Commit Diff


commit - 8b6932843b87183548532dae81ebad384663950a
commit + e059bbcc10b86ee36a3b805c784b4057c0970bee
blob - 269c1b36d07fd286e1773f1a58763b2b5e5182c3
blob + df2d90ea0e7fe4d18d6356afd2387738fd7053cb
--- contrib/imapduser.8
+++ contrib/imapduser.8
@@ -2,7 +2,7 @@
 .\"
 .\" Written for the OpenIMAPD project. Public domain / no rights reserved.
 .\"
-.Dd $Mdocdate: September 9 2026 $
+.Dd $Mdocdate: September 18 2026 $
 .Dt IMAPDUSER 8
 .Os
 .Sh NAME
blob - 79a3db86c5d124e3e3840c6d57cf4566a6586c13
blob + 0b06f37a593afdff9c8300f25cf94fe6ea57708b
--- src/append_cmd.c
+++ src/append_cmd.c
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  *
@@ -14,7 +16,7 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* append_cmd.c: APPEND literal-driven message upload and its async IMSG_MBOX_APPENDED completion handling. */
+/* append_cmd.c: APPEND literal upload, async IMSG_MBOX_APPENDED completion. */
 
 #include <sys/types.h>
 #include <sys/queue.h>
@@ -40,7 +42,7 @@
 #include "listener.h"
 #include "mboxname.h"
 
-/* RFC 9051 SS9 date-time via sscanf(3); calendar validity (e.g. Feb 31) unchecked, timegm(3) normalizes it. */
+/* RFC 9051 SS9 date-time via sscanf(3); timegm(3) normalizes bad dates. */
 int
 parse_date_time(const char *s, int64_t *out)
 {
@@ -83,7 +85,11 @@ parse_date_time(const char *s, int64_t *out)
 	if (zsign == '-')
 		zoff = -zoff;
 
-	/* Reject out-of-range zone offsets -- sscanf/RFC 9051 don't bound them, and an extreme offset can produce a maildir basename unsafe for shell globs. */
+	/*
+	 * Reject out-of-range zone offsets -- sscanf/RFC 9051 don't bound
+	 * them, and an extreme offset can produce a maildir basename unsafe
+	 * for shell globs.
+	 */
 	if ((int64_t)t - zoff < 0)
 		return (-1);
 
@@ -91,7 +97,7 @@ parse_date_time(const char *s, int64_t *out)
 	return (0);
 }
 
-/* Result struct for parse_append_args(), avoids an unwieldy number of out-parameters. */
+/* Result struct for parse_append_args(), avoids many out-parameters. */
 struct append_parsed {
 	char		mailbox[MBOX_NAME_MAX];
 	uint32_t	sysflags;
@@ -102,7 +108,7 @@ struct append_parsed {
 	int		litnonsync;
 };
 
-/* RFC 9051 SS6.3.12 append grammar; flag-list parsing reused from parse_store_flags(). */
+/* RFC 9051 SS6.3.12 append grammar; reuses parse_store_flags() for flags. */
 int
 parse_append_args(char *args, struct append_parsed *out, const char **errmsg)
 {
@@ -116,7 +122,7 @@ parse_append_args(char *args, struct append_parsed *ou
 		return (-1);
 	}
 
-	/* one parser for every mailbox argument in the tree; see mailbox_cmd.c */
+	/* one parser for every mailbox argument; see mailbox_cmd.c */
 	if (parse_mailbox_name(&p, out->mailbox, sizeof(out->mailbox),
 	    errmsg) == -1)
 		return (-1);
@@ -209,7 +215,12 @@ parse_append_args(char *args, struct append_parsed *ou
 		memcpy(digitsbuf, start, digits_len);
 		digitsbuf[digits_len] = '\0';
 
-		/* RFC 9051 SS9's number64 is unsigned, but strtoull(3) accepts a sign, so "{-1}" would arrive as ULLONG_MAX and get a misleading NO [LIMIT] instead of BAD -- same digit guard listener.c's literal pre-scan already applies. */
+		/*
+		 * RFC 9051 SS9's number64 is unsigned, but strtoull(3) accepts
+		 * a sign, so "{-1}" would arrive as ULLONG_MAX and get a
+		 * misleading NO [LIMIT] instead of BAD -- same digit guard
+		 * listener.c's literal pre-scan already applies.
+		 */
 		if (digitsbuf[0] < '0' || digitsbuf[0] > '9') {
 			*errmsg = "malformed literal octet count";
 			return (-1);
@@ -223,7 +234,10 @@ parse_append_args(char *args, struct append_parsed *ou
 		out->litlen = (uint64_t)litlen;
 
 		if (out->litnonsync && out->litlen > 4096) {
-			/* RFC 9051 SS4.3: non-sync literals capped at 4096 octets, BAD, not cmd_append()'s NO size cap. */
+			/*
+			 * RFC 9051 SS4.3: non-sync literals capped at 4096B,
+			 * BAD not NO.
+			 */
 			*errmsg = "non-synchronizing literal exceeds RFC "
 			    "9051 SS4.3's 4096-octet limit, use a "
 			    "synchronizing literal instead";
@@ -239,11 +253,12 @@ parse_append_args(char *args, struct append_parsed *ou
 	return (0);
 }
 
-/* Parses the literal announcement, allocates s->literal_buf, and enters literal-read mode (s->literal_pending). */
+/* Parses the literal announcement, starts the store's file, reads the rest. */
 int
 cmd_append(struct session *s, const char *tag, char *args)
 {
 	struct append_parsed	 parsed;
+	struct imsg_mbox_append	 req;
 	int			 rc;
 	const char		*errmsg;
 
@@ -257,56 +272,75 @@ cmd_append(struct session *s, const char *tag, char *a
 		return (1);
 	}
 
-	if (parsed.litlen > APPEND_LITERAL_MAX) {
-		/* RFC 5530 LIMIT code, matches APPEND_LITERAL_MAX's situation precisely. */
-		session_reply(s, tag, "NO",
-		    "[LIMIT] message too large for this server "
-		    "limit, see APPEND_LITERAL_MAX)");
+	if (parsed.litlen > listener_append_max) {
+		char	text[96];
+
+		/*
+		 * RFC 5530 LIMIT code. The text states the number, as the
+		 * example in RFC 9051 SS7.1 does, since it reaches the client.
+		 */
+		(void)snprintf(text, sizeof(text), "[LIMIT] message exceeds "
+		    "this server's %llu octet limit",
+		    (unsigned long long)listener_append_max);
+		session_reply(s, tag, "NO", text);
 		return (1);
 	}
 
 	if (s->store_iev == NULL) {
-		/* Same store_iev invariant as cmd_select()/cmd_fetch(), ST_AUTH requires it already wired. */
+		/*
+		 * Same store_iev invariant as cmd_select()/cmd_fetch(); ST_AUTH
+		 * needs it.
+		 */
 		log_warnx("session %u: APPEND with no store channel wired",
 		    s->id);
 		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
 		return (1);
 	}
 
-	if (parsed.litlen > 0) {
-		if ((s->literal_buf = malloc((size_t)parsed.litlen)) ==
-		    NULL) {
-			log_warn("session %u: malloc APPEND literal buffer",
-			    s->id);
-			session_reply(s, tag, "NO", "[SERVERBUG] internal error");
-			return (1);
-		}
-	} else
-		s->literal_buf = NULL;	/* zero-length literal; session_dispatch_client() handles it without special-casing */
-
+	memset(&req, 0, sizeof(req));
 	if (strlcpy(s->append_mailbox, parsed.mailbox,
 	    sizeof(s->append_mailbox)) >= sizeof(s->append_mailbox) ||
-	    strlcpy(s->append_keywords, parsed.keywords,
-	    sizeof(s->append_keywords)) >= sizeof(s->append_keywords)) {
+	    strlcpy(req.mailbox, parsed.mailbox, sizeof(req.mailbox)) >=
+	    sizeof(req.mailbox) ||
+	    strlcpy(req.keywords, parsed.keywords, sizeof(req.keywords)) >=
+	    sizeof(req.keywords)) {
 		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
 		return (1);
 	}
-	s->append_sysflags = parsed.sysflags;
-	s->append_has_date = parsed.has_date;
-	s->append_date = parsed.date;
+	req.sysflags = parsed.sysflags;
+	req.has_date = parsed.has_date;
+	req.date = parsed.date;
+	req.msglen = parsed.litlen;
 	s->append_prev_state = s->state;
 
-	/* tag is already IMAP_TAG_MAX-bounded by session_handle_line(); re-checked here defensively. */
+	/* tag is IMAP_TAG_MAX-bounded by session_handle_line(); rechecked */
 	if (strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
 	    sizeof(s->pending_tag)) {
 		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
 		return (1);
 	}
+
+	/*
+	 * The store opens the message's tmp/ file now and is sent the literal
+	 * in pieces as it arrives, rather than this process holding it whole.
+	 */
+	if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_APPEND, 0, 0, -1,
+	    &req, sizeof(req)) == -1) {
+		log_warn("session %u: imsg_compose IMSG_MBOX_APPEND", s->id);
+		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+		return (1);
+	}
+
 	s->literal_len = parsed.litlen;
 	s->literal_remaining = parsed.litlen;
 	s->literal_pending = 1;
 
-	/* RFC 9051 SS4.3: "+" continuation is only for synchronizing literals. Uses sizeof()-1, not a hand count -- a wrong hand-counted length once wrote a stray NUL onto the wire, same idiom as listener.c's session_write() calls. */
+	/*
+	 * RFC 9051 SS4.3: "+" continuation is only for synchronizing
+	 * literals. Uses sizeof()-1, not a hand count -- a wrong
+	 * hand-counted length once wrote a stray NUL onto the wire, same
+	 * idiom as listener.c's session_write() calls.
+	 */
 	if (!parsed.litnonsync) {
 		static const char cont[] = "+ Ready for literal data\r\n";
 
@@ -316,78 +350,39 @@ cmd_append(struct session *s, const char *tag, char *a
 	return (1);
 }
 
-/* Builds the combined header+message imsg, enters SESSION_APPENDING; s->literal_buf is freed either way. */
+/* Literal and its CRLF are in: tells the store to commit the message. */
 int
 session_finish_append(struct session *s)
 {
-	struct imsg_mbox_append	 req;
-	char				*combined;
-	size_t				 combined_len;
-
-	memset(&req, 0, sizeof(req));
-	if (strlcpy(req.mailbox, s->append_mailbox, sizeof(req.mailbox)) >=
-	    sizeof(req.mailbox) ||
-	    strlcpy(req.keywords, s->append_keywords, sizeof(req.keywords)) >=
-	    sizeof(req.keywords)) {
-		log_warnx("session %u: APPEND mailbox/keywords truncated, "
-		    "can't happen (both already bounded when first stored)",
-		    s->id);
-		session_reply(s, s->pending_tag, "NO", "[SERVERBUG] internal error");
-		free(s->literal_buf);
-		s->literal_buf = NULL;
-		s->state = s->append_prev_state;
-		return (1);
-	}
-	req.sysflags = s->append_sysflags;
-	req.has_date = s->append_has_date;
-	req.date = s->append_date;
-	req.msglen = (uint32_t)s->literal_len;
-
 	if (s->store_iev == NULL) {
 		log_warnx("session %u: APPEND with no store channel wired "
 		    "(literal already read)", s->id);
-		session_reply(s, s->pending_tag, "NO", "[SERVERBUG] internal error");
-		free(s->literal_buf);
-		s->literal_buf = NULL;
-		s->state = s->append_prev_state;
+		session_reply(s, s->pending_tag, "NO",
+		    "[SERVERBUG] internal error");
 		return (1);
 	}
 
-	combined_len = sizeof(req) + (size_t)s->literal_len;
-	if ((combined = malloc(combined_len)) == NULL) {
-		log_warn("session %u: malloc APPEND imsg buffer", s->id);
-		session_reply(s, s->pending_tag, "NO", "[SERVERBUG] internal error");
-		free(s->literal_buf);
-		s->literal_buf = NULL;
-		s->state = s->append_prev_state;
-		return (1);
-	}
-	memcpy(combined, &req, sizeof(req));
-	if (s->literal_len > 0)
-		memcpy(combined + sizeof(req), s->literal_buf,
-		    (size_t)s->literal_len);
-
-	free(s->literal_buf);
-	s->literal_buf = NULL;
-
 	s->state = SESSION_APPENDING;
 
-	/* Same fail-soft shape as send_mbox_request(): without it, a compose failure leaves s->state stuck at SESSION_APPENDING and session_is_busy() blocks every further command. */
-	if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_APPEND, 0, 0, -1,
-	    combined, combined_len) == -1) {
-		log_warn("session %u: imsg_compose IMSG_MBOX_APPEND", s->id);
-		free(combined);
+	/*
+	 * Same fail-soft shape as send_mbox_request(): without it, a
+	 * compose failure leaves s->state stuck at SESSION_APPENDING and
+	 * session_is_busy() blocks every further command.
+	 */
+	if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_APPEND_END, 0, 0, -1,
+	    NULL, 0) == -1) {
+		log_warn("session %u: imsg_compose IMSG_MBOX_APPEND_END",
+		    s->id);
 		s->state = s->append_prev_state;
 		session_reply(s, s->pending_tag, "NO",
 		    "[SERVERBUG] internal error");
 		return (1);
 	}
-	free(combined);
 
 	return (1);
 }
 
-/* Terminal APPEND reply; restores s->state to s->append_prev_state (AUTHENTICATED or SELECTED). */
+/* Terminal APPEND reply; restores s->state to s->append_prev_state. */
 void
 session_handle_mbox_appended(struct session *s,
     const struct imsg_mbox_appended *res)
@@ -399,14 +394,24 @@ session_handle_mbox_appended(struct session *s,
 	if (res->error != MBOX_OP_OK) {
 		if (res->error == MBOX_OP_ERR_NO_SUCH_MAILBOX)
 			session_reply(s, s->pending_tag, "NO",
-			    "[TRYCREATE] no such mailbox");	/* SS6.3.12: reports why, not a promise CREATE would help */
+			    /*
+			     * SS6.3.12: reports why, not a promise CREATE would
+			     * help
+			     */
+			    "[TRYCREATE] no such mailbox");
+		else if (res->error == MBOX_OP_ERR_BUSY)
+			session_reply(s, s->pending_tag, "NO",
+			    IMAP_BUSY_TEXT);
 		else
 			session_reply(s, s->pending_tag, "NO",
 			    "APPEND failed");
 		return;
 	}
 
-	/* INBOX compared case-insensitively (SS5.1); any other mailbox name case-sensitively. */
+	/*
+	 * INBOX compared case-insensitively (SS5.1); other names
+	 * case-sensitively.
+	 */
 	if (mailbox_name_is_inbox(s->append_mailbox) &&
 	    mailbox_name_is_inbox(s->selected_mailbox))
 		appended_to_selected = 1;
blob - 27fa92b6d7ede434293de8142b8d3afcc477c6c6
blob + 9e9fdeab180b7849b4578bc3b08810eb0bdc06be
--- src/auth.c
+++ src/auth.c
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  *
@@ -14,7 +16,7 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* auth.c, credential verification process: AUTHENTICATE PLAIN against the flat cred file. */
+/* auth.c: credential verification, AUTHENTICATE PLAIN vs flat cred file. */
 
 #include <sys/types.h>
 #include <sys/stat.h>
@@ -42,7 +44,8 @@ struct cred_entry {
 };
 
 static struct imsgev	 iev_listener;
-static struct imsgev	 iev_parent;	/* fd 3, alive for the process's lifetime */
+/* fd 3, alive for the process's lifetime */
+static struct imsgev	 iev_parent;
 static char		 cred_file_basename[256];
 
 static int	 cred_lookup(const char *, const char *username,
@@ -52,8 +55,19 @@ 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: refuses a second IMSG_AUTH_REQUEST for an already-resolved session_id, defending against a compromised/buggy listener replaying a grant; tracked in a fixed-size ring (not a TAILQ) since auth is never notified of session end, and session_id is unique-per-daemon so an evicted entry is harmless. */
-/* Caps bcrypt-costing auth attempts per connection (sshd's MaxAuthTries default) so a client retrying without limit can't convert cheap packets into unbounded server CPU; past the cap, requests are refused without calling crypt_checkpass(3), closing the cost asymmetry (though not dropping the connection). */
+/*
+ * Refuses a second IMSG_AUTH_REQUEST for an already-resolved session_id,
+ * defending against a compromised/buggy listener replaying a grant; tracked in
+ * a fixed-size ring (not a TAILQ) since auth is never notified of session end,
+ * and session_id is unique-per-daemon so an evicted entry is harmless.
+ */
+/*
+ * Caps bcrypt-costing auth attempts per connection (sshd's MaxAuthTries
+ * default) so a client retrying without limit can't convert cheap packets into
+ * unbounded server CPU; past the cap, requests are refused without calling
+ * crypt_checkpass(3), closing the cost asymmetry (though not dropping the
+ * connection).
+ */
 #define AUTH_MAX_TRIES	6
 static unsigned int	 auth_failures;
 
@@ -65,7 +79,8 @@ static int	 auth_resolved_full;	/* 1 once the ring has
 static int
 auth_session_already_resolved(uint32_t sid)
 {
-	size_t	limit = auth_resolved_full ? AUTH_RESOLVED_MAX : auth_resolved_next;
+	size_t	limit = auth_resolved_full ? AUTH_RESOLVED_MAX :
+	    auth_resolved_next;
 	size_t	i;
 
 	for (i = 0; i < limit; i++) {
@@ -97,10 +112,16 @@ auth_main(void)
 	char			 chrootdir[1024];
 	ssize_t			 n;
 
-	/* fd-passing is allowed on this channel for the IMSG_SETUP_PEER peer fd below; see imsgev_ibuf_init()'s own comment */
+	/*
+	 * fd-passing allowed here for IMSG_SETUP_PEER below; see
+	 * imsgev_ibuf_init()
+	 */
 	imsgev_ibuf_init(&ibuf3, 3);
 
-	/* IMSG_AUTH_INIT must be read first: cred_file is needed before chroot() can be computed */
+	/*
+	 * IMSG_AUTH_INIT read first: cred_file needed before chroot() is
+	 * computed
+	 */
 	for (;;) {
 		if ((n = imsgbuf_get(&ibuf3, &imsg)) == -1)
 			fatal("imsgbuf_get");
@@ -116,7 +137,11 @@ 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, since strlcpy(3) would otherwise read unboundedly past this stack struct while computing the chroot(2) target. */
+	/*
+	 * imsg_get_data() guarantees size, not NUL termination -- force it,
+	 * since strlcpy(3) would otherwise read unboundedly past this stack
+	 * struct while computing the chroot(2) target.
+	 */
 	init.cred_file[sizeof(init.cred_file) - 1] = '\0';
 	imsg_free(&imsg);
 
@@ -125,11 +150,19 @@ auth_main(void)
 		fatalx("getpwnam _imapauth: no such user "
 		    "(expected, not yet provisioned by an install script)");
 
-	/* chroot into the dir containing the cred file, not the file itself; basename kept for unveil() */
+	/*
+	 * chroot into dir holding cred file, not itself; basename kept for
+	 * unveil()
+	 */
 	if (strlcpy(chrootdir, init.cred_file, sizeof(chrootdir)) >=
 	    sizeof(chrootdir))
 		fatalx("cred_file too long: %s", init.cred_file);
-	/* Actually verifies what the fatalx() below claims: strrchr() finding '/' only proves a directory component exists, not an absolute path (e.g. "etc/creds" would otherwise chroot(2) relative to cwd), and parse.y doesn't enforce this upstream. */
+	/*
+	 * Actually verifies what the fatalx() below claims: strrchr() finding
+	 * '/' only proves a directory component exists, not an absolute path
+	 * (e.g. "etc/creds" would otherwise chroot(2) relative to cwd), and
+	 * parse.y doesn't enforce this upstream.
+	 */
 	if (init.cred_file[0] != '/')
 		fatalx("cred_file must be an absolute path: %s",
 		    init.cred_file);
@@ -140,7 +173,8 @@ auth_main(void)
 	    sizeof(cred_file_basename)) >= sizeof(cred_file_basename))
 		fatalx("cred_file basename too long: %s", init.cred_file);
 	if (slash == chrootdir)
-		chrootdir[1] = '\0';	/* "/creds" -> chroot("/"), not chroot("") */
+		/* "/creds" -> chroot("/"), not chroot("") */
+		chrootdir[1] = '\0';
 	else
 		*slash = '\0';
 
@@ -154,7 +188,16 @@ auth_main(void)
 	    setresuid(pw->pw_uid, pw->pw_uid, pw->pw_uid) == -1)
 		fatal("cannot drop privileges to _imapauth");
 
-	/* SS7: wires auth-worker's one and only peer via parent.c's spawn_connection()/setup_peer_send(), with no IMSG_SETUP_DONE ack needed since this boot sequence already reads a fixed, statically-known message set before touching the event loop. */
+	/* no session id yet; retitled on the first request below */
+	/* the imsg id cannot carry one: listener.c reads id 0 as "auth peer" */
+	setproctitle("auth");
+
+	/*
+	 * Wires auth-worker's one and only peer via parent.c's
+	 * spawn_connection()/setup_peer_send(), with no IMSG_SETUP_DONE ack
+	 * needed since this boot sequence already reads a fixed,
+	 * statically-known message set before touching the event loop.
+	 */
 	peer_fd = setup_recv_one_peer(&ibuf3);
 
 	event_init();
@@ -174,7 +217,13 @@ auth_main(void)
 			fatal("unveil lock");
 	}
 
-	/* No recvfd, no sendfd: this process gets its one peer fd via setup_recv_one_peer() before this line and never attaches a descriptor to an imsg itself (only parent.c does); a plain imsg with fd == -1 needs neither pledge promise, and a wrong guess here is an uncatchable SIGABRT, not silent breakage. */
+	/*
+	 * No recvfd, no sendfd: this process gets its one peer fd via
+	 * setup_recv_one_peer() before this line and never attaches a
+	 * descriptor to an imsg itself (only parent.c does); a plain imsg with
+	 * fd == -1 needs neither pledge promise, and a wrong guess here is an
+	 * uncatchable SIGABRT, not silent breakage.
+	 */
 #ifdef __OpenBSD__
 	if (pledge("stdio rpath", NULL) == -1)
 		fatal("pledge");
@@ -184,7 +233,7 @@ auth_main(void)
 	fatalx("auth: exited event loop");
 }
 
-/* EV_WRITE must be handled: imsg_compose() only queues, imsgbuf_write() puts it on the wire */
+/* EV_WRITE must be handled: imsg_compose() queues, imsgbuf_write() sends */
 static void
 auth_dispatch(int fd, short event, void *arg)
 {
@@ -201,7 +250,13 @@ auth_dispatch(int fd, short event, void *arg)
 		if ((n = imsgbuf_read(&iev->ibuf)) == -1)
 			fatal("imsgbuf_read");
 		if (n == 0) {
-			/* SS7: this auth-worker was spawned to serve exactly one connection and will never serve another, so it exits here on listener EOF rather than idling in event_dispatch() forever -- the ordinary, expected end of a session per parent.c's reap_child(). */
+			/*
+			 * This auth-worker was spawned to serve exactly one
+			 * connection and will never serve another, so it exits
+			 * here on listener EOF rather than idling in
+			 * event_dispatch() forever -- the ordinary, expected
+			 * end of a session per parent.c's reap_child().
+			 */
 			log_debug("auth-worker: listener closed channel, "
 			    "exiting");
 			exit(0);
@@ -223,15 +278,22 @@ auth_dispatch(int fd, short event, void *arg)
 				log_warnx("bad IMSG_AUTH_REQUEST");
 				break;
 			}
-			/* imsg_get_data() guarantees size, not NUL termination, force it */
+			/*
+			 * imsg_get_data() guarantees size, not NUL termination,
+			 * force it
+			 */
 			req.username[sizeof(req.username) - 1] = '\0';
 			req.password[sizeof(req.password) - 1] = '\0';
 
+			/* this worker's session; one worker per session */
+			setproctitle("session %u auth", req.session_id);
+
 			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));
+				    "session already successfully "
+				    "authenticated, refusing", req.session_id);
+				explicit_bzero(req.password,
+				    sizeof(req.password));
 				break;
 			}
 
@@ -255,7 +317,12 @@ auth_dispatch(int fd, short event, void *arg)
 				cred.session_id = res.session_id;
 				cred.uid = res.uid;
 				cred.gid = res.gid;
-				/* cred.maildir and res.maildir are both sized AUTH_MAILDIR_MAX, so truncation is structurally impossible and the strlcpy() return value is discarded deliberately. */
+				/*
+				 * cred.maildir and res.maildir are both sized
+				 * AUTH_MAILDIR_MAX, so truncation is
+				 * structurally impossible and the strlcpy()
+				 * return value is discarded deliberately.
+				 */
 				(void)strlcpy(cred.maildir, res.maildir,
 				    sizeof(cred.maildir));
 				if (imsg_compose(&iev_parent.ibuf,
@@ -276,7 +343,7 @@ auth_dispatch(int fd, short event, void *arg)
 	(void)fd;
 }
 
-/* parent never sends auth anything post-boot, so this exists to flush queued IMSG_AUTH_CRED writes and notice if parent's end closes */
+/* parent never sends auth anything post-boot; flushes CRED writes, sees EOF */
 static void
 auth_dispatch_parent(int fd, short event, void *arg)
 {
@@ -313,7 +380,11 @@ auth_dispatch_parent(int fd, short event, void *arg)
 	(void)fd;
 }
 
-/* Usernames come off the network and reach syslog only through this: anything outside printable ASCII becomes '?', since auth can't assume listener.c's CR/LF rejection held. */
+/*
+ * Usernames come off the network and reach syslog only through this: anything
+ * outside printable ASCII becomes '?', since auth can't assume listener.c's
+ * CR/LF rejection held.
+ */
 static void
 auth_safe_name(const char *in, char *out, size_t outsize)
 {
@@ -327,7 +398,7 @@ auth_safe_name(const char *in, char *out, size_t outsi
 	out[i] = '\0';
 }
 
-/* always calls crypt_checkpass() with hash == NULL on unknown username, to avoid timing leaks */
+/* calls crypt_checkpass() with hash NULL on unknown user, avoids timing leak */
 static void
 auth_verify(struct imsg_auth_request *req, struct imsg_auth_result *res)
 {
@@ -336,7 +407,11 @@ auth_verify(struct imsg_auth_request *req, struct imsg
 	int			 found;
 	char			 safename[AUTH_USERNAME_MAX];
 
-	/* Budget spent: refuses before cred_lookup() so a client past AUTH_MAX_TRIES can't even trigger a re-read/re-scan of the credential file; reported identically to any other failure. */
+	/*
+	 * Budget spent: refuses before cred_lookup() so a client past
+	 * AUTH_MAX_TRIES can't even trigger a re-read/re-scan of the credential
+	 * file; reported identically to any other failure.
+	 */
 	if (auth_failures >= AUTH_MAX_TRIES) {
 		char	 overname[AUTH_USERNAME_MAX];
 
@@ -363,12 +438,18 @@ auth_verify(struct imsg_auth_request *req, struct imsg
 		auth_failures++;
 	}
 
-	/* Logs every authentication outcome (previously nothing did, leaving password-guessing runs untraceable for fail2ban-style tooling); log_info() is always emitted, and the failure line deliberately doesn't distinguish "no such user" from "wrong password" to avoid an enumeration oracle. */
+	/*
+	 * Logs every authentication outcome (previously nothing did, leaving
+	 * password-guessing runs untraceable for fail2ban-style tooling);
+	 * log_info() is always emitted, and the failure line deliberately
+	 * doesn't distinguish "no such user" from "wrong password" to avoid an
+	 * enumeration oracle.
+	 */
 	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);
+		    (unsigned int)res->uid);
 	else
 		log_info("session %u: authentication failed for \"%s\"",
 		    req->session_id, safename);
@@ -376,7 +457,14 @@ auth_verify(struct imsg_auth_request *req, struct imsg
 	explicit_bzero(&ce, sizeof(ce));
 }
 
-/* True if a privileged file at st is safe to trust here: owned by root or the current (post-chroot, post-setresuid) uid, and not group-writable, group-executable, or accessible to world at all; same policy parse.y's check_file_secrecy() applies to imapd.conf (parse.y:678-695), kept as its own function since that one runs pre-privsep against an fd the parent still owns and logs a different message. */
+/*
+ * True if a privileged file at st is safe to trust here: owned by root or the
+ * current (post-chroot, post-setresuid) uid, and not group-writable,
+ * group-executable, or accessible to world at all; same policy parse.y's
+ * check_file_secrecy() applies to imapd.conf (parse.y:678-695), kept as its own
+ * function since that one runs pre-privsep against an fd the parent still owns
+ * and logs a different message.
+ */
 static int
 cred_file_secure(const struct stat *st)
 {
@@ -400,7 +488,15 @@ cred_lookup(const char *path, const char *username, st
 		return (-1);
 	}
 
-	/* Rejects a credentials file not owned by root or the current uid, or that is group-writable, group-executable, or accessible to world at all (parent.c already enforces an equivalent policy for the TLS key, and parse.y's check_file_secrecy() for imapd.conf itself; this file holding every bcrypt hash had no such check until this one); group-read stays permissive so the documented "root:_imapauth 0640" layout keeps working, and a bad mode or owner fails closed. */
+	/*
+	 * Rejects a credentials file not owned by root or the current uid, or
+	 * that is group-writable, group-executable, or accessible to world at
+	 * all (parent.c already enforces an equivalent policy for the TLS key,
+	 * and parse.y's check_file_secrecy() for imapd.conf itself; this file
+	 * holding every bcrypt hash had no such check until this one);
+	 * group-read stays permissive so the documented "root:_imapauth 0640"
+	 * layout keeps working, and a bad mode or owner fails closed.
+	 */
 	{
 		struct stat	 st;
 
@@ -415,8 +511,8 @@ cred_lookup(const char *path, const char *username, st
 			    "not group-writable, group-executable, or "
 			    "world-accessible; refusing all authentication "
 			    "until this is fixed",
-			    path, (unsigned)(st.st_mode & 07777),
-			    (unsigned)st.st_uid);
+			    path, (unsigned int)(st.st_mode & 07777),
+			    (unsigned int)st.st_uid);
 			fclose(fp);
 			return (-1);
 		}
@@ -429,7 +525,11 @@ cred_lookup(const char *path, const char *username, st
 		char		*ep;
 		unsigned long	 ulval;
 
-		/* fgets(3) silently splits an over-long line, which would otherwise parse the tail as a phantom credential entry -- refuses to read the whole file rather than guess. */
+		/*
+		 * fgets(3) silently splits an over-long line, which would
+		 * otherwise parse the tail as a phantom credential entry --
+		 * refuses to read the whole file rather than guess.
+		 */
 		if (strchr(line, '\n') == NULL &&
 		    strlen(line) == sizeof(line) - 1) {
 			log_warnx("%s: over-long line, refusing to parse the "
@@ -456,16 +556,32 @@ cred_lookup(const char *path, const char *username, st
 		if (strcmp(fields[0], username) != 0)
 			continue;
 
-		/* skip rather than silently truncate a field, same as every other malformed-line case */
+		/*
+		 * skip rather than truncate a field, same as other malformed
+		 * lines
+		 */
 		if (strlcpy(out->username, fields[0], sizeof(out->username))
 		    >= sizeof(out->username) ||
 		    strlcpy(out->passwordhash, fields[1],
 		    sizeof(out->passwordhash)) >= sizeof(out->passwordhash))
 			continue;
-		/* Requires a bcrypt hash ("$2" prefix): crypt_checkpass(3) treats an empty stored hash plus empty password as a successful login rather than a disabled account, so a blank field must be rejected here, and non-bcrypt values are skipped the same way as every other malformed entry to avoid an enumeration oracle -- use imapduser -d to disable an account instead. */
+		/*
+		 * Requires a bcrypt hash ("$2" prefix): crypt_checkpass(3)
+		 * treats an empty stored hash plus empty password as a
+		 * successful login rather than a disabled account, so a blank
+		 * field must be rejected here, and non-bcrypt values are
+		 * skipped the same way as every other malformed entry to avoid
+		 * an enumeration oracle -- use imapduser -d to disable an
+		 * account instead.
+		 */
 		if (fields[1][0] != '$' || fields[1][1] != '2')
 			continue;
-		/* strtoul(3) accepts a leading '-', so "-1" would parse as 0xffffffff (and "0" is root); the credential file shouldn't be able to express either uid/gid, so both are rejected here before the wrap case slips through unnoticed. */
+		/*
+		 * strtoul(3) accepts a leading '-', so "-1" would parse as
+		 * 0xffffffff (and "0" is root); the credential file shouldn't
+		 * be able to express either uid/gid, so both are rejected here
+		 * before the wrap case slips through unnoticed.
+		 */
 		if (fields[2][0] < '0' || fields[2][0] > '9' ||
 		    fields[3][0] < '0' || fields[3][0] > '9')
 			continue;
@@ -488,7 +604,12 @@ 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() and auth_dispatch() scrub their own copies, so this buffer is the one left behind. */
+	/*
+	 * line[] held the raw credential record (username, bcrypt hash, uid,
+	 * gid, maildir) for every entry scanned; auth_verify() and
+	 * auth_dispatch() scrub their own copies, so this buffer is the one
+	 * left behind.
+	 */
 	explicit_bzero(line, sizeof(line));
 	fclose(fp);
 	return (found ? 0 : -1);
blob - 86be637642814c5966c967710f302bf7574884b3
blob + f40fc88dc9f436f73ddc512da25a07fc77ded283
--- src/auth_cmd.c
+++ src/auth_cmd.c
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  *
@@ -14,7 +16,7 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* auth_cmd.c: CAPABILITY/NOOP/LOGOUT/ID/LOGIN/STARTTLS/AUTHENTICATE/ENABLE handlers that don't need a mailbox session. */
+/* auth_cmd.c: CAPABILITY/NOOP/LOGOUT/ID/LOGIN/AUTH/STARTTLS/ENABLE handlers. */
 
 #include <sys/types.h>
 #include <sys/queue.h>
@@ -40,17 +42,21 @@
 #include "listener.h"
 
 /* RFC 9051 SS6.1.1 capability strings, selected by session->tls_active. */
-#define CAPABILITY_PRE_TLS	"IMAP4rev2 STARTTLS LOGINDISABLED ID CONDSTORE QRESYNC"
-#define CAPABILITY_POST_TLS	"IMAP4rev2 AUTH=PLAIN LOGINDISABLED ID CONDSTORE QRESYNC"
+#define CAPABILITY_PRE_TLS	"IMAP4rev2 STARTTLS LOGINDISABLED ID " \
+				"CONDSTORE QRESYNC"
+#define CAPABILITY_POST_TLS	"IMAP4rev2 AUTH=PLAIN LOGINDISABLED ID " \
+				"CONDSTORE QRESYNC"
 
 
 int
 cmd_capability(struct session *s, const char *tag, char *args)
 {
-	(void)args;	/* RFC 9051: "Arguments: none", extra args ignored, not rejected */
+	/* RFC 9051: "Arguments: none", extra args ignored, not rejected */
+	(void)args;
 
 	session_untagged(s, s->tls_active ?
-	    "CAPABILITY " CAPABILITY_POST_TLS : "CAPABILITY " CAPABILITY_PRE_TLS);
+	    "CAPABILITY " CAPABILITY_POST_TLS :
+	    "CAPABILITY " CAPABILITY_PRE_TLS);
 	session_reply(s, tag, "OK", "CAPABILITY completed");
 	return (1);
 }
@@ -73,7 +79,7 @@ cmd_logout(struct session *s, const char *tag, char *a
 	/* Exact example text from RFC 9051 SS6.1.3. */
 	session_untagged(s, "BYE IMAP4rev2 Server logging out");
 	session_reply(s, tag, "OK", "LOGOUT completed");
-	session_teardown(s);
+	session_teardown(s, "logout");
 	return (0);
 }
 
@@ -81,7 +87,7 @@ cmd_logout(struct session *s, const char *tag, char *a
 int
 cmd_id(struct session *s, const char *tag, char *args)
 {
-	/* RFC 2971 SS3.1: field/value list not parsed, just logged and discarded; always replies NIL per SS3.2 */
+	/* RFC 2971 SS3.1: field/value list logged, not parsed; replies NIL */
 	log_debug("session %u: ID params: %s", s->id,
 	    args != NULL ? args : "(none)");
 	session_untagged(s, "ID NIL");
@@ -90,13 +96,14 @@ cmd_id(struct session *s, const char *tag, char *args)
 }
 
 
-/* LOGIN is permanently disabled, matching LOGINDISABLED in both CAPABILITY strings above. */
+/* LOGIN permanently disabled, matching LOGINDISABLED in CAPABILITY strings. */
 int
 cmd_login(struct session *s, const char *tag, char *args)
 {
 	(void)args;
 
-	session_reply(s, tag, "NO", "LOGIN not supported, use AUTHENTICATE PLAIN");
+	session_reply(s, tag, "NO",
+	    "LOGIN not supported, use AUTHENTICATE PLAIN");
 	return (1);
 }
 
@@ -110,29 +117,50 @@ cmd_starttls(struct session *s, const char *tag, char 
 		return (1);
 	}
 	if (s->tls_active) {
-		/* RFC 9051 SS6.2.1: BAD if STARTTLS received after negotiation */
+		/* RFC 9051 SS6.2.1: BAD if STARTTLS comes after negotiation */
 		session_reply(s, tag, "BAD", "TLS already active");
 		return (1);
 	}
 	if (listener_tls_ctx == NULL) {
-		/* RFC 9051 SS6.2.1 NO + RFC 5530 UNAVAILABLE: cert/key loading failed at boot */
+		/*
+		 * RFC 9051 SS6.2.1 NO + RFC 5530 UNAVAILABLE: cert/key load
+		 * failed at boot
+		 */
 		session_reply(s, tag, "NO",
 		    "[UNAVAILABLE] TLS negotiation unavailable");
 		return (1);
 	}
 
-	session_reply(s, tag, "OK", "Begin TLS negotiation now");	/* must precede TLS start, so goes out in cleartext */
+	/* precedes TLS */
+	session_reply(s, tag, "OK", "Begin TLS negotiation now");
 
-	s->inbuflen = 0;	/* command-injection mitigation: discard plaintext already buffered past this line */
+	/* command-injection mitigation: discard buffered plaintext */
+	s->inbuflen = 0;
 
 	session_tls_start(s);
 	return (1);
 }
 
-/* RFC 4616 SS2: authzid/authcid/passwd each up to 255 octets + 2 NUL delimiters = 767, rounded up */
+/* RFC 4616 SS2: authzid/authcid/passwd up to 255 octets + 2 NULs = 767 */
 #define SASL_PLAIN_MAX	768
 
-/* decodes+verifies one SASL PLAIN message (RFC 4616 SS2), sends IMSG_AUTH_REQUEST; never tears down the session, always returns 1 */
+/* Stores the username for the close line; mirrors auth.c's auth_safe_name() */
+/* non-printable becomes '?', so s->user is safe wherever it is logged */
+static void
+session_set_user(struct session *s, const unsigned char *in,
+    size_t inlen)
+{
+	size_t	 i;
+
+	for (i = 0; i < inlen && i + 1 < sizeof(s->user); i++) {
+		unsigned char	 c = in[i];
+
+		s->user[i] = (c >= 0x20 && c < 0x7f) ? (char)c : '?';
+	}
+	s->user[i] = '\0';
+}
+
+/* decodes+verifies one SASL PLAIN msg (RFC 4616 SS2); never tears down */
 int
 sasl_plain_finish(struct session *s, const char *tag, const char *b64,
     int allow_empty_equals)
@@ -178,15 +206,20 @@ sasl_plain_finish(struct session *s, const char *tag, 
 	passwd = nul + 1;
 	passwdlen = (size_t)rawlen - off - authcidlen - 1;
 
-	/* RFC 4616 SS2: empty prep result SHALL fail verification, NO not BAD, framing is fine */
+	/* RFC 4616 SS2: empty prep result fails verification, NO not BAD */
 	if (authcidlen == 0 || passwdlen == 0) {
-		session_reply(s, tag, "NO", "[AUTHENTICATIONFAILED] authentication failed");
+		session_reply(s, tag, "NO",
+		    "[AUTHENTICATIONFAILED] authentication failed");
 		explicit_bzero(raw, sizeof(raw));
 		return (1);
 	}
-	/* too big for imsg_auth_request's fixed fields; same generic NO, avoids a distinct error leaking an oracle */
+	/*
+	 * too big for imsg_auth_request's fixed fields; generic NO avoids an
+	 * oracle
+	 */
 	if (authcidlen >= AUTH_USERNAME_MAX || passwdlen >= AUTH_PASSWORD_MAX) {
-		session_reply(s, tag, "NO", "[AUTHENTICATIONFAILED] authentication failed");
+		session_reply(s, tag, "NO",
+		    "[AUTHENTICATIONFAILED] authentication failed");
 		explicit_bzero(raw, sizeof(raw));
 		return (1);
 	}
@@ -194,6 +227,7 @@ sasl_plain_finish(struct session *s, const char *tag, 
 	memset(&req, 0, sizeof(req));
 	req.session_id = s->id;
 	memcpy(req.username, authcid, authcidlen);
+	session_set_user(s, authcid, authcidlen);
 	memcpy(req.password, passwd, passwdlen);
 	explicit_bzero(raw, sizeof(raw));
 
@@ -202,7 +236,11 @@ sasl_plain_finish(struct session *s, const char *tag, 
 		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
 		return (1);
 	}
-	/* SS7: the auth-worker may not exist if its fork failed (parent.c), leaving iev_auth.ibuf.fd at -1 -- fail gracefully rather than compose to an unwired imsgev. */
+	/*
+	 * The auth-worker may not exist if its fork failed (parent.c), leaving
+	 * iev_auth.ibuf.fd at -1 -- fail gracefully rather than compose to an
+	 * unwired imsgev.
+	 */
 	if (iev_auth.ibuf.fd == -1) {
 		session_reply(s, tag, "NO", "[UNAVAILABLE] authentication "
 		    "temporarily unavailable");
@@ -221,13 +259,15 @@ sasl_plain_finish(struct session *s, const char *tag, 
 	return (1);
 }
 
-/* client's response to our "+ " continuation after bare "AUTHENTICATE PLAIN" (see cmd_authenticate()) */
+/* reply to "+ " continuation after "AUTHENTICATE PLAIN" (cmd_authenticate) */
 int
 session_handle_auth_continuation(struct session *s, const char *line)
 {
-	s->auth_cont = 0;	/* next line is back to an ordinary tagged command regardless of outcome */
+	/* next line back to ordinary tagged command either way */
+	s->auth_cont = 0;
 
-	if (strcmp(line, "*") == 0) {	/* RFC 9051 SS6.2.2: lone "*" cancels the exchange */
+	/* RFC 9051 SS6.2.2: lone "*" cancels exchange */
+	if (strcmp(line, "*") == 0) {
 		session_reply(s, s->pending_tag, "BAD",
 		    "AUTHENTICATE cancelled");
 		return (1);
@@ -236,7 +276,7 @@ session_handle_auth_continuation(struct session *s, co
 	return sasl_plain_finish(s, s->pending_tag, line, 0);
 }
 
-/* client's response to our "+ idling" continuation (RFC 9051 SS6.3.13; see cmd_idle()); only "DONE" terminates IDLE */
+/* reply to our "+ idling" continuation (SS6.3.13); only "DONE" ends IDLE */
 int
 session_handle_idle_continuation(struct session *s, const char *line)
 {
@@ -292,13 +332,17 @@ cmd_authenticate(struct session *s, const char *tag, c
 		return (1);
 	}
 
-	if (initial != NULL) {	/* RFC 9051 SS6.2.2 initial-resp: finishes in one round trip */
-		/* `initial` is the base64 cleartext password and points into s->inbuf; have the reader scrub it once consumed. */
+	/* RFC 9051 SS6.2.2 initial-resp: one round trip */
+	if (initial != NULL) {
+		/*
+		 * `initial` is base64 cleartext password into s->inbuf; reader
+		 * scrubs it
+		 */
 		s->scrub_inbuf = 1;
 		return sasl_plain_finish(s, tag, initial, 1);
 	}
 
-	/* no initial response: send "+", auth_cont routes the reply line to session_handle_auth_continuation() */
+	/* no initial response: send "+"; auth_cont routes the reply line */
 	if (strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
 	    sizeof(s->pending_tag)) {
 		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
@@ -309,7 +353,7 @@ cmd_authenticate(struct session *s, const char *tag, c
 	return (1);
 }
 
-/* shared reply for a recognized command store.c can't run yet (no wire payload designed); NO not BAD, syntax is fine */
+/* reply for a command store.c can't run yet (no payload); NO not BAD */
 int
 stub_not_implemented(struct session *s, const char *tag, const char *cmdname)
 {
@@ -319,7 +363,7 @@ stub_not_implemented(struct session *s, const char *ta
 	return (1);
 }
 
-/* RFC 9051 SS6.3.1 ENABLE: unknown extensions ignored; ENABLED lists only what THIS command newly enabled, even if empty */
+/* RFC 9051 SS6.3.1 ENABLE: unknown exts ignored; ENABLED lists only new ones */
 int
 cmd_enable(struct session *s, const char *tag, char *args)
 {
@@ -352,7 +396,7 @@ cmd_enable(struct session *s, const char *tag, char *a
 	if (newly_condstore || newly_qresync)
 		session_condstore_enable(s);
 
-	/* buf[64] can never truncate here: worst case is two fixed literals plus a space, 18 bytes ("QRESYNC CONDSTORE"). */
+	/* buf[64] never truncates: worst case two literals plus a space */
 	buf[0] = '\0';
 	if (newly_qresync)
 		(void)strlcat(buf, "QRESYNC", sizeof(buf));
blob - f9228ac75a7d99082afc200f02579cf5847e1b7a
blob + 37d8cbb635a00549cdc76e86d82a9256c9d165a3
--- src/envelope.c
+++ src/envelope.c
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  *
@@ -40,7 +42,7 @@ int
 envbuf_append(char *buf, size_t bufsize, size_t *outlen, const char *data,
     size_t datalen)
 {
-	/* Subtract rather than add -- "*outlen + datalen" could wrap near SIZE_MAX and falsely pass the check. */
+	/* Subtract, don't add: "*outlen + datalen" could wrap near SIZE_MAX */
 	if (*outlen > bufsize || datalen > bufsize - *outlen)
 		return (-1);
 	memcpy(buf + *outlen, data, datalen);
@@ -55,7 +57,7 @@ envbuf_append_str(char *buf, size_t bufsize, size_t *o
 	return (envbuf_append(buf, bufsize, outlen, s, strlen(s)));
 }
 
-/* Appends one RFC 9051 nstring: NIL if val is NULL, else quoted+escaped; not RFC 2047 decoded (verbatim). */
+/* Appends one RFC 9051 nstring: NIL if val NULL, else quoted+escaped. */
 int
 envbuf_append_nstring(char *buf, size_t bufsize, size_t *outlen,
     const char *val, size_t vallen)
@@ -71,7 +73,10 @@ envbuf_append_nstring(char *buf, size_t bufsize, size_
 	for (i = 0; i < vallen; i++) {
 		char	 c = val[i];
 
-		/* RFC 9051 SS4.3 quoted strings exclude NUL/CR/LF; substitute rather than reject so one bad byte doesn't drop the whole field. */
+		/*
+		 * RFC 9051 SS4.3 quoted strings exclude NUL/CR/LF; substitute
+		 * rather than reject so one bad byte doesn't drop the field.
+		 */
 		if (c == '\0' || c == '\r' || c == '\n')
 			c = ' ';
 		if ((c == '"' || c == '\\') &&
@@ -85,12 +90,15 @@ envbuf_append_nstring(char *buf, size_t bufsize, size_
 	return (0);
 
 fail:
-	/* All-or-nothing: a partial append would leave an unterminated quoted string in the caller's buffer. */
+	/*
+	 * All-or-nothing: a partial append leaves an unterminated quoted
+	 * string.
+	 */
 	*outlen = save;
 	return (-1);
 }
 
-/* Formats one RFC 5322 mailbox as an IMAP address tuple (RFC 9051 SS9); no group syntax, addr-adl always NIL. */
+/* Formats an RFC 5322 mailbox as an address tuple (RFC 9051 SS9); no groups. */
 int
 envbuf_append_one_address(char *buf, size_t bufsize, size_t *outlen,
     const char *tok, size_t toklen)
@@ -111,12 +119,13 @@ envbuf_append_one_address(char *buf, size_t bufsize, s
 		tok++;
 		toklen--;
 	}
-	while (toklen > 0 && (tok[toklen - 1] == ' ' || tok[toklen - 1] == '\t'))
+	while (toklen > 0 && (tok[toklen - 1] == ' ' ||
+	    tok[toklen - 1] == '\t'))
 		toklen--;
 	if (toklen == 0)
 		return (-1);
 
-	/* unquoted '<' splits display-name (before) from addr-spec (up to matching unquoted '>') */
+	/* unquoted '<' splits display-name from addr-spec (to unquoted '>') */
 	lt = toklen;
 	{
 		size_t	 i;
@@ -151,17 +160,22 @@ envbuf_append_one_address(char *buf, size_t bufsize, s
 			const char	*disp = tok;
 			size_t		 displen = lt;
 
-			while (displen > 0 && (disp[0] == ' ' || disp[0] == '\t')) {
+			while (displen > 0 &&
+			    (disp[0] == ' ' || disp[0] == '\t')) {
 				disp++;
 				displen--;
 			}
 			while (displen > 0 &&
-			    (disp[displen - 1] == ' ' || disp[displen - 1] == '\t'))
+			    (disp[displen - 1] == ' ' ||
+			    disp[displen - 1] == '\t'))
 				displen--;
 
 			if (displen >= 2 && disp[0] == '"' &&
 			    disp[displen - 1] == '"') {
-				/* emission loop below re-escapes for the wire; no separate unescape pass needed */
+				/*
+				 * emission loop below re-escapes for the wire;
+				 * no unescape pass needed
+				 */
 				disp++;
 				displen -= 2;
 			}
@@ -182,7 +196,8 @@ envbuf_append_one_address(char *buf, size_t bufsize, s
 		spec++;
 		speclen--;
 	}
-	while (speclen > 0 && (spec[speclen - 1] == ' ' || spec[speclen - 1] == '\t'))
+	while (speclen > 0 && (spec[speclen - 1] == ' ' ||
+	    spec[speclen - 1] == '\t'))
 		speclen--;
 
 	found_at = 0;
@@ -208,8 +223,12 @@ envbuf_append_one_address(char *buf, size_t bufsize, s
 	host = spec + at + 1;
 	hostlen = speclen - at - 1;
 
-	/* strip surrounding quotes from a quoted local-part (unescaped "@" inside not handled) */
-	if (mailboxlen >= 2 && mailbox[0] == '"' && mailbox[mailboxlen - 1] == '"') {
+	/*
+	 * strip quotes from a quoted local-part (unescaped "@" inside not
+	 * handled)
+	 */
+	if (mailboxlen >= 2 && mailbox[0] == '"' &&
+	    mailbox[mailboxlen - 1] == '"') {
 		mailbox++;
 		mailboxlen -= 2;
 	}
@@ -220,7 +239,8 @@ envbuf_append_one_address(char *buf, size_t bufsize, s
 	if (name != NULL) {
 		size_t	 j;
 
-		if (envbuf_append(addrbuf, sizeof(addrbuf), &addrlen, "\"", 1) == -1)
+		if (envbuf_append(addrbuf, sizeof(addrbuf), &addrlen,
+		    "\"", 1) == -1)
 			return (-1);
 		for (j = 0; j < namelen; j++) {
 			char	c = name[j];
@@ -237,14 +257,17 @@ envbuf_append_one_address(char *buf, size_t bufsize, s
 			    &c, 1) == -1)
 				return (-1);
 		}
-		if (envbuf_append(addrbuf, sizeof(addrbuf), &addrlen, "\"", 1) == -1)
+		if (envbuf_append(addrbuf, sizeof(addrbuf), &addrlen,
+		    "\"", 1) == -1)
 			return (-1);
 	} else {
-		if (envbuf_append_str(addrbuf, sizeof(addrbuf), &addrlen, "NIL") == -1)
+		if (envbuf_append_str(addrbuf, sizeof(addrbuf), &addrlen,
+		    "NIL") == -1)
 			return (-1);
 	}
 
-	if (envbuf_append_str(addrbuf, sizeof(addrbuf), &addrlen, " NIL ") == -1)
+	if (envbuf_append_str(addrbuf, sizeof(addrbuf), &addrlen,
+	    " NIL ") == -1)
 		return (-1);
 	if (envbuf_append_nstring(addrbuf, sizeof(addrbuf), &addrlen, mailbox,
 	    mailboxlen) == -1)
@@ -257,11 +280,17 @@ envbuf_append_one_address(char *buf, size_t bufsize, s
 	if (envbuf_append(addrbuf, sizeof(addrbuf), &addrlen, ")", 1) == -1)
 		return (-1);
 
-	/* One atomic append: the whole "(...)" tuple lands or none of it does, since envbuf_append() leaves *outlen untouched on failure. */
+	/*
+	 * One atomic append: the whole "(...)" tuple lands or none of it
+	 * does, since 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. */
+/*
+ * 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.
+ */
 int
 envbuf_append_address_list(char *buf, size_t bufsize, size_t *outlen,
     const char *val, size_t vallen)
@@ -274,7 +303,8 @@ envbuf_append_address_list(char *buf, size_t bufsize, 
 		val++;
 		vallen--;
 	}
-	while (vallen > 0 && (val[vallen - 1] == ' ' || val[vallen - 1] == '\t'))
+	while (vallen > 0 && (val[vallen - 1] == ' ' ||
+	    val[vallen - 1] == '\t'))
 		vallen--;
 
 	if (vallen == 0)
@@ -311,7 +341,12 @@ envbuf_append_address_list(char *buf, size_t bufsize, 
 			tok_len--;
 
 		if (tok_len > 0) {
-			/* Safe to skip a malformed or non-fitting address and continue: envbuf_append_one_address() builds the tuple locally before one atomic append, leaving buf/outlen untouched on failure. */
+			/*
+			 * Safe to skip a malformed or non-fitting address and
+			 * continue: envbuf_append_one_address() builds the
+			 * tuple locally before one atomic append, leaving
+			 * buf/outlen untouched on failure.
+			 */
 			if (envbuf_append_one_address(buf, bufsize, outlen,
 			    val + tok_start, tok_len) == 0)
 				any = 1;
@@ -325,7 +360,7 @@ envbuf_append_address_list(char *buf, size_t bufsize, 
 	return (envbuf_append(buf, bufsize, outlen, ")", 1));
 }
 
-/* Looks up header field `name`, appends its nstring form (NIL if absent); shared by ENVELOPE's plain-string members. */
+/* Looks up header `name`, appends nstring (NIL if absent); for ENVELOPE. */
 int
 append_field_nstring(char *out, size_t outsize, size_t *outlen,
     const char *hdrbuf, uint32_t hdrlen, const char *name)
@@ -343,9 +378,13 @@ append_field_nstring(char *out, size_t outsize, size_t
 	return (rc);
 }
 
-/* Builds RFC 9051 SS7.5.2 ENVELOPE list; Sender/Reply-To default to From if absent/empty; -1 if unreadable or over ENVELOPE_MAX. */
+/*
+ * Builds RFC 9051 SS7.5.2 ENVELOPE list; Sender/Reply-To default to From
+ * if absent/empty; -1 if unreadable or over ENVELOPE_MAX.
+ */
 int
-build_envelope(const char *basename, char **buf_out, uint32_t *len_out)
+build_envelope(int dfd, const char *basename, char **buf_out,
+    uint32_t *len_out)
 {
 	char		*hdrbuf = NULL;
 	uint32_t	 hdrlen = 0;
@@ -357,7 +396,7 @@ build_envelope(const char *basename, char **buf_out, u
 	*buf_out = NULL;
 	*len_out = 0;
 
-	if (read_message_header(basename, &hdrbuf, &hdrlen) == -1)
+	if (read_message_header(dfd, basename, &hdrbuf, &hdrlen) == -1)
 		return (-1);
 
 	if (envbuf_append(out, sizeof(out), &outlen, "(", 1) == -1)
@@ -426,7 +465,8 @@ build_envelope(const char *basename, char **buf_out, u
 			    envbuf_append(out, sizeof(out), &outlen,
 			    from_formatted, from_len) == -1)
 				goto fail;
-			if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1)
+			if (envbuf_append(out, sizeof(out), &outlen,
+			    " ", 1) == -1)
 				goto fail;
 		}
 	}
@@ -452,7 +492,8 @@ build_envelope(const char *basename, char **buf_out, u
 				    &outlen, "NIL") == -1)
 					goto fail;
 			}
-			if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1)
+			if (envbuf_append(out, sizeof(out), &outlen,
+			    " ", 1) == -1)
 				goto fail;
 		}
 	}
@@ -487,7 +528,11 @@ fail:
 	return (-1);
 }
 
-/* BODYSTRUCTURE (RFC 9051 SS7.5.2): recursive RFC 2045/2046 MIME parse, bounded by MIME_MAX_DEPTH/MIME_MAX_PARTS; no extension data, message/rfc822, or RFC 2231 continuations. */
+/*
+ * BODYSTRUCTURE (RFC 9051 SS7.5.2): recursive RFC 2045/2046 MIME parse,
+ * bounded by MIME_MAX_DEPTH/MIME_MAX_PARTS; no extension data,
+ * message/rfc822, or RFC 2231 continuations.
+ */
 int
 build_body_structure(int depth, int *nparts_used, const char *hdr,
     size_t hdrlen, const char *body, size_t bodylen, char *out,
@@ -495,7 +540,8 @@ build_body_structure(int depth, int *nparts_used, cons
 {
 	char	type[64], subtype[64];
 	char	params_fmt[600];
-	char	boundary[70 + 1];	/* RFC 2046 SS5.1.1 caps boundary at 70 chars, +1 NUL */
+	/* RFC 2046 SS5.1.1 caps boundary at 70 chars, +1 NUL */
+	char	boundary[70 + 1];
 	int	has_boundary;
 
 	if (depth > MIME_MAX_DEPTH)
@@ -526,7 +572,10 @@ build_body_structure(int depth, int *nparts_used, cons
 			size_t		 plen = part_ends[i] - part_starts[i];
 			size_t		 phdrend;
 
-			/* zero-length body-part is spec-legal (RFC 2046 SS5.1.1); treat as hdrlen==0/bodylen==0 directly */
+			/*
+			 * zero-length body-part is spec-legal (RFC 2046
+			 * SS5.1.1); treat as 0/0
+			 */
 			if (plen == 0)
 				phdrend = 0;
 			else if (find_header_body_split(pbuf, plen,
@@ -548,7 +597,8 @@ build_body_structure(int depth, int *nparts_used, cons
 	if (strcasecmp(type, "MESSAGE") == 0 &&
 	    (strcasecmp(subtype, "RFC822") == 0 ||
 	    strcasecmp(subtype, "GLOBAL") == 0))
-		return (-1);	/* scoped out, see BODYSTRUCTURE comment above */
+		/* scoped out, see BODYSTRUCTURE comment above */
+		return (-1);
 
 	{
 		char	*idval = NULL, *descval = NULL, *encval = NULL;
@@ -580,7 +630,8 @@ build_body_structure(int depth, int *nparts_used, cons
 			rc = envbuf_append_nstring(out, outsize, outlen, idval,
 			    idlen);
 		else
-			rc = envbuf_append_nstring(out, outsize, outlen, NULL, 0);
+			rc = envbuf_append_nstring(out, outsize, outlen,
+			    NULL, 0);
 		free(idval);
 		if (rc == -1)
 			return (-1);
@@ -592,7 +643,8 @@ build_body_structure(int depth, int *nparts_used, cons
 			rc = envbuf_append_nstring(out, outsize, outlen,
 			    descval, desclen);
 		else
-			rc = envbuf_append_nstring(out, outsize, outlen, NULL, 0);
+			rc = envbuf_append_nstring(out, outsize, outlen,
+			    NULL, 0);
 		free(descval);
 		if (rc == -1)
 			return (-1);
@@ -633,7 +685,8 @@ build_body_structure(int depth, int *nparts_used, cons
 			char	numbuf[32];
 
 			snprintf(numbuf, sizeof(numbuf), "%zu", bodylen);
-			if (envbuf_append_str(out, outsize, outlen, numbuf) == -1)
+			if (envbuf_append_str(out, outsize, outlen,
+			    numbuf) == -1)
 				return (-1);
 		}
 
@@ -646,7 +699,8 @@ build_body_structure(int depth, int *nparts_used, cons
 					lines++;
 			}
 			snprintf(numbuf2, sizeof(numbuf2), " %zu", lines);
-			if (envbuf_append_str(out, outsize, outlen, numbuf2) == -1)
+			if (envbuf_append_str(out, outsize, outlen,
+			    numbuf2) == -1)
 				return (-1);
 		}
 
@@ -654,9 +708,13 @@ build_body_structure(int depth, int *nparts_used, cons
 	}
 }
 
-/* Top-level entry: reads message once (capped at bodystructure_read_max), finds header/body split, walks from depth 0; -1 on any failure. */
+/*
+ * Top-level entry: reads message once (capped at bodystructure_read_max),
+ * finds header/body split, walks from depth 0; -1 on any failure.
+ */
 int
-build_bodystructure(const char *basename, char **buf_out, uint32_t *len_out)
+build_bodystructure(int dfd, const char *basename, char **buf_out,
+    uint32_t *len_out)
 {
 	char		*wholebuf = NULL;
 	uint32_t	 wholelen = 0;
@@ -668,7 +726,7 @@ build_bodystructure(const char *basename, char **buf_o
 	*buf_out = NULL;
 	*len_out = 0;
 
-	if (read_message_body(basename, 0, bodystructure_read_max,
+	if (read_message_body(dfd, basename, 0, bodystructure_read_max,
 	    "BODYSTRUCTURE", &wholebuf, &wholelen) == -1)
 		return (-1);
 	if (wholelen == 0 ||
blob - 204ba724b7978cb4705e7709d340b52914883ba1
blob + b3a8877617850b49d567d824e44dfc14c5f6f6ca
--- src/fetch_cmd.c
+++ src/fetch_cmd.c
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  *
@@ -57,7 +59,11 @@ parse_nz_number(const char *str, uint32_t *out)
 	return (0);
 }
 
-/* Parses one range token (single optional colon, no comma) into r; factored out of the old parse_seq_range() so parse_sequence_set() can reuse it per comma-separated segment. */
+/*
+ * Parses one range token (single optional colon, no comma) into r; factored out
+ * of the old parse_seq_range() so parse_sequence_set() can reuse it per
+ * comma-separated segment.
+ */
 static int
 parse_one_seq_range(const char *tok, struct seq_range *r)
 {
@@ -102,7 +108,13 @@ parse_one_seq_range(const char *tok, struct seq_range 
 	return (0);
 }
 
-/* Parses an RFC 9051 SS9 sequence-set (comma-separated seq-number/seq-range) by splitting on top-level commas and parsing each with parse_one_seq_range(), writing up to SEQSET_MAX_RANGES entries to ranges[] and the count to *nranges, or returning -1 with *errmsg set on a malformed, empty, or excess segment. */
+/*
+ * Parses an RFC 9051 SS9 sequence-set (comma-separated seq-number/seq-range) by
+ * splitting on top-level commas and parsing each with parse_one_seq_range(),
+ * writing up to SEQSET_MAX_RANGES entries to ranges[] and the count to
+ * *nranges, or returning -1 with *errmsg set on a malformed, empty, or excess
+ * segment.
+ */
 int
 parse_sequence_set(const char *text, struct seq_range ranges[SEQSET_MAX_RANGES],
     uint32_t *nranges, const char **errmsg)
@@ -150,7 +162,7 @@ parse_sequence_set(const char *text, struct seq_range 
 	return (0);
 }
 
-/* like strtok_r(str, " ", &savep), but space isn't a delimiter inside an unclosed '[' or '(' (RFC 9051 SS9 header-list) */
+/* like strtok_r(); space isn't a delim inside an unclosed '[' or '(' (SS9) */
 static char *
 fetch_att_tok(char *str, char **savep)
 {
@@ -184,7 +196,7 @@ fetch_att_tok(char *str, char **savep)
 	return (start);
 }
 
-/* parses "HEADER.FIELDS[.NOT] (name ...)" bracket body (RFC 9051 SS9); -1 on syntax error is a real client BAD, not a silent drop */
+/* parses HEADER.FIELDS[.NOT] body (SS9); -1 is BAD, not a silent drop */
 int
 parse_header_fields_att(const char *inner, int *not_out, char *fields_out,
     size_t fields_outsize)
@@ -241,7 +253,7 @@ parse_header_fields_att(const char *inner, int *not_ou
 	return (0);
 }
 
-/* RFC 9051 SS6.4.5.1 section-part grammar check; verbatim string still crosses to store.c's parse_section_part() */
+/* SS6.4.5.1 section-part check; string still reaches parse_section_part() */
 int
 section_part_valid(const char *s)
 {
@@ -269,7 +281,7 @@ section_part_valid(const char *s)
 	return (1);
 }
 
-/* RFC 9051 SS6.4.5 partial-range suffix "<start.count>"; count may be 0 (apply_partial_range() in store.c handles that) */
+/* SS6.4.5 "<start.count>"; count 0 handled by partial_range() */
 int
 parse_partial_suffix(const char *s, int *has_partial_out,
     uint32_t *start_out, uint32_t *count_out)
@@ -310,7 +322,13 @@ 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). */
+/*
+ * 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,
@@ -355,14 +373,21 @@ parse_body_peek_section_tok(const char *tok, uint32_t 
 			return (0);
 		}
 	} else {
-		*degraded_out = 1;	/* recognized shape, unsupported section (e.g. "2.1.TEXT") */
+		/* recognized shape, unsupported section e.g. "2.1.TEXT" */
+		*degraded_out = 1;
 		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). */
+/*
+ * 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,
@@ -377,7 +402,8 @@ parse_body_peek_header_fields_tok(const char *tok, uin
 		return (-1);
 	}
 	if (*attrs_inout & MBOX_FETCH_HEADER_FIELDS)
-		return (0);	/* already captured one, ignore any further duplicates */
+		/* already captured one, ignore any further duplicates */
+		return (0);
 
 	if (toklen - strlen("BODY.PEEK[") - 1 >= sizeof(inner)) {
 		*errmsg = "HEADER.FIELDS section too long";
@@ -401,7 +427,7 @@ parse_body_peek_header_fields_tok(const char *tok, uin
 	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 */
+/* SS6.4.5: ALL/FULL/FAST; unsupported skipped (*degraded_out=1), else -2/NO */
 int
 parse_fetch_atts(char *spec, uint32_t *attrs_out, int *degraded_out,
     int *header_fields_not_out, char *header_fields_out,
@@ -457,7 +483,10 @@ parse_fetch_atts(char *spec, uint32_t *attrs_out, int 
 			attrs |= MBOX_FETCH_FLAGS | MBOX_FETCH_INTERNALDATE |
 			    MBOX_FETCH_RFC822_SIZE | MBOX_FETCH_ENVELOPE;
 		} else if (strcasecmp(tok, "FULL") == 0) {
-			/* SS6.4.5 macro: ALL + bare BODY (bodystructure_full_out stays 0) */
+			/*
+			 * SS6.4.5 macro: ALL + bare BODY
+			 * (bodystructure_full_out stays 0)
+			 */
 			attrs |= MBOX_FETCH_FLAGS | MBOX_FETCH_INTERNALDATE |
 			    MBOX_FETCH_RFC822_SIZE | MBOX_FETCH_ENVELOPE |
 			    MBOX_FETCH_BODYSTRUCTURE;
@@ -470,11 +499,14 @@ parse_fetch_atts(char *spec, uint32_t *attrs_out, int 
 		} else if (strcasecmp(tok, "RFC822.SIZE") == 0) {
 			attrs |= MBOX_FETCH_RFC822_SIZE;
 		} else if (strcasecmp(tok, "MODSEQ") == 0) {
-			attrs |= MBOX_FETCH_MODSEQ;	/* RFC 7162 SS3.1.4.2, also CONDSTORE-enabling, cmd_fetch() checks this bit */
+			/* SS3.1.4.2, CONDSTORE; cmd_fetch() checks */
+			attrs |= MBOX_FETCH_MODSEQ;
 		} else if (strcasecmp(tok, "BODY.PEEK[HEADER]") == 0) {
-			attrs |= MBOX_FETCH_BODY_HEADER;	/* the one exact-match BODY[...]; plain BODY[HEADER] would need \Seen, unimplemented */
-		} else if (strncasecmp(tok, "BODY.PEEK[", strlen("BODY.PEEK[")) ==
-		    0 && strncasecmp(tok, "BODY.PEEK[HEADER.FIELDS",
+			/* exact BODY[...]; \Seen unimplemented */
+			attrs |= MBOX_FETCH_BODY_HEADER;
+		} else if (strncasecmp(tok, "BODY.PEEK[",
+		    strlen("BODY.PEEK[")) == 0 &&
+		    strncasecmp(tok, "BODY.PEEK[HEADER.FIELDS",
 		    strlen("BODY.PEEK[HEADER.FIELDS")) != 0) {
 			if (parse_body_peek_section_tok(tok, &attrs,
 			    section_part_out, section_part_outsize,
@@ -489,10 +521,14 @@ parse_fetch_atts(char *spec, uint32_t *attrs_out, int 
 			    header_fields_label_outsize, errmsg) == -1)
 				return (-1);
 		} else if (strcasecmp(tok, "ENVELOPE") == 0) {
-			attrs |= MBOX_FETCH_ENVELOPE;	/* RFC 9051 SS7.5.2; no .PEEK variant, no \Seen side effect */
+			/* SS7.5.2; no .PEEK, no \Seen effect */
+			attrs |= MBOX_FETCH_ENVELOPE;
 		} else if (strcasecmp(tok, "BODY") == 0 ||
 		    strcasecmp(tok, "BODYSTRUCTURE") == 0) {
-			/* both produce identical output; exact-matched ahead of the "BODY" prefix catch-all below */
+			/*
+			 * both produce identical output, exact-matched ahead of
+			 * "BODY" catch-all
+			 */
 			attrs |= MBOX_FETCH_BODYSTRUCTURE;
 			*bodystructure_full_out =
 			    (strcasecmp(tok, "BODYSTRUCTURE") == 0);
@@ -500,7 +536,10 @@ parse_fetch_atts(char *spec, uint32_t *attrs_out, int 
 		    strcasecmp(tok, "RFC822") == 0 ||
 		    strcasecmp(tok, "RFC822.HEADER") == 0 ||
 		    strcasecmp(tok, "RFC822.TEXT") == 0) {
-			/* MIME part-addressed BODY[...] and RFC822(.HEADER/.TEXT) shorthands unimplemented; dropped */
+			/*
+			 * MIME part BODY[...] and RFC822(.HEADER/.TEXT)
+			 * shorthands unimplemented
+			 */
 			degraded = 1;
 		} else {
 			*errmsg = "unknown message data item";
@@ -509,21 +548,30 @@ parse_fetch_atts(char *spec, uint32_t *attrs_out, int 
 	}
 
 	if (attrs == 0) {
-		/* every requested item was unsupported, e.g. BODY[<part>] or RFC822(.HEADER/.TEXT) alone */
+		/*
+		 * every item unsupported, e.g. BODY[<part>] or
+		 * RFC822(.HEADER/.TEXT) alone
+		 */
 		*errmsg = "cannot fetch that message content yet, "
 		    "supported: FLAGS/UID/INTERNALDATE/RFC822.SIZE/MODSEQ/"
 		    "ENVELOPE/(BODY|BODYSTRUCTURE)/BODY.PEEK[...]";
 		return (-2);
 	}
 
-	/* both HEADER and HEADER.FIELDS requested (legal, SS6.4.5): HEADER wins, only one pending_header_* slot exists */
+	/*
+	 * both HEADER/HEADER.FIELDS requested (legal, SS6.4.5): HEADER wins
+	 * here
+	 */
 	if ((attrs & MBOX_FETCH_BODY_HEADER) &&
 	    (attrs & MBOX_FETCH_HEADER_FIELDS))
 		attrs &= ~MBOX_FETCH_HEADER_FIELDS;
 
 	*attrs_out = attrs;
 	*degraded_out = degraded;
-	/* accumulated in locals like attrs, copied out here so the HEADER-wins resolution above stays the one adjustment point */
+	/*
+	 * accumulated in attrs, copied out so HEADER-wins is the one adjust
+	 * point
+	 */
 	*has_partial_out = has_partial;
 	*partial_start_out = partial_start;
 	*partial_count_out = partial_count;
@@ -535,7 +583,7 @@ const char *fetch_month_names[12] = {
 	"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
 };
 
-/* RFC 9051 SS9 date-time; always formats in UTC "+0000", ts carries no tz info and a chroot'd store child has no tzdata */
+/* SS9 date-time; always UTC "+0000": ts has no tz, chroot child lacks tzdata */
 void
 format_internaldate(int64_t ts, char *out, size_t outsize)
 {
@@ -555,7 +603,7 @@ format_internaldate(int64_t ts, char *out, size_t outs
 	    tm.tm_hour, tm.tm_min, tm.tm_sec);
 }
 
-/* snprintf-into-growing-buffer helper; clamps *len to bufsize so a prior truncation can't underflow the next call's remaining size */
+/* growing-buf helper; clamps *len, truncation can't underflow bufsize */
 static void
 fetch_append(char *buf, size_t bufsize, size_t *len, const char *fmt, ...)
 {
@@ -577,7 +625,42 @@ fetch_append(char *buf, size_t bufsize, size_t *len, c
 		*len = bufsize;
 }
 
-/* sends one untagged "* <seqno> FETCH (...)" (RFC 9051 SS7.5.2); literal-syntax items flush buf then write their payload raw */
+/*
+ * Writes len octets of fd, starting at off, to the client. The literal's
+ * length is already on the wire, so a short read cannot be repaired and
+ * the connection is shut down instead of desynchronized.
+ */
+static void
+session_write_file_range(struct session *s, int fd, uint64_t off,
+    uint64_t len)
+{
+	char	buf[16384];
+	size_t	want;
+	ssize_t	n;
+
+	while (len > 0 && !s->write_failed) {
+		want = len < sizeof(buf) ? (size_t)len : sizeof(buf);
+		if ((n = pread(fd, buf, want, (off_t)off)) == -1 &&
+		    errno == EINTR)
+			continue;
+		if (n <= 0) {
+			if (n == -1)
+				log_warn("session %u: read FETCH body", s->id);
+			else
+				log_warnx("session %u: FETCH body ended %llu "
+				    "octets early", s->id,
+				    (unsigned long long)len);
+			s->write_failed = 1;
+			(void)shutdown(s->client_fd, SHUT_RDWR);
+			return;
+		}
+		session_write(s, buf, (size_t)n);
+		off += (uint64_t)n;
+		len -= (uint64_t)n;
+	}
+}
+
+/* sends untagged FETCH response (SS7.5.2); literals flush buf, write raw */
 void
 session_send_fetch_response(struct session *s,
     struct imsg_mbox_fetch_meta *meta)
@@ -593,7 +676,8 @@ session_send_fetch_response(struct session *s,
 	    MBOX_FETCH_BODY_PART)) && s->pending_body_found;
 	int	 have_envelope = (s->fetch_attrs & MBOX_FETCH_ENVELOPE) &&
 	    s->pending_envelope_found;
-	int	 have_bodystructure = (s->fetch_attrs & MBOX_FETCH_BODYSTRUCTURE) &&
+	int	 have_bodystructure =
+	    (s->fetch_attrs & MBOX_FETCH_BODYSTRUCTURE) &&
 	    s->pending_bodystructure_found;
 
 	fetch_append(buf, sizeof(buf), &len, "%u FETCH (", meta->seqno);
@@ -661,22 +745,26 @@ session_send_fetch_response(struct session *s,
 			need_sp = 1;
 		}
 		if (have_body) {
-			/* RFC 9051 SS6.4.5: echo the origin octet only if the client sent one; never echo store.c's count */
+			/*
+			 * SS6.4.5: echo origin octet only if client sent one,
+			 * not store.c's count
+			 */
 			len = 0;
 			if (s->pending_body_has_partial)
 				fetch_append(buf, sizeof(buf), &len,
-				    "%sBODY[%s]<%u> {%u}\r\n",
+				    "%sBODY[%s]<%u> {%llu}\r\n",
 				    need_sp ? " " : "", s->pending_body_label,
 				    s->pending_body_partial_origin,
-				    s->pending_body_len);
+				    (unsigned long long)s->pending_body_len);
 			else
 				fetch_append(buf, sizeof(buf), &len,
-				    "%sBODY[%s] {%u}\r\n", need_sp ? " " : "",
-				    s->pending_body_label, s->pending_body_len);
+				    "%sBODY[%s] {%llu}\r\n", need_sp ? " " : "",
+				    s->pending_body_label,
+				    (unsigned long long)s->pending_body_len);
 			session_write(s, buf, len);
 			if (s->pending_body_len > 0)
-				session_write(s, s->pending_body_buf,
-				    s->pending_body_len);
+				session_write_file_range(s, s->pending_body_fd,
+				    s->pending_body_off, s->pending_body_len);
 		}
 		session_write(s, ")\r\n", 3);
 	} else {
@@ -689,42 +777,62 @@ session_send_fetch_response(struct session *s,
 		session_untagged(s, buf);
 	}
 
-	/* reset pending_*_found even when have_* is false, so it doesn't leak into the next message's response */
+	/*
+	 * Reset pending_*_found when have_* is false, so it can't leak to
+	 * the next reply. Requested but not found also means the store
+	 * could not produce the item, which RFC 9051 SS6.4.5 answers with a
+	 * tagged NO once the command finishes.
+	 */
 	if (have_header) {
 		free(s->pending_header_buf);
 		s->pending_header_buf = NULL;
 		s->pending_header_len = 0;
 	}
-	if (s->fetch_attrs & (MBOX_FETCH_BODY_HEADER | MBOX_FETCH_HEADER_FIELDS))
+	if (s->fetch_attrs &
+	    (MBOX_FETCH_BODY_HEADER | MBOX_FETCH_HEADER_FIELDS)) {
+		if (!have_header)
+			s->fetch_incomplete = 1;
 		s->pending_header_found = 0;
+	}
 
 	if (have_body) {
-		free(s->pending_body_buf);
-		s->pending_body_buf = NULL;
+		if (s->pending_body_fd != -1)
+			close(s->pending_body_fd);
+		s->pending_body_fd = -1;
+		s->pending_body_off = 0;
 		s->pending_body_len = 0;
 	}
 	if (s->fetch_attrs & (MBOX_FETCH_BODY_WHOLE | MBOX_FETCH_BODY_TEXT |
-	    MBOX_FETCH_BODY_PART))
+	    MBOX_FETCH_BODY_PART)) {
+		if (!have_body)
+			s->fetch_incomplete = 1;
 		s->pending_body_found = 0;
+	}
 
 	if (have_envelope) {
 		free(s->pending_envelope_buf);
 		s->pending_envelope_buf = NULL;
 		s->pending_envelope_len = 0;
 	}
-	if (s->fetch_attrs & MBOX_FETCH_ENVELOPE)
+	if (s->fetch_attrs & MBOX_FETCH_ENVELOPE) {
+		if (!have_envelope)
+			s->fetch_incomplete = 1;
 		s->pending_envelope_found = 0;
+	}
 
 	if (have_bodystructure) {
 		free(s->pending_bodystructure_buf);
 		s->pending_bodystructure_buf = NULL;
 		s->pending_bodystructure_len = 0;
 	}
-	if (s->fetch_attrs & MBOX_FETCH_BODYSTRUCTURE)
+	if (s->fetch_attrs & MBOX_FETCH_BODYSTRUCTURE) {
+		if (!have_bodystructure)
+			s->fetch_incomplete = 1;
 		s->pending_bodystructure_found = 0;
+	}
 }
 
-/* STORE's untagged FETCH response (RFC 9051 SS6.4.6) always shows FLAGS; MODSEQ shown whenever CONDSTORE-aware (SS3.1.3) */
+/* STORE's FETCH (SS6.4.6) shows FLAGS; MODSEQ if CONDSTORE-aware (SS3.1.3) */
 void
 session_send_store_fetch_response(struct session *s,
     const struct imsg_mbox_fetch_meta *meta)
@@ -732,7 +840,10 @@ session_send_store_fetch_response(struct session *s,
 	char	buf[MBOX_FLAGS_MAX + 96];
 	size_t	len;
 
-	/* RFC 9051 SS6.4.9: a UID STORE's echo must include UID, right after FLAGS */
+	/*
+	 * RFC 9051 SS6.4.9: a UID STORE's echo must include UID, right after
+	 * FLAGS
+	 */
 	len = (size_t)snprintf(buf, sizeof(buf), "%u FETCH (FLAGS (%s)",
 	    meta->seqno, meta->flags);
 	if (s->cmd_by_uid && len < sizeof(buf))
@@ -747,7 +858,7 @@ session_send_store_fetch_response(struct session *s,
 	session_untagged(s, buf);
 }
 
-/* splits a trailing RFC 4466 modifier list off spec (shared by cmd_fetch()/cmd_store_cmd()); NUL-terminates spec in place */
+/* splits trailing RFC4466 modifiers off spec; NUL-terminates spec in place */
 char *
 split_trailing_modifiers(char *spec)
 {
@@ -766,7 +877,11 @@ split_trailing_modifiers(char *spec)
 					break;
 				}
 			} else if (*p == '\0')
-				return (NULL);	/* unterminated; caller's own parser produces the BAD for this */
+				/*
+				 * unterminated; caller's parser produces the
+				 * BAD for this
+				 */
+				return (NULL);
 			p++;
 		}
 	} else {
@@ -784,7 +899,10 @@ split_trailing_modifiers(char *spec)
 	return (p);
 }
 
-/* FETCH's trailing fetch-modifier list (RFC 4466 + RFC 7162 SS3.1.4.1/SS3.2.6); *want_vanished lets fetch_dispatch() pair-check later */
+/*
+ * FETCH's trailing fetch-modifier list (RFC 4466 + RFC 7162 SS3.1.4.1/SS3.2.6);
+ * *want_vanished lets fetch_dispatch() pair-check later.
+ */
 int
 parse_fetch_modifiers(char *modspec, struct imsg_mbox_fetch *req,
     const struct session *s, int by_uid, int *want_vanished,
@@ -814,7 +932,13 @@ parse_fetch_modifiers(char *modspec, struct imsg_mbox_
 				    "mod-sequence value";
 				return (-1);
 			}
-			/* RFC 7162 SS7 mod-sequence-values are unsigned only, but strtoull(3) accepts a leading sign, so a guard rejects non-digit-leading input to stop "-1" silently becoming ULLONG_MAX (same check as auth.c/index.c/listener.c's literal parser). */
+			/*
+			 * RFC 7162 SS7 mod-sequence-values are unsigned only,
+			 * but strtoull(3) accepts a leading sign, so a guard
+			 * rejects non-digit-leading input to stop "-1" silently
+			 * becoming ULLONG_MAX (same check as
+			 * auth.c/index.c/listener.c's literal parser).
+			 */
 			if (*valtok < '0' || *valtok > '9') {
 				*errmsg = "invalid CHANGEDSINCE mod-sequence";
 				return (-1);
@@ -831,7 +955,10 @@ parse_fetch_modifiers(char *modspec, struct imsg_mbox_
 			req->attrs |= MBOX_FETCH_MODSEQ;
 		} else if (strcasecmp(tok, "VANISHED") == 0) {
 			if (!by_uid) {
-				/* RFC 7162 SS3.2.6: VANISHED not allowed with plain FETCH, MUST return tagged BAD */
+				/*
+				 * RFC 7162 SS3.2.6: VANISHED with plain FETCH
+				 * MUST return tagged BAD
+				 */
 				*errmsg = "VANISHED is only valid as a UID "
 				    "FETCH modifier (RFC 7162 SS3.2.6)";
 				return (-1);
@@ -851,14 +978,14 @@ parse_fetch_modifiers(char *modspec, struct imsg_mbox_
 	return (0);
 }
 
-/* RFC 9051 SS6.4.5 fetch + RFC 4466/7162 modifier list; plain BODY[...] and BODY[<part>] are a deliberate scope cut */
+/* SS6.4.5 fetch+RFC4466/7162 modifiers; BODY[...]/BODY[<part>] a scope cut */
 int
 cmd_fetch(struct session *s, const char *tag, char *args)
 {
 	return fetch_dispatch(s, tag, args, 0);
 }
 
-/* shared body for cmd_fetch() (by_uid=0) and cmd_uid()'s FETCH branch (by_uid=1); RFC 9051 SS6.4.9 forces MBOX_FETCH_UID into attrs */
+/* shared cmd_fetch/cmd_uid FETCH (uid 0/1); forces MBOX_FETCH_UID (SS6.4.9) */
 int
 fetch_dispatch(struct session *s, const char *tag, char *args, int by_uid)
 {
@@ -929,7 +1056,7 @@ fetch_dispatch(struct session *s, const char *tag, cha
 	req.by_uid = by_uid;
 	req.header_fields_not = header_fields_not;
 
-	/* re-checked though already bounds-checked above; store.c applies has_partial/section_part to whichever of WHOLE/TEXT/PART wins */
+	/* bounds-checked above; applies per WHOLE/TEXT/PART winner */
 	if (strlcpy(req.header_fields, header_fields,
 	    sizeof(req.header_fields)) >= sizeof(req.header_fields) ||
 	    strlcpy(req.section_part, section_part, sizeof(req.section_part))
@@ -941,51 +1068,62 @@ fetch_dispatch(struct session *s, const char *tag, cha
 	req.partial_start = partial_start;
 	req.partial_count = partial_count;
 
-	/* verbatim client-typed label never crosses to store.c, stashed here for session_send_fetch_response() to echo */
+	/* client label never reaches store.c; stashed to echo in the reply */
 	if (attrs & MBOX_FETCH_BODY_HEADER) {
 		if (strlcpy(s->pending_header_label, "HEADER",
 		    sizeof(s->pending_header_label)) >=
 		    sizeof(s->pending_header_label)) {
-			session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+			session_reply(s, tag, "NO",
+			    "[SERVERBUG] internal error");
 			return (1);
 		}
 	} else if (attrs & MBOX_FETCH_HEADER_FIELDS) {
 		if (strlcpy(s->pending_header_label, header_fields_label,
 		    sizeof(s->pending_header_label)) >=
 		    sizeof(s->pending_header_label)) {
-			session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+			session_reply(s, tag, "NO",
+			    "[SERVERBUG] internal error");
 			return (1);
 		}
 	}
 
-	/* same idea, for BODY.PEEK[]/[TEXT]/[<section-part>]; WHOLE>TEXT>PART must match store.c's handle_mbox_fetch() */
+	/*
+	 * same, for BODY.PEEK[]/[TEXT]/[<part>]; matches handle_mbox_fetch()
+	 * order
+	 */
 	if (attrs & MBOX_FETCH_BODY_WHOLE) {
 		s->pending_body_label[0] = '\0';
 	} else if (attrs & MBOX_FETCH_BODY_TEXT) {
 		if (strlcpy(s->pending_body_label, "TEXT",
 		    sizeof(s->pending_body_label)) >=
 		    sizeof(s->pending_body_label)) {
-			session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+			session_reply(s, tag, "NO",
+			    "[SERVERBUG] internal error");
 			return (1);
 		}
 	} else if (attrs & MBOX_FETCH_BODY_PART) {
 		if (strlcpy(s->pending_body_label, section_part,
 		    sizeof(s->pending_body_label)) >=
 		    sizeof(s->pending_body_label)) {
-			session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+			session_reply(s, tag, "NO",
+			    "[SERVERBUG] internal error");
 			return (1);
 		}
 	}
 	s->pending_body_has_partial = has_partial;
 	s->pending_body_partial_origin = partial_start;
 
-	/* same idea, for BODYSTRUCTURE: response label echoes whichever bare token ("BODY"/"BODYSTRUCTURE") the client used */
+	/*
+	 * same, BODYSTRUCTURE: echoes "BODY"/"BODYSTRUCTURE" bare token client
+	 * used
+	 */
 	if (attrs & MBOX_FETCH_BODYSTRUCTURE) {
 		if (strlcpy(s->pending_bodystructure_label,
 		    bodystructure_full ? "BODYSTRUCTURE" : "BODY",
 		    sizeof(s->pending_bodystructure_label)) >=
 		    sizeof(s->pending_bodystructure_label)) {
-			session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+			session_reply(s, tag, "NO",
+			    "[SERVERBUG] internal error");
 			return (1);
 		}
 	}
@@ -999,7 +1137,10 @@ fetch_dispatch(struct session *s, const char *tag, cha
 	}
 
 	if (want_vanished && !req.has_changedsince) {
-		/* RFC 7162 SS3.2.6: VANISHED MUST be paired with CHANGEDSINCE, else tagged BAD */
+		/*
+		 * RFC 7162 SS3.2.6: VANISHED MUST pair with CHANGEDSINCE, else
+		 * tagged BAD
+		 */
 		session_reply(s, tag, "BAD",
 		    "VANISHED requires CHANGEDSINCE also be specified "
 		    "(RFC 7162 SS3.2.6)");
@@ -1011,7 +1152,10 @@ fetch_dispatch(struct session *s, const char *tag, cha
 		req.attrs |= MBOX_FETCH_UID;
 
 	if (s->store_iev == NULL) {
-		/* same invariant check as cmd_select(), ST_SELECTED requires store_iev already wired */
+		/*
+		 * same invariant as cmd_select(): ST_SELECTED requires
+		 * store_iev wired
+		 */
 		log_warnx("session %u: %s with no store channel wired",
 		    s->id, cmdname);
 		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
@@ -1020,7 +1164,10 @@ fetch_dispatch(struct session *s, const char *tag, cha
 
 	req.nranges = nranges;
 
-	/* RFC 7162 SS3.1: MODSEQ fetch-att and CHANGEDSINCE modifier are both CONDSTORE-enabling */
+	/*
+	 * RFC 7162 SS3.1: MODSEQ fetch-att and CHANGEDSINCE both
+	 * CONDSTORE-enabling
+	 */
 	if (req.attrs & MBOX_FETCH_MODSEQ)
 		session_condstore_enable(s);
 
@@ -1031,6 +1178,7 @@ fetch_dispatch(struct session *s, const char *tag, cha
 	}
 	s->fetch_attrs = req.attrs;
 	s->cmd_by_uid = by_uid;
+	s->fetch_incomplete = 0;
 	s->state = SESSION_FETCHING;
 
 	if (!send_mbox_request(s, IMSG_MBOX_FETCH, cmdname, "IMSG_MBOX_FETCH",
blob - 3063e8439e40e298e3a780bbc9955988325c4100
blob + f3d26fd552c675eab5d75707a86c008bcd83c659
--- 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: September 9 2026 $
+.Dd $Mdocdate: September 18 2026 $
 .Dt IMAPD 8
 .Os
 .Sh NAME
@@ -82,6 +82,8 @@ Within that scope it implements
 .Li CREATE ,
 .Li DELETE ,
 .Li RENAME ,
+.Li SUBSCRIBE ,
+.Li UNSUBSCRIBE ,
 .Li LIST ,
 .Li LSUB ,
 .Li NAMESPACE ,
@@ -99,12 +101,32 @@ Within that scope it implements
 .Li IDLE ,
 and the RFC 7162 CONDSTORE and QRESYNC extensions
 .Pq mod-sequence tracking, conditional STORE, VANISHED responses .
-.Li SUBSCRIBE
-and
+.Li LIST
+accepts the
+.Li SUBSCRIBED
+selection option of RFC 9051 Section 6.3.9.1.
+.Li LSUB ,
+which RFC 9051 deprecates in favour of that option, is retained and
+reports the same set of names.
+An account that has never issued
 .Li UNSUBSCRIBE
-are deliberately out of scope, not merely unimplemented; see CAVEATS
-below.
+has every mailbox subscribed; see
+.Pa imapd.subscriptions
+under FILES.
 .Pp
+.Nm
+logs to
+.Xr syslogd 8
+with the
+.Dv LOG_MAIL
+facility, the same one
+.Xr smtpd 8
+uses, so its messages go wherever
+.Xr syslog.conf 5
+directs the mail facility.
+Releases before 0.1.5 used
+.Dv LOG_DAEMON .
+.Pp
 The options are as follows:
 .Bl -tag -width Ds
 .It Fl d
@@ -195,8 +217,11 @@ rereads
 .Pq or the file given via Fl f
 and reloads the
 .Ic spool ,
+.Ic append max ,
 .Ic attachment max ,
 .Ic idle poll ,
+.Ic lock timeout ,
+.Ic login grace ,
 .Ic startups ,
 .Ic tls certificate ,
 and
@@ -347,7 +372,7 @@ Defaults to
 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.
+Must be owned by root, mode 0740 or stricter.
 .It Ic idle poll Ar seconds
 How often a session in
 .Li IDLE
@@ -437,6 +462,81 @@ Defaults to
 100, matching
 .Xr sshd_config 5 Ns 's
 own default of 10:30:100.
+.It Ic login grace Ar seconds
+How long a connection may go without authenticating before
+.Nm
+closes it.
+.Pp
+Every accepted connection costs three processes, a listener-worker, an
+auth-worker and a search-oracle, and a connection that completes the TCP
+handshake and then sends nothing would otherwise hold all three
+indefinitely.
+Enough such connections reach the
+.Ic startups full
+limit and every later connection is refused, so the timer bounds that
+exposure.
+It covers a connection that never begins a TLS handshake on the implicit
+TLS port as well as one that never sends a command, since the former never
+reaches the command path at all.
+.Pp
+RFC 9051, section 5.4 permits this explicitly: servers
+.Qq are allowed to use a shortened pre-authentication timer to protect
+.Qq themselves from Denial-of-Service attacks .
+The timer is cancelled the moment a session authenticates and never applies
+to an established session.
+It is not the post-authentication autologout timer that the same section
+requires to be at least 30 minutes;
+.Nm
+has no such timer.
+.Pp
+Must be between 1 and 3600 seconds inclusive, or 0 to disable it, which
+reopens the denial of service described above.
+Defaults to 60.
+.It Ic lock timeout Ar seconds
+How long a command waits for another session of the same user to release a
+mailbox's index lock before giving up and answering
+.Li NO
+with the RFC 9051, section 7.1
+.Li INUSE
+response code.
+.Pp
+Two connections for one account are ordinary, and a command that changes a
+mailbox holds its index lock for the whole of the change.
+A client marking a large mailbox read can therefore hold the lock for the
+better part of a minute, and another of that user's clients waits behind it.
+This directive bounds that wait.
+.Pp
+It is deliberately generous, because it is a safety net for a holder that
+is stuck rather than a cure for one that is merely slow.
+A deadline shorter than an ordinary command's own duration would refuse
+ordinary concurrent use, and a client that does not retry
+.Li INUSE
+would discard the user's change with nothing shown on screen.
+Raise it for mailboxes much larger than a hundred thousand messages, where
+a single command can legitimately run longer than the default.
+.Pp
+Must be between 1 and 3600 seconds inclusive, or 0 to disable the bound,
+which restores an unbounded wait.
+Defaults to 120.
+.It Ic append max Ar bytes
+Largest message a client may upload with
+.Li APPEND .
+A larger one is refused with a
+.Li NO
+response carrying the RFC 5530
+.Li LIMIT
+code, and nothing is stored.
+.Ar bytes
+must be between 1 and 1073741824 (1 GiB) inclusive; values outside that
+range are a configuration error.
+Defaults to 36700160 (35 MiB), the same as the
+.Ic max-message-size
+default of
+.Xr smtpd.conf 5 .
+It may not exceed
+.Ic attachment max ,
+since a message larger than that could be stored but its structure
+could not be fetched; such a configuration is an error.
 .It Ic attachment max Ar bytes
 Largest message
 .Nm
@@ -450,6 +550,8 @@ structure cannot be produced, not truncated.
 .Ar bytes
 must be between 12000 and 1073741824 (1 GiB) inclusive; values outside
 that range are a configuration error.
+It must be at least
+.Ic append max .
 Defaults to 41943040 (40 MiB).
 .It Ic include Ar path
 Parse
@@ -531,6 +633,52 @@ re-added keeps its history here, which is the desired 
 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.
+.It Pa imapd.subscriptions
+Per-user list of subscribed mailboxes, at the root of each maildir, one
+mailbox name per line.
+.Pp
+The file is created on demand by the first
+.Li UNSUBSCRIBE .
+While it is absent, every mailbox is subscribed.
+That rule is what lets an account upgraded from a version without
+subscriptions keep the mailbox list its client already had, rather than
+come back subscribed to nothing; the first
+.Li UNSUBSCRIBE
+therefore writes out every mailbox then present, less the one being
+removed.
+.Pp
+A name stays in the file after its mailbox is deleted, which RFC 9051
+Section 6.3.8 requires.
+.Li LIST
+with the
+.Li SUBSCRIBED
+option reports such a name with the
+.Li \eNonExistent
+attribute, and
+.Li LSUB
+reports it with
+.Li \eNoselect .
+.Li INBOX
+is never named in the file.
+.Pp
+Neither
+.Li CREATE
+nor
+.Li RENAME
+changes the file.
+A new mailbox is subscribed only when a client subscribes it, and renaming
+a subscribed mailbox leaves the old name in the file rather than moving it
+to the new one.
+RFC 9051 Section 6.3.8 tells a server not to remove a name from the
+subscription list because the mailbox by that name no longer exists, and
+moving it is exactly that, so what is subscribed is left to the client to
+say.
+A subscription stranded by a rename is repaired with one
+.Li SUBSCRIBE .
+.Pp
+A file that cannot be read is treated as absent, so damage shows too many
+mailboxes rather than too few, and the failure is logged.
+It is removed with the maildir and needs no separate administration.
 .El
 .Sh NETWORK
 .Nm
@@ -560,9 +708,11 @@ directive under FILES above.
 .Xr tls_init 3 ,
 .Xr httpd.conf 5 ,
 .Xr sshd_config 5 ,
+.Xr syslog.conf 5 ,
 .Xr httpd 8 ,
 .Xr imapduser 8 ,
-.Xr smtpd 8
+.Xr smtpd 8 ,
+.Xr syslogd 8
 .Sh STANDARDS
 .Rs
 .%A A. Melnikov
@@ -604,11 +754,31 @@ privilege-separation tradition of
 .Xr smtpd 8 .
 .Sh CAVEATS
 This implementation is under active development.
-.Li SUBSCRIBE ,
-.Li UNSUBSCRIBE ,
-and shared or multi-user mailboxes
+.Pp
+.Li INBOX
+cannot be unsubscribed.
+RFC 9051 Section 5.1 guarantees that it always exists and that nothing
+can delete it, so it is treated as permanently subscribed:
+.Li SUBSCRIBE INBOX
+succeeds and changes nothing, and
+.Li UNSUBSCRIBE INBOX
+replies
+.Li NO .
+A subscription-filtered view therefore always includes it.
+.Pp
+.Li LIST
+selection options other than
+.Li SUBSCRIBED
+are refused rather than ignored.
+.Li REMOTE
+and
+.Li RECURSIVEMATCH
+are not implemented, and accepting either silently would misreport which
+names the response contains.
+.Pp
+Shared or multi-user mailboxes
 .Pq no Li ACL support
-are deliberately left out.
+are deliberately out of scope.
 .Pp
 Mailbox names are required to be well-formed UTF-8.
 RFC 9051 Section 5.1 encodes them in Net-Unicode
blob - 3488e7c9336f34c6c5d5a011a449644f91959cfa
blob + 5b86996154c9b3069bea23f9c2a7cf037c026690
--- src/imapd.conf.example
+++ src/imapd.conf.example
@@ -46,7 +46,8 @@ listen on 0.0.0.0 tls port 993
 
 # TLS certificate and private key. Default to /etc/ssl/imapd.crt and
 # /etc/ssl/private/imapd.key respectively. The key must be owned by
-# root (or the current user) and mode 0740 or stricter.
+# root (uid 0 specifically -- unlike imapd.conf itself, this check does
+# not accept the current user) and mode 0740 or stricter.
 #tls certificate "/etc/ssl/imapd.crt"
 #tls key "/etc/ssl/private/imapd.key"
 
@@ -59,6 +60,13 @@ listen on 0.0.0.0 tls port 993
 # routinely carries larger attachments than that.
 attachment max 41943040
 
+# Largest message a client may upload with APPEND, see imapd(8). Must be
+# between 1 and 1073741824 (1 GiB) bytes. Defaults to 36700160 (35 MiB),
+# the same as smtpd.conf(5)'s max-message-size default, so a message the
+# local MTA accepts can also be saved by a client. It may not exceed
+# attachment max above, or imapd refuses the configuration.
+#append max 36700160
+
 # 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
@@ -68,6 +76,35 @@ attachment max 41943040
 # nothing until it sends DONE). Defaults to 5.
 #idle poll 5
 
+# How long a connection may go without authenticating before imapd
+# closes it. Every accepted connection costs three processes (a
+# listener-worker, an auth-worker and a search-oracle), and a connection
+# that completes TCP and then says nothing would otherwise hold them for
+# ever, so a few dozen silent connections can reach the "startups full"
+# limit below and lock everyone else out. RFC 9051 section 5.4 permits
+# this timer explicitly: "servers are allowed to use a shortened
+# pre-authentication timer to protect themselves from Denial-of-Service
+# attacks". The timer is cancelled the moment a session authenticates,
+# so it never applies to an established session, and this is NOT the
+# post-authentication autologout that the same RFC section puts a 30
+# minute floor under; imapd has no such timer. Must be 1-3600 seconds,
+# or 0 to disable, which reopens the denial of service just described.
+# Defaults to 60.
+#login grace 60
+
+# How long a command waits for another session of the same user to
+# release a mailbox's index lock before answering NO [INUSE] (RFC 9051
+# section 7.1). Two clients on one account is ordinary, and a command
+# that changes a mailbox holds the lock for the whole change, so a client
+# marking a large mailbox read can hold it for the better part of a
+# minute. This is a safety net for a holder that is stuck, not a cure for
+# one that is slow: a deadline shorter than an ordinary command would
+# refuse ordinary concurrent use, and a client that does not retry INUSE
+# discards the user's change silently. Raise it for mailboxes much larger
+# than a hundred thousand messages. Must be 1-3600 seconds, or 0 to
+# disable the bound (an unbounded wait). Defaults to 120.
+#lock timeout 120
+
 # 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).
blob - b39ecd65a8d22008183ce59025d7bcd9da215595
blob + 4b279ff164f1c69ee2048b601d4709ae2c096844
--- src/imapd.h
+++ src/imapd.h
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  *
@@ -14,9 +16,7 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/*
- * Shared definitions for all four imapd(8) process roles
- */
+/* Shared definitions for every imapd(8) process role. */
 
 #ifndef IMAPD_H
 #define IMAPD_H
@@ -24,32 +24,26 @@
 #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 <sys/socket.h>	/* sockaddr_storage, socklen_t */
 
 #include <event.h>
 #include <imsg.h>
 #include <stdint.h>
 
-#define IMAPD_VERSION	"0.1.4"
+#define IMAPD_VERSION	"0.1.5"
 
-/*
- * Process roles, selected at exec time via "-x <role>". See main.c.
- */
+/* Process roles, selected at exec time via "-x <role>". See main.c. */
 enum openimap_proc_type {
 	PROC_PARENT,
 	PROC_LISTENER,
 	PROC_AUTH,
 	PROC_STORE,
 	PROC_KEYMGR,
-	PROC_SEARCH		/* SS8: per-connection SEARCH-grammar
-				 * parsing oracle, docs/openimap-tls-privsep-
-				 * design.md SS8.1 */
+	PROC_SEARCH		/* per-connection SEARCH-grammar
+				 * parsing oracle */
 };
 
-/*
- * imsg message catalog.
- */
+/* imsg message catalog. */
 enum imsg_type {
 	IMSG_NONE,
 
@@ -57,26 +51,12 @@ enum imsg_type {
 	IMSG_SETUP_PEER,
 	IMSG_SETUP_DONE,
 
-	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 -> listener-worker at fork, and -> keymgr at boot and on */
+	/* every SIGHUP. The certificate is public; each gets its own copy. */
+	IMSG_TLS_CERT,
 
-	/*
-	 * 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.
-	 */
+	/* parent -> listener-worker at fork: one already-accepted */
+	/* connection, the client fd riding as this imsg's fd-pass. */
 	IMSG_LISTENER_SESSION_INIT,
 
 	/* parent -> auth, at boot */
@@ -86,49 +66,31 @@ 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.
-	 */
+	/* parent -> listener-worker and -> search-oracle at fork, wiring the */
+	/* per-connection SEARCH-parsing oracle. Its own type rather than */
+	/* IMSG_SETUP_PEER, whose id field is already a peer discriminator. */
 	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.
-	 */
+	/* listener -> search-oracle: SEARCH argument text as raw trailing */
+	/* bytes, no fixed struct. Oracle -> listener: struct */
+	/* imsg_search_parse_result. One round trip per session at most, so */
+	/* no correlation id. */
 	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. */
+	/* parent -> keymgr, at boot and on SIGHUP: the real TLS private key */
 	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. */
+	/* listener <-> keymgr: one private-key operation, forwarded from */
+	/* listener's OpenSSL engine override. Each reply reuses its */
+	/* request's type, correlated by the imsg id field. */
 	IMSG_KEYMGR_RSA_PRIVENC,
 	IMSG_KEYMGR_RSA_PRIVDEC,
 	IMSG_KEYMGR_ECDSA_SIGN,
 
+	/* parent -> keymgr, on shutdown: exit, this was not a crash */
+	IMSG_KEYMGR_SHUTDOWN,
+
 	/* auth -> parent, per successful login */
 	IMSG_AUTH_CRED,
 
@@ -137,25 +99,21 @@ enum imsg_type {
 	IMSG_STORE_INIT,
 	IMSG_STORE_SHUTDOWN,
 
-	/* listener <-> store, once a session's store child is wired up */
-	IMSG_MBOX_SELECT,		/* EXAMINE too: it is a SELECT with the
-					 * request's "readonly" field set, see
-					 * select_or_examine() in mailbox_cmd.c */
+	/* listener <-> store, once a session's store child is wired up. */
+	/* SELECT carries EXAMINE too, as a request with "readonly" set. */
+	IMSG_MBOX_SELECT,
 	IMSG_MBOX_SELECTED,
 	IMSG_MBOX_FETCH,
 	IMSG_MBOX_FETCH_META,
-	IMSG_MBOX_FETCH_HEADER,	/* raw BODY.PEEK[HEADER] bytes for one
-					 * message (store -> listener) */
-	IMSG_MBOX_FETCH_BODY,	/* raw BODY.PEEK[] / BODY.PEEK[TEXT] bytes for
-					 * one message (store -> listener) */
-	IMSG_MBOX_FETCH_ENVELOPE,	/* pre-formatted ENVELOPE parenthesized-
-					 * list text for one message (store ->
-					 * listener)*/
-	IMSG_MBOX_FETCH_BODYSTRUCTURE,	/* pre-formatted BODYSTRUCTURE
-					 * parenthesized-list text for one
-					 * message (store -> listener) */
+	/* the four below are all store -> listener, one per message */
+	IMSG_MBOX_FETCH_HEADER,		/* raw BODY.PEEK[HEADER] bytes */
+	IMSG_MBOX_FETCH_BODY,		/* body descriptor and octet range */
+	IMSG_MBOX_FETCH_ENVELOPE,	/* formatted ENVELOPE text */
+	IMSG_MBOX_FETCH_BODYSTRUCTURE,	/* formatted BODYSTRUCTURE text */
 	IMSG_MBOX_STORE,
-	IMSG_MBOX_APPEND,
+	IMSG_MBOX_APPEND,		/* opens the message's tmp/ file */
+	IMSG_MBOX_APPEND_DATA,		/* one piece of the literal */
+	IMSG_MBOX_APPEND_END,		/* literal complete, commit it */
 	IMSG_MBOX_APPENDED,
 	IMSG_MBOX_COPY,
 	IMSG_MBOX_MOVE,
@@ -172,35 +130,28 @@ enum imsg_type {
 	IMSG_MBOX_RENAME,
 	IMSG_MBOX_RESULT,
 
-	/*
-	 * RFC 7162 (CONDSTORE/QRESYNC) additions
-	 */
-	IMSG_MBOX_SELECT_VANISHED,	/* one vanished UID during a QRESYNC
-					 * SELECT resync (store -> listener,
-					 * before IMSG_MBOX_SELECTED) */
-	IMSG_MBOX_STORE_MODIFIED,	/* one message that failed a STORE's
-					 * UNCHANGEDSINCE test (store ->
-					 * listener, before IMSG_MBOX_RESULT) */
+	/* RFC 7162 CONDSTORE/QRESYNC. Both store -> listener, streamed */
+	/* before the terminal reply. */
+	IMSG_MBOX_SELECT_VANISHED,	/* one vanished UID, QRESYNC resync */
+	IMSG_MBOX_STORE_MODIFIED,	/* one UNCHANGEDSINCE failure */
 
-	/*
-	 * RFC 9051 SS6.3.13 (IDLE) additions.
-	 */
-	IMSG_MBOX_IDLE_REFRESH,		/* listener -> store, no payload */
-	IMSG_MBOX_IDLE_UID,		/* one currently-existing UID, in
-					 * ascending order (store -> listener,
-					 * before IMSG_MBOX_IDLE_REFRESHED) */
-	IMSG_MBOX_IDLE_REFRESHED,	/* terminal reply (store -> listener) */
+	/* RFC 9051 SS6.3.13 (IDLE) */
+	IMSG_MBOX_IDLE_REFRESH,		/* listener -> store, seed or diff */
+	IMSG_MBOX_IDLE_EXPUNGE,		/* one untagged EXPUNGE to print */
+	IMSG_MBOX_IDLE_FETCH,		/* one flag change, as fetch_meta */
+	IMSG_MBOX_IDLE_REFRESHED,	/* terminal reply */
 
-	/*
-	 * RFC 9051 SS6.3.4-SS6.3.6 (CREATE/DELETE/RENAME) and SS6.3.9 (LIST).
-	 */
-	IMSG_MBOX_LIST_ITEM		/* one mailbox name (store -> listener),
-					 * before the terminal IMSG_MBOX_RESULT */
+	/* RFC 9051 SS6.3.9 (LIST): one mailbox name, store -> listener, */
+	/* before the terminal IMSG_MBOX_RESULT. */
+	IMSG_MBOX_LIST_ITEM,
+
+	/* RFC 9051 SS6.3.7/SS6.3.8: both carry struct imsg_mbox_subscribe */
+	/* and reply with IMSG_MBOX_RESULT. */
+	IMSG_MBOX_SUBSCRIBE,
+	IMSG_MBOX_UNSUBSCRIBE
 };
 
-/*
- * privsep imsg-over-event(3) wrapper.
- */
+/* privsep imsg-over-event(3) wrapper. */
 struct imsgev {
 	struct imsgbuf	 ibuf;
 	void		(*handler)(int, short, void *);
@@ -213,18 +164,9 @@ struct imsgev {
 
 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 */
-	/*
-	 * 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.
-	 */
+					 * address, or "*" for both */
+	/* A port of 0 means this listener is not configured. parse.y clears */
+	/* the one a config did not name, but only if it named either. */
 	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 */
@@ -235,57 +177,50 @@ struct openimap_config {
 	char	 tls_cert_file[1024];
 	char	 tls_key_file[1024];
 	uint32_t bodystructure_read_max; /* "attachment max" directive */
+	uint64_t append_max;		/* "append 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". */
+	/* the "startups begin/rate/full" directive. config_load() defaults */
+	/* to 10/30/100, matching sshd_config(5)'s own "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().
-	 */
+	/* "idle poll": how often an IDLEing session asks its store child */
+	/* whether the mailbox changed. 0 disables polling, and an IDLEing */
+	/* session then sees nothing until it sends DONE. */
 	uint32_t idle_poll_secs;
+
+	/* "lock timeout": seconds a command waits for another session's */
+	/* index lock before answering NO [INUSE]. 0 disables the bound, */
+	/* restoring the unbounded wait it replaced. */
+	uint32_t lock_timeout_secs;
+
+	/* "login grace": seconds a connection may go without authenticating */
+	/* before it is closed. 0 disables the timer, which reopens the */
+	/* denial of service it exists to stop. */
+	uint32_t login_grace_secs;
 };
 
-/*
- * imsg payload wire structs.
- */
+/* imsg payload wire structs. */
 #define AUTH_USERNAME_MAX	64
 #define AUTH_PASSWORD_MAX	128
 #define AUTH_MAILDIR_MAX	256
 
-/*
- * Boot-time config-delivery payloads.
- */
-/*
- * 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).
- */
+/* Boot-time config-delivery payloads. */
+
+/* IMSG_LISTENER_SESSION_INIT's payload. The client fd rides as the imsg's */
+/* fd-pass, not a field here. remote_ss/remote_sslen are the raw sockaddr */
+/* from accept(2), so the worker does its own getnameinfo() formatting. */
 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.
-	 */
+	/* carried per connection, since this process is spawned fresh per */
+	/* connection and so needs no reload path of its own */
 	uint32_t		idle_poll_secs;
+	uint32_t		login_grace_secs;
+	uint64_t		append_max;
 };
 
 struct imsg_auth_init {
@@ -293,45 +228,39 @@ struct imsg_auth_init {
 };
 
 /*
- * 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.
+ * IMSG_KEYMGR_RSA_PRIVENC / _RSA_PRIVDEC / _ECDSA_SIGN (listener to keymgr
+ * and back, same imsg type each way, correlated by the imsg id field).
+ * Fixed header plus trailing raw bytes on one imsg, as imsg_mbox_append
+ * below does.
  *
- * 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.
+ * hash is libtls's tls_cert_pubkey_hash() format ("SHA256:" plus lowercase
+ * hex of the certificate's DER SubjectPublicKeyInfo digest); keymgr.c
+ * recomputes it from the certificate it holds and refuses a mismatch.
+ * padding is an OpenSSL RSA_PKCS1_PADDING-style constant, ignored for
+ * ECDSA_SIGN. KEYMGR_DATA_MAX (1024) covers an RSA buffer up to an
+ * 8192-bit key and any ECDSA digest or signature.
  */
 #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 */
+	/* RSA padding mode; ignored for ECDSA */
+	uint32_t	padding;
+	/* trailing input bytes, <= KEYMGR_DATA_MAX */
+	uint32_t	fromlen;
 };
 
 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 */
+	/*
+	 * 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()
+	 */
+	int		ok;
+	/* trailing output bytes, meaningful only if ok */
+	uint32_t	tolen;
 };
 
 struct imsg_auth_request {
@@ -365,13 +294,18 @@ struct imsg_store_init {
 	uint32_t	session_id;
 	uid_t		uid;
 	gid_t		gid;
-	char		spool_root[1024];	/* store needs this to chroot() */
-	char		maildir[STORE_MAILDIR_MAX]; /* THIS session's own
-						 * mailbox subdirectory, relative
-						 * to spool_root above */
-	uint32_t	bodystructure_read_max; /* copied from struct
-						 * openimap_config's field of the
-						 * same name */
+	/* store needs this to chroot() */
+	char		spool_root[1024];
+	/*
+	 * THIS session's own mailbox subdirectory, relative to spool_root above
+	 */
+	char		maildir[STORE_MAILDIR_MAX];
+	/*
+	 * copied from struct openimap_config's field of the same name
+	 */
+	uint32_t	bodystructure_read_max;
+	uint64_t	append_max;
+	uint32_t	lock_timeout_secs;
 };
 
 /*
@@ -387,7 +321,7 @@ struct imsg_store_init {
  *
  * MBOX_OP_ERR_NO_SUCH_MAILBOX and MBOX_OP_ERR_ALREADY_EXISTS are
  * client-visible via RFC 5530 SS3's NONEXISTENT and ALREADYEXISTS codes
- * respectively.
+ * respectively, and MBOX_OP_ERR_BUSY via RFC 9051 SS7.1's INUSE.
  */
 enum mbox_op_error {
 	MBOX_ERR_UNSET = 0,
@@ -395,6 +329,8 @@ enum mbox_op_error {
 	MBOX_OP_ERR_GENERIC,
 	MBOX_OP_ERR_NO_SUCH_MAILBOX,
 	MBOX_OP_ERR_ALREADY_EXISTS,
+	/* gave up waiting for another session's index lock */
+	MBOX_OP_ERR_BUSY,
 };
 
 /* QRESYNC select-param (RFC 7162 SS3.2.5). */
@@ -465,8 +401,8 @@ struct imsg_mbox_selected {
  * streaming.
  *
  * mailbox targets any named mailbox independent of what's selected
- * (SS6.3.11); store.c visits it via select_mailbox_dir() and restores the
- * prior selection afterward.
+ * (SS6.3.11); store.c opens it with mailbox_open_dir() and closes it
+ * afterward, leaving the selection alone.
  */
 struct imsg_mbox_status {
 	char		mailbox[MBOX_NAME_MAX];
@@ -575,126 +511,77 @@ struct imsg_mbox_select_vanished {
 						 * hence a separate bit from
 						 * WHOLE/TEXT. */
 
-/*
- * 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.
- *
- * 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.)
- */
+/* Cap on the dotted-numeric section-part string. An oversized one is */
+/* dropped as an unsupported fetch-att, not truncated and not an error. */
 #define SECTION_PART_MAX	40
 
-/*
- * Cap on raw header bytes IMSG_MBOX_FETCH_HEADER can carry (must fit
- * under imsg's MAX_IMSGSIZE, 16384). Real RFC 5322 headers are well
- * under 8192; an oversized header is "not found" for BODY.PEEK[HEADER],
- * not truncated.
- */
+/* Cap on raw header bytes one IMSG_MBOX_FETCH_HEADER carries. An */
+/* oversized header is "not found", not truncated. */
 #define FETCH_HEADER_MAX	8192
 
-/*
- * Cap on a whole message's size, for both APPEND and BODY.PEEK[]/
- * BODY.PEEK[TEXT] (shared so the two enforcement points can't drift).
- * 12000 leaves headroom under imsg's 16384-byte MAX_IMSGSIZE. Larger
- * messages need real fd-passing, not implemented; rejected rather than
- * truncated.
- */
-#define APPEND_LITERAL_MAX	12000
+/* "append max" default and ceiling. The default matches smtpd.conf(5)'s */
+/* max-message-size default of 35M. */
+#define APPEND_MAX_DEFAULT	(35 * 1024 * 1024)
+#define APPEND_MAX_MAX		1073741824
 
-/*
- * Cap on bytes any single BODY[<section>]/BODY.PEEK[<section>] response
- * can carry on one IMSG_MBOX_FETCH_BODY; reuses APPEND_LITERAL_MAX's
- * value and MAX_IMSGSIZE-headroom reasoning. Real clients (confirmed:
- * Apple Mail) re-fetch large parts via <<partial>> ranges rather than
- * expecting a whole part in one response. A <<partial>> count larger
- * than this is clamped down, per RFC 9051 SS6.4.5's own truncate-past-
- * end-of-text precedent, rather than rejected.
- */
-#define FETCH_PART_MAX		APPEND_LITERAL_MAX
+/* Bytes a FETCH walk composes before it pauses to let them drain, so */
+/* the store holds this much of a reply rather than all of it. */
+#define FETCH_BATCH_MAX		(1024 * 1024)
 
-/*
- * Cap on the space-joined header-field-name list a BODY.PEEK[HEADER.
- * FIELDS[.NOT] (...)] fetch-att carries to store.c. 256 bytes covers
- * any realistic request; listener.c rejects (BAD) an oversized list
- * rather than truncating it.
- */
+/* Descriptors a FETCH walk passes before it pauses. Each holds a slot in */
+/* the system-wide file table, kern.maxfiles, until it has been sent. */
+#define FETCH_FD_MAX		16
+
+/* Cap on the space-joined header-field-name list; an oversized list is */
+/* rejected, not truncated. */
 #define HEADER_FIELDS_MAX	256
 
-/*
- * Cap on the fully-formatted ENVELOPE text store.c's build_envelope()
- * can carry on one IMSG_MBOX_FETCH_ENVELOPE. Sized off FETCH_HEADER_MAX
- * (8192), since every envelope field comes from a header bounded by
- * that same cap. An oversized envelope is "not found", not truncated.
- */
+/* Cap on formatted ENVELOPE text; an oversized one is "not found". */
 #define ENVELOPE_MAX	8192
 
-/*
- * Caps on store.c's recursive BODYSTRUCTURE builder (build_body_
- * structure()): a message's own headers claim its own part count and
- * nesting depth, so both need a hard ceiling rather than trusting them.
- * Exceeding either is "not found" for that message's BODYSTRUCTURE
- * rather than a truncated part tree.
- */
+/* Ceilings on the recursive BODYSTRUCTURE builder: a message's own */
+/* headers claim its part count and depth, so neither may be trusted. */
+/* Exceeding either is "not found" rather than a truncated part tree. */
 #define MIME_MAX_DEPTH	10
 #define MIME_MAX_PARTS	64
 
-/*
- * Cap on the fully-formatted BODYSTRUCTURE text store.c's
- * build_bodystructure() can carry on one IMSG_MBOX_FETCH_BODYSTRUCTURE.
- * 12000 mirrors APPEND_LITERAL_MAX's MAX_IMSGSIZE-headroom reasoning;
- * MIME_MAX_PARTS/MIME_MAX_DEPTH, not this byte cap, are expected to be
- * the limiting factor in practice.
- */
+/* Cap on formatted BODYSTRUCTURE text; MIME_MAX_PARTS and */
+/* MIME_MAX_DEPTH above are the limits that bite first in practice. */
 #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.
- */
+/* "idle poll" bounds. The default is short because an unchanged mailbox */
+/* costs two stat(2) calls and no lock. IDLE_POLL_MAX is a sanity bound. */
 #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
- * MTA isn't size-bounded by this server's own APPEND cap, and
- * real-hardware testing showed base64 attachments routinely exceed it.
- * The read is never sent back over the wire whole (only the derived,
- * already-bounded structure summary is), so it can afford to be large.
- *
- * 41943040 (40 MiB) is sized off Gmail's documented 25MB attachment
- * limit (support.google.com/mail/answer/6584) plus base64 overhead, not
- * a protocol requirement. Exceeding it is "not found" for that
- * message's BODYSTRUCTURE, not truncated.
- *
- * Operator-configurable via imapd.conf's "attachment max <bytes>"
- * directive (parse.y, struct openimap_config's bodystructure_read_max).
- * store.c uses the runtime value from IMSG_STORE_INIT; this macro now
- * only serves as config_load()'s default when the directive is absent.
- */
+/* "login grace" bounds. RFC 9051 SS5.4 permits a shortened */
+/* pre-authentication timer specifically against denial of service; the */
+/* 30 minute floor in that section governs a POST-authentication */
+/* autologout, which this server does not have. */
+#define LOGIN_GRACE_DEFAULT	60	/* seconds */
+#define LOGIN_GRACE_MAX		3600	/* seconds; 0 disables the timer */
+
+/* "lock timeout" bounds. A command that cannot take a mailbox's index */
+/* lock waits this long before answering NO [INUSE] (RFC 9051 SS7.1). It */
+/* is a safety net for a holder that is stuck, not a cure for one that is */
+/* merely slow: an ordinary STORE over a large mailbox holds the lock for */
+/* the better part of a minute on modest hardware, and a deadline under */
+/* that would refuse ordinary concurrent use. */
+#define LOCK_TIMEOUT_DEFAULT	120	/* seconds */
+#define LOCK_TIMEOUT_MAX	3600	/* seconds; 0 disables the bound */
+
+/* Cap on on-disk bytes read while deriving a message's MIME structure. */
+/* Deliberately above APPEND_MAX_DEFAULT: mail from an external MTA is */
+/* not bounded by "append max", and only the derived summary */
+/* goes back over the wire. Exceeding it is "not found", not truncated. */
+/* config_load()'s default for imapd.conf's "attachment max". */
 #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.
+ * One comma-separated range of an RFC 9051 SS9 sequence-set. "*" travels
+ * unresolved via lo_is_star/hi_is_star, since only the store knows the live
+ * value to resolve it against. A whole sequence-set rides as an imsg
+ * request's trailing seq_range[nranges] array.
  */
 struct seq_range {
 	uint32_t	lo;	/* 1-based, inclusive; ignored if lo_is_star */
@@ -703,16 +590,8 @@ struct seq_range {
 	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.
- */
+/* Bounds a sequence-set's range count, so the trailing seq_range array */
+/* cannot grow an imsg past MAX_IMSGSIZE. */
 #define SEQSET_MAX_RANGES	500
 
 struct imsg_mbox_fetch {
@@ -763,8 +642,8 @@ struct imsg_mbox_fetch {
 	 * range (SS6.4.5), applying uniformly to whole/TEXT/section_part.
 	 * store.c slices the extracted content to
 	 * [partial_start, partial_start + partial_count), clamped to
-	 * FETCH_PART_MAX and the content's actual length (SS6.4.5's
-	 * truncate-past-end-of-text rule). has_partial distinguishes
+	 * the content's actual length (SS6.4.5's truncate-past-end-of-text
+	 * rule) and to nothing else. has_partial distinguishes
 	 * "no range" from a legal partial_start of 0.
 	 */
 	char		section_part[SECTION_PART_MAX];
@@ -819,28 +698,22 @@ struct imsg_mbox_fetch_header {
 /*
  * IMSG_MBOX_FETCH_BODY: sent by store.c immediately before the
  * IMSG_MBOX_FETCH_META for the same message, iff req->attrs &
- * (MBOX_FETCH_BODY_WHOLE | MBOX_FETCH_BODY_TEXT). Same ordering contract
- * and wire shape as imsg_mbox_fetch_header above.
+ * (MBOX_FETCH_BODY_WHOLE | MBOX_FETCH_BODY_TEXT | MBOX_FETCH_BODY_PART).
+ * The octets do not ride on the imsg. A found, non-empty body carries a
+ * read-only descriptor on the message file, and offset and length say
+ * which octets the literal is; the store has already done any parsing,
+ * so the listener only reads and writes.
  */
 struct imsg_mbox_fetch_body {
 	uint32_t	seqno;		/* 1-based, matches the following
 					 * IMSG_MBOX_FETCH_META */
 	uint32_t	uid;
 	int		found;		/* 0 if the message file couldn't be
-					 * found, it exceeded
-					 * APPEND_LITERAL_MAX, it contained a
-					 * NUL byte, or (is_text only) no
-					 * header/body separator was found.
-					 * bodylen and the trailing bytes are
-					 * only meaningful if 1. */
-	int		is_text;	/* 0 = BODY.PEEK[] bytes (whole
-					 * message); 1 = BODY.PEEK[TEXT] bytes
-					 * (body only). Set from which of
-					 * MBOX_FETCH_BODY_WHOLE/_TEXT was
-					 * requested (WHOLE wins if both). */
-	uint32_t	bodylen;	/* length of the trailing raw body
-					 * bytes on this imsg, capped at
-					 * APPEND_LITERAL_MAX */
+					 * found, it contained a NUL byte, or
+					 * (TEXT only) no header/body
+					 * separator was found */
+	uint64_t	offset;		/* first octet, from the file's start */
+	uint64_t	length;		/* octets; no descriptor when 0 */
 };
 
 /*
@@ -1083,17 +956,11 @@ struct imsg_mbox_copy_mapping {
  * IMSG_MBOX_APPEND (listener -> store) / IMSG_MBOX_APPENDED (store ->
  * listener, exactly once).
  *
- * RFC 9051 SS6.3.12 append literal (the message body) is carried as
- * variable-length trailing data on the SAME imsg after this fixed
- * struct, not fd-passed: store.c's handle_mbox_append() reads the
- * struct with imsg_get_buf(), then reads whatever imsg_get_len()
- * reports afterward as the body (imsg_get_data() can't be used here,
- * it requires an exact length match against the whole imsg).
- *
- * Only works because the message is capped at APPEND_LITERAL_MAX to
- * fit under imsg's MAX_IMSGSIZE (16384) alongside this struct. A larger
- * message needs real fd-passing, not implemented; listener.c rejects an
- * oversized literal announcement with a plain NO before reading it.
+ * The RFC 9051 SS6.3.12 literal does not ride on this imsg. This struct
+ * goes alone when the literal is announced, the octets follow as
+ * IMSG_MBOX_APPEND_DATA pieces as they are read, and IMSG_MBOX_APPEND_END
+ * follows the command's closing CRLF. The store answers only after END.
+ * Each piece is at most SESSION_INBUF_MAX, under MAX_IMSGSIZE.
  */
 struct imsg_mbox_append {
 	char		mailbox[MBOX_NAME_MAX];
@@ -1105,17 +972,14 @@ struct imsg_mbox_append {
 					 * (SS6.3.12) */
 	int64_t		date;		/* Unix timestamp; meaningful only if
 					 * has_date */
-	uint32_t	msglen;		/* length of the trailing message
-					 * bytes; redundant with imsg_get_len()
-					 * after the header is read, kept as
-					 * an explicit cross-check against
-					 * struct-layout skew or truncation */
+	uint64_t	msglen;		/* announced literal length; the
+					 * DATA pieces must add up to it */
 };
 
 struct imsg_mbox_appended {
 	enum mbox_op_error error;	/* MBOX_OP_ERR_NO_SUCH_MAILBOX, "not
 					 * INBOX", tagged NO gets [TRYCREATE]
-					 * per SS6.3.12; MBOX_OP_ERR_GENERIC, 
+					 * per SS6.3.12; MBOX_OP_ERR_GENERIC,
 					 * plain NO */
 	uint32_t	uidvalidity;
 	uint32_t	uid;		/* appended message's UID; with
@@ -1237,9 +1101,11 @@ struct imsg_mbox_search {
 #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 */
+	/*
+	 * valid when rc == 0; struct search_node[nnodes] trails, same technique
+	 * as imsg_mbox_search above
+	 */
+	uint32_t	nnodes;
 	int		uses_modseq;	/* valid when rc == 0, see this
 					 * struct's own comment */
 	char		errmsg[SEARCH_ORACLE_ERRMSG_MAX]; /* valid when
@@ -1260,56 +1126,60 @@ struct imsg_mbox_search_match {
 };
 
 /*
- * RFC 9051 SS6.3.13 (IDLE). IMSG_MBOX_IDLE_REFRESH (listener -> store, no
- * payload) / IMSG_MBOX_IDLE_UID (store -> listener, one per existing
- * message, ascending UID order) / IMSG_MBOX_IDLE_REFRESHED (store ->
- * listener, terminal).
+ * RFC 9051 SS6.3.13 (IDLE). IMSG_MBOX_IDLE_REFRESH (listener -> store),
+ * IMSG_MBOX_IDLE_EXPUNGE (store -> listener, one per untagged EXPUNGE to
+ * print, in order), IMSG_MBOX_IDLE_FETCH (store -> listener, one per
+ * message whose flags changed, carrying imsg_mbox_fetch_meta as FETCH and
+ * QRESYNC resync do), IMSG_MBOX_IDLE_REFRESHED (terminal).
  *
- * 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.
+ * SS6.3.13 names flag changes among what IDLE exists to report, and
+ * requires an unsolicited FETCH to carry a UID item (SS7.5.2 repeats it).
+ * Messages that arrived since the last refresh are reported by EXISTS
+ * alone: their flags are news to nobody, and a client that wants them
+ * asks.
  *
- * 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.
+ * Sent once after "+ idling" with seed set, then once per "idle poll"
+ * interval without it. store.c answers from refresh_index(), so a refresh
+ * sees what a fresh SELECT would, new mail from an external MTA included.
+ * Most polls cost two stat(2) calls and no lock; see index.c's
+ * idle_probe_unchanged().
  *
- * 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.
+ * The store child holds the UID list it last reported and compares in one
+ * walk, so what crosses the socket is what to print rather than the whole
+ * mailbox: a change to one message costs one imsg, not one per message.
  */
-struct imsg_mbox_idle_uid {
-	uint32_t	uid;
+struct imsg_mbox_idle_refresh {
+	/*
+	 * Adopt the current state as the baseline and report nothing. Set
+	 * whenever the listener cannot vouch for what the client has already
+	 * been told: at "+ idling", since the mailbox may have changed while
+	 * the session was not idling.
+	 */
+	int		seed;
 };
 
+struct imsg_mbox_idle_expunge {
+	uint32_t	seqno;		/* 1-based, already decremented for
+					 * the EXPUNGEs sent before it
+					 * (RFC 9051 SS7.5.1) */
+};
+
 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.
+	 * directory nor new/ touched since the last look, in which case no
+	 * IMSG_MBOX_IDLE_EXPUNGE messages preceded this one and every field
+	 * below is left zero and is meaningless.
 	 */
 	int		unchanged;
+	int		exists_changed;	/* print exists as "* n EXISTS" */
 	uint32_t	exists;
-	uint32_t	uidvalidity;
-	uint32_t	uidnext;
-	uint64_t	highestmodseq;
+	int		busy;	/* lock held elsewhere; ok says if it seeded */
 };
 
 /*
- * RFC 9051 SS6.3.4/SS6.3.5 (CREATE/DELETE) and SS6.3.9 (LIST), this pass, 
+ * RFC 9051 SS6.3.4/SS6.3.5 (CREATE/DELETE) and SS6.3.9 (LIST), this pass,
  * flat, non-nested mailboxes as sibling subdirectories of the
  * session's own per-user maildir root; CREATE/DELETE/RENAME all reply with
  * the existing struct imsg_mbox_result, only "ok" meaningful). listener.c
@@ -1339,15 +1209,42 @@ struct imsg_mbox_rename {
 };
 
 /*
- * RFC 9051 SS6.3.9 (LIST). One IMSG_MBOX_LIST_ITEM per on-disk mailbox
- * subdirectory (INBOX excluded), unordered; listener.c does its own
- * wildcard matching. Terminal reply reuses imsg_mbox_result ("ok" = 0
- * only on a real I/O error, not on finding zero mailboxes).
+ * RFC 9051 SS6.3.9 (LIST). One IMSG_MBOX_LIST_ITEM per mailbox, unordered,
+ * INBOX excluded; listener.c does its own wildcard matching. Terminal reply
+ * reuses imsg_mbox_result ("ok" = 0 only on a real I/O error, not on finding
+ * zero mailboxes).
+ *
+ * subscribed_only picks WHICH set of names is streamed: 0 is every mailbox
+ * on disk, 1 is every subscribed name, which SS6.3.9.1 says may include names
+ * with no mailbox behind them. The store reads the subscription file only
+ * when this is 1, so a plain LIST -- what a client sends on every connection
+ * -- costs exactly what it did before subscriptions existed.
  */
+struct imsg_mbox_list {
+	int		subscribed_only;
+};
+
+/*
+ * One mailbox name for the LIST above. `exists` is 0 only in a
+ * subscribed_only stream, naming something subscribed with no mailbox on
+ * disk: SS6.3.8 forbids dropping such a name from the list, and SS6.3.9.6's
+ * Table 3 has the server report it rather than stay silent. Every item in a
+ * subscribed_only stream is subscribed by construction, so no field says so.
+ */
 struct imsg_mbox_list_item {
 	char		mailbox[MBOX_NAME_MAX];
+	int		exists;
 };
 
+/*
+ * RFC 9051 SS6.3.7 (SUBSCRIBE) / SS6.3.8 (UNSUBSCRIBE). One struct for
+ * both, as IMSG_MBOX_COPY and IMSG_MBOX_MOVE already share imsg_mbox_copy.
+ * Terminal reply is imsg_mbox_result, only "error" meaningful.
+ */
+struct imsg_mbox_subscribe {
+	char		mailbox[MBOX_NAME_MAX];
+};
+
 /* main.c */
 const char	*log_procname(enum openimap_proc_type);
 
blob - 1867c3df066b4fed8fcc23aac6f1b02eb03727eb
blob + 52f95b54c764213581617143650d8138ecb2e94b
--- src/imsgev.c
+++ src/imsgev.c
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  * Copyright (c) 2009 Eric Faurot <eric@openbsd.org>
@@ -35,7 +37,11 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* Shared imsgbuf+event(3) wrapper for parent/listener/auth/store, built on the current imsgbuf_*() API (imsg_get() and friends were removed upstream); imsgbuf_get()'s 1/0/-1 return is handled exactly as before. */
+/*
+ * Shared imsgbuf+event(3) wrapper for parent/listener/auth/store, built on the
+ * current imsgbuf_*() API (imsg_get() and friends were removed upstream);
+ * imsgbuf_get()'s 1/0/-1 return is handled exactly as before.
+ */
 
 #include <sys/types.h>
 
@@ -46,7 +52,12 @@
 #include "imapd.h"
 #include "log.h"
 
-/* Sets imapd-wide imsgbuf settings for every channel: imsgbuf_set_maxsize() raises the whole-message limit by IMSG_HEADER_SIZE since its argument is payload-only, and imsgbuf_allow_fdpass() is needed since the parent fd-passes on these channels at spawn time. */
+/*
+ * Sets imapd-wide imsgbuf settings for every channel: imsgbuf_set_maxsize()
+ * raises the whole-message limit by IMSG_HEADER_SIZE since its argument is
+ * payload-only, and imsgbuf_allow_fdpass() is needed since the parent fd-passes
+ * on these channels at spawn time.
+ */
 void
 imsgev_ibuf_init(struct imsgbuf *ibuf, int fd)
 {
@@ -57,7 +68,11 @@ imsgev_ibuf_init(struct imsgbuf *ibuf, int fd)
 	imsgbuf_allow_fdpass(ibuf);
 }
 
-/* Arms EV_WRITE via libutil's imsg_close() callback so every queued message gets it exactly once; the early return skips re-arming once EV_WRITE is already pending mid-batch. */
+/*
+ * Arms EV_WRITE via libutil's imsg_close() callback so every queued message
+ * gets it exactly once; the early return skips re-arming once EV_WRITE is
+ * already pending mid-batch.
+ */
 static void
 imsgev_on_compose(struct imsgbuf *ibuf, void *arg)
 {
@@ -83,12 +98,17 @@ imsgev_init(struct imsgev *iev, int fd, void (*handler
 	event_set(&iev->ev, fd, iev->events, iev->handler, iev->data);
 	event_add(&iev->ev, NULL);
 
-	/* Must follow event_set()/event_add() (callback touches iev->ev) and imsgev_ibuf_init() (imsgbuf_init() memset()s the struct); not done inside imsgev_ibuf_init() itself since roles call it pre-event-loop on fd 3. */
+	/*
+	 * Must follow event_set()/event_add() (callback touches iev->ev) and
+	 * imsgev_ibuf_init() (imsgbuf_init() memset()s the struct); not done
+	 * inside imsgev_ibuf_init() itself since roles call it pre-event-loop
+	 * on fd 3.
+	 */
 	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) */
+/* like imsgev_init(), but copies an already-init'd *ibuf (no re-init) */
 void
 imsgev_init_from_ibuf(struct imsgev *iev, const struct imsgbuf *ibuf,
     void (*handler)(int, short, void *), void *data)
@@ -108,7 +128,7 @@ imsgev_init_from_ibuf(struct imsgev *iev, const struct
 	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. */
+/* re-arm after imsg_compose(); adds EV_WRITE if output is queued */
 void
 imsgev_add(struct imsgev *iev)
 {
@@ -122,14 +142,21 @@ imsgev_add(struct imsgev *iev)
 	event_add(&iev->ev, NULL);
 }
 
-/* Re-arms EV_READ (which imsgev_init() sets without EV_PERSIST, so it drops after firing) at the end of every dispatch handler, delegating to imsgev_add() -- kept as a separate name for clarity, not different behavior. */
+/*
+ * Re-arms EV_READ (which imsgev_init() sets without EV_PERSIST, so it drops
+ * after firing) at the end of every dispatch handler, delegating to
+ * imsgev_add() -- kept as a separate name for clarity, not different behavior.
+ */
 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 */
+/*
+ * 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)
 {
@@ -159,7 +186,10 @@ 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: imsgbuf_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)
 {
@@ -174,7 +204,8 @@ setup_recv_done_and_ack(struct imsgbuf *ibuf3)
 		if ((n = imsgbuf_read(ibuf3)) == -1)
 			fatal("imsgbuf_read");
 		if (n == 0)
-			fatalx("setup_recv_done_and_ack: parent closed channel");
+			fatalx("setup_recv_done_and_ack: parent closed "
+			    "channel");
 	}
 
 	if (imsg_get_type(&imsg) != IMSG_SETUP_DONE)
blob - 9a77c2b79924002c66a151ee72482f4bebf69da2
blob + 294bf25f0d4dfad1fb3c5ff997f48046af9e8abe
--- src/index.c
+++ src/index.c
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  *
@@ -14,7 +16,7 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* index.c, the maildir index file format: load/save/append, QRESYNC resync, and vanished-UID tracking. */
+/* index.c: maildir index format -- load/save/append, QRESYNC, vanished-UID. */
 
 #include <sys/types.h>
 #include <sys/file.h>
@@ -36,8 +38,16 @@
 #include "log.h"
 #include "store_internal.h"
 
-/* Index lines are colon-delimited text, so no field may contain ':', CR, or LF -- centralized here since keywords-field callers bypass index_append() and hand-build lines. */
-/* RFC 7162 SS7 bounds a mod-sequence to a positive 63-bit integer; values read back from the index are bounded the same way the wire-facing parsers already are. */
+/*
+ * Index lines are colon-delimited text, so no field may contain ':', CR, or LF
+ * -- centralized here since keywords-field callers bypass index_append() and
+ * hand-build lines.
+ */
+/*
+ * RFC 7162 SS7 bounds a mod-sequence to a positive 63-bit integer; values read
+ * back from the index are bounded the same way the wire-facing parsers already
+ * are.
+ */
 #define INDEX_MODSEQ_MAX	INT64_MAX
 
 int
@@ -46,13 +56,20 @@ index_field_valid(const char *field)
 	return (field != NULL && strpbrk(field, ":\r\n") == NULL);
 }
 
-/* A basename read from the index is pasted into paths for open(2)/stat(2)/rename(2); unveil(2) only stops it leaving the maildir, so load-time enforces the same format rules as the write side. */
+/*
+ * A basename read from the index is pasted into paths for
+ * open(2)/stat(2)/rename(2); unveil(2) only stops it leaving the maildir, so
+ * load-time enforces the same format rules as the write side.
+ */
 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. */
+	/*
+	 * 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 */
@@ -65,7 +82,12 @@ index_basename_valid(const char *basename)
 	return (1);
 }
 
-/* Grows idx->lines by doubling (from 16) when it is full; index_load() and index_append() carried byte-identical copies of this block, so the growth policy and its failure log now live in one place. Returns 0 when there is room for one more line, -1 on allocation failure (already logged). */
+/*
+ * Grows idx->lines by doubling (from 16) when it is full; index_load() and
+ * index_append() carried byte-identical copies of this block, so the growth
+ * policy and its failure log now live in one place. Returns 0 when there is
+ * room for one more line, -1 on allocation failure (already logged).
+ */
 static int
 index_lines_grow(struct mbox_index *idx)
 {
@@ -111,7 +133,11 @@ index_load(int fd, struct mbox_index *idx)
 	}
 
 	while (fgets(line, sizeof(line), fp) != NULL) {
-		/* fgets(3) silently splits an over-long line; peek at the next byte to distinguish a legal max-length line (next byte is '\n' or EOF) from an actual split record. */
+		/*
+		 * fgets(3) silently splits an over-long line; peek at the next
+		 * byte to distinguish a legal max-length line (next byte is
+		 * '\n' or EOF) from an actual split record.
+		 */
 		if (strchr(line, '\n') == NULL &&
 		    strlen(line) == sizeof(line) - 1) {
 			int	c = fgetc(fp);
@@ -128,7 +154,8 @@ index_load(int fd, struct mbox_index *idx)
 			continue;
 
 		if (first) {
-			char	*colon, *colon2, *ep;
+			char		*colon, *colon2, *ep;
+			unsigned long	 parsed;
 
 			first = 0;
 			if ((colon = strchr(line, ':')) == NULL) {
@@ -137,21 +164,37 @@ index_load(int fd, struct mbox_index *idx)
 				goto fail;
 			}
 			*colon = '\0';
-			/* Each header field is digits-or-nothing: strtoul(3)/strtoull(3) accept leading whitespace and a sign, so unguarded input like "-1:1:1" would silently parse into a bogus value; same guard used elsewhere. */
+			/*
+			 * Each header field is digits-or-nothing:
+			 * strtoul(3)/strtoull(3) accept leading whitespace and
+			 * a sign, so unguarded input like "-1:1:1" would
+			 * silently parse into a bogus value; same guard used
+			 * elsewhere. The two uint32 fields are range-checked
+			 * before narrowing as well: RFC 9051 SS2.3.1.1 makes
+			 * UIDVALIDITY and UIDNEXT non-zero 32-bit values, and
+			 * "4294967296" would otherwise truncate to 0 and
+			 * "4294967297" to 1, the second handing out UIDs that
+			 * are already in 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) {
+			parsed = strtoul(line, &ep, 10);
+			if (*ep != '\0' || errno != 0 ||
+			    parsed > UINT32_MAX) {
 				log_warnx("session %u: malformed "
 				    "UIDVALIDITY: %s", session_id, line);
 				goto fail;
 			}
+			idx->uidvalidity = (uint32_t)parsed;
 
-			/* RFC 7162: optional third field HIGHESTMODSEQ; NULL means older two-field header, defaults to 1 */
+			/*
+			 * RFC 7162: optional third field HIGHESTMODSEQ; NULL
+			 * means older two-field header, defaults to 1
+			 */
 			if ((colon2 = strchr(colon + 1, ':')) != NULL)
 				*colon2 = '\0';
 
@@ -161,12 +204,14 @@ index_load(int fd, struct mbox_index *idx)
 				goto fail;
 			}
 			errno = 0;
-			idx->uidnext = (uint32_t)strtoul(colon + 1, &ep, 10);
-			if (*ep != '\0' || errno != 0) {
+			parsed = strtoul(colon + 1, &ep, 10);
+			if (*ep != '\0' || errno != 0 ||
+			    parsed > UINT32_MAX) {
 				log_warnx("session %u: malformed UIDNEXT: %s",
 				    session_id, colon + 1);
 				goto fail;
 			}
+			idx->uidnext = (uint32_t)parsed;
 
 			if (colon2 != NULL) {
 				if (colon2[1] < '0' || colon2[1] > '9') {
@@ -214,34 +259,49 @@ index_load(int fd, struct mbox_index *idx)
 	return (0);
 
 fail:
-	/* idx may hold partially-allocated lines here; index_free() is a safe no-op, making "-1 means idx is already freed" true for every caller including refresh_index(). */
+	/*
+	 * idx may hold partially-allocated lines here; index_free() is a safe
+	 * no-op, making "-1 means idx is already freed" true for every caller
+	 * including refresh_index().
+	 */
 	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. */
+/* Parses "UID:basename:keywords[:MODSEQ]"; -1 (logged) on corrupt line. */
 int
 index_parse_line(const char *line, struct index_rec *rec)
 {
 	const char	*p, *q, *r;
 	char		*ep;
+	unsigned long	 parsed;
 
 	memset(rec, 0, sizeof(*rec));
 
-	/* strtoul(3) accepts leading whitespace and a sign, so an unguarded UID field could parse ":x:y:1" as 0 or "-1:x:y:1" as 4294967295; the field must be digits-or-nothing. */
+	/*
+	 * strtoul(3) accepts leading whitespace and a sign, so an unguarded UID
+	 * field could parse ":x:y:1" as 0 or "-1:x:y:1" as 4294967295; the
+	 * field must be 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);
 	}
+	/*
+	 * Narrowed only after the range test, the same four-part form the
+	 * UIDVALIDITY floor read uses: a value strtoul(3) accepts but a
+	 * uint32_t cannot hold was truncating, so "4294967296" became UID 0.
+	 */
 	errno = 0;
-	rec->uid = (uint32_t)strtoul(line, &ep, 10);
-	if (*ep != ':' || errno != 0) {
+	parsed = strtoul(line, &ep, 10);
+	if (*ep != ':' || errno != 0 || parsed > UINT32_MAX) {
 		log_warnx("session %u: corrupt index line: %s", session_id,
 		    line);
 		return (-1);
 	}
+	rec->uid = (uint32_t)parsed;
 	p = ep + 1;
 	if ((q = strchr(p, ':')) == NULL) {
 		log_warnx("session %u: corrupt index line: %s", session_id,
@@ -255,7 +315,12 @@ index_parse_line(const char *line, struct index_rec *r
 	}
 	memcpy(rec->basename, p, (size_t)(q - p));
 	rec->basename[q - p] = '\0';
-	/* Refuses traversal, hidden names, and control bytes before this basename is pasted into open(2)/stat(2)/rename(2) paths, since unveil(2) only stops paths leaving the maildir; logged by UID, never by the untrusted basename itself. */
+	/*
+	 * Refuses traversal, hidden names, and control bytes before this
+	 * basename is pasted into open(2)/stat(2)/rename(2) paths, since
+	 * unveil(2) only stops paths leaving the maildir; logged by UID, never
+	 * by the untrusted basename itself.
+	 */
 	if (!index_basename_valid(rec->basename)) {
 		log_warnx("session %u: refusing index line with unsafe "
 		    "basename (UID %u)", session_id, rec->uid);
@@ -273,7 +338,13 @@ index_parse_line(const char *line, struct index_rec *r
 		memcpy(rec->keywords, p, (size_t)(r - p));
 		rec->keywords[r - p] = '\0';
 
-		/* Same digit-or-nothing guard as the UID field, now applied to MODSEQ: RFC 7162 SS7 bounds it at 9,223,372,036,854,775,807, but strtoull(3)'s sign handling would otherwise turn "-1" into 18446744073709551615 and leak into CHANGEDSINCE/UNCHANGEDSINCE and client-visible MODSEQ. */
+		/*
+		 * Same digit-or-nothing guard as the UID field, now applied to
+		 * MODSEQ: RFC 7162 SS7 bounds it at 9,223,372,036,854,775,807,
+		 * but strtoull(3)'s sign handling would otherwise turn "-1"
+		 * into 18446744073709551615 and leak into
+		 * CHANGEDSINCE/UNCHANGEDSINCE and client-visible MODSEQ.
+		 */
 		if (r[1] < '0' || r[1] > '9') {
 			log_warnx("session %u: malformed per-message MODSEQ "
 			    "in index line: %s", session_id, line);
@@ -288,7 +359,10 @@ index_parse_line(const char *line, struct index_rec *r
 			return (-1);
 		}
 	} else {
-		/* no MODSEQ field, pre-CONDSTORE line (struct index_rec backward-compatibility) */
+		/*
+		 * no MODSEQ field: pre-CONDSTORE line (index_rec
+		 * backward-compat)
+		 */
 		if (strlcpy(rec->keywords, p, sizeof(rec->keywords)) >=
 		    sizeof(rec->keywords)) {
 			log_warnx("session %u: keywords too long in index "
@@ -301,26 +375,38 @@ index_parse_line(const char *line, struct index_rec *r
 	return (0);
 }
 
-/* Highest UID of a *present* message (idx->lines is UID-ascending), 0 if none; this is "*" for SEARCH/FETCH/STORE/EXPUNGE, not uidnext-1. */
+/*
+ * Highest UID of a *present* message (idx->lines is UID-ascending), 0 if none;
+ * this is "*" for SEARCH/FETCH/STORE/EXPUNGE, not uidnext-1.
+ */
 uint32_t
 index_max_uid(struct mbox_index *idx)
 {
-	const char	*line;
-	char		*ep;
-	uint32_t	 v;
+	struct index_rec	 rec;
 
 	if (idx->nlines == 0)
 		return (0);
 
-	line = idx->lines[idx->nlines - 1];
-	errno = 0;
-	v = (uint32_t)strtoul(line, &ep, 10);
-	if (*ep != ':')
-		return (0);	/* corrupt last line, treat as "no UIDs in use" rather than guessing */
-	return (v);
+	/*
+	 * One parser for the UID field, not two: the hand-rolled strtoul(3)
+	 * that used to live here had neither guard and read "-1:name::1" as
+	 * 4294967295. A line index_parse_line() rejects is skipped as a
+	 * message by every walker of idx->lines, so it has no present UID
+	 * for this to return; 0 means "no UIDs in use", as before.
+	 */
+	if (index_parse_line(idx->lines[idx->nlines - 1], &rec) == -1)
+		return (0);
+	return (rec.uid);
 }
 
-/* Resolves "*" entries in a parsed sequence-set against max (index_max_uid() for UID requests, idx->nlines for sequence-number requests); swaps any backwards "*"-involving range per RFC 9051 SS9 (since parse_one_seq_range() can't), then clamps lo up to 1 and, when clamp_hi is set, hi down to max, keeping every range including degenerate ones that seqset_contains() correctly treats as unmatchable. */
+/*
+ * Resolves "*" entries in a parsed sequence-set against max (index_max_uid()
+ * for UID requests, idx->nlines for sequence-number requests); swaps any
+ * backwards "*"-involving range per RFC 9051 SS9 (since parse_one_seq_range()
+ * can't), then clamps lo up to 1 and, when clamp_hi is set, hi down to max,
+ * keeping every range including degenerate ones that seqset_contains()
+ * correctly treats as unmatchable.
+ */
 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])
@@ -348,7 +434,7 @@ seqset_resolve(const struct seq_range *ranges, uint32_
 	return (n);
 }
 
-/* True if val falls in any of the nresolved [lo, hi] pairs from seqset_resolve() above. */
+/* True if val is in any nresolved [lo, hi] pair from seqset_resolve() above. */
 int
 seqset_contains(const struct seq_range *resolved, uint32_t nresolved,
     uint32_t val)
@@ -362,7 +448,11 @@ seqset_contains(const struct seq_range *resolved, uint
 	return (0);
 }
 
-/* Highest hi across all resolved ranges (0 if none), letting an ascending scan of idx->lines break early once past it, same as a single-range scan already did. */
+/*
+ * Highest hi across all resolved ranges (0 if none), letting an ascending scan
+ * of idx->lines break early once past it, same as a single-range scan already
+ * did.
+ */
 uint32_t
 seqset_max_hi(const struct seq_range *resolved, uint32_t nresolved)
 {
@@ -375,7 +465,16 @@ seqset_max_hi(const struct seq_range *resolved, uint32
 	return (max);
 }
 
-/* The "does this command apply to this message?" rule for FETCH/STORE/COPY, in one place: RFC 9051 SS6.4.9 makes a UID command's sequence-set UID-space and a bare one position-space, so the caller passes both and by_uid picks. PAST_END is a stop signal, valid only because those three walk idx->lines in ascending order -- the compaction loops in move_same_mailbox()/handle_mbox_expunge() deliberately don't use this, since breaking early would leave the surviving lines they still have to copy down unwritten. */
+/*
+ * The "does this command apply to this message?" rule for FETCH/STORE/COPY, in
+ * one place: RFC 9051 SS6.4.9 makes a UID command's sequence-set UID-space and
+ * a bare one position-space, so the caller passes both and by_uid picks.
+ * PAST_END is a stop signal, valid only because those three walk idx->lines in
+ * ascending order -- the compaction loops in
+ * move_same_mailbox()/handle_mbox_expunge() deliberately don't use this, since
+ * breaking early would leave the surviving lines they still have to copy down
+ * unwritten.
+ */
 enum seqset_pos
 seqset_position(const struct seq_range *resolved, uint32_t nresolved,
     uint32_t max_hi, int by_uid, uint32_t uid, uint32_t seqno)
@@ -389,7 +488,7 @@ seqset_position(const struct seq_range *resolved, uint
 	return (SEQSET_MATCH);
 }
 
-/* Reports every UID in [lo, hi] absent from idx as IMSG_MBOX_SELECT_VANISHED ranges; RFC 7162 SS3.2.6 VANISHED modifier. */
+/* Reports UID in [lo, hi] absent from idx as VANISHED; RFC 7162 SS3.2.6. */
 void
 send_vanished_range(const struct mbox_index *idx, uint32_t lo, uint32_t hi,
     struct imsgev *iev)
@@ -422,7 +521,11 @@ send_vanished_range(const struct mbox_index *idx, uint
 				    "IMSG_MBOX_SELECT_VANISHED", session_id);
 		}
 
-		/* Stop here: a UID of UINT32_MAX would wrap to 0 and make the tail check trivially true, emitting a VANISHED range that wrongly claims every message in the mailbox is gone. */
+		/*
+		 * Stop here: a UID of UINT32_MAX would wrap to 0 and make the
+		 * tail check trivially true, emitting a VANISHED range that
+		 * wrongly claims every message in the mailbox is gone.
+		 */
 		if (rec.uid == UINT32_MAX)
 			return;
 		want = rec.uid + 1;
@@ -441,7 +544,7 @@ send_vanished_range(const struct mbox_index *idx, uint
 	}
 }
 
-/* Linear scan for a basename already in the index; O(n) per lookup, fine for modest-mailbox-size scope. */
+/* Linear scan for a basename in the index; O(n), fine at modest size. */
 int
 index_has_basename(struct mbox_index *idx, const char *basename)
 {
@@ -465,21 +568,31 @@ index_has_basename(struct mbox_index *idx, const char 
 	return (0);
 }
 
-/* Appends one "UID:basename::MODSEQ" record; caller owns idx->uidnext. RFC 7162 SS3.1: each append gets its own bumped modseq. */
+/* Appends "UID:basename::MODSEQ"; caller owns uidnext, bumps modseq (SS3.1). */
 int
 index_append(struct mbox_index *idx, uint32_t uid, const char *basename)
 {
 	char	line[STORE_INDEX_LINE_MAX];
 	int	len;
 
-	/* RFC 9051 SS9 forbids UID 0; with no ceiling on uidnext, exhaustion would wrap it to 0 and silently reuse in-use UIDs (forbidden by SS2.3.1.1, and breaking index_max_uid()'s ascending assumption), so refuse here instead -- the RFC's real fix, changing UIDVALIDITY, needs persistent state not yet kept (see index.c review's finding #1). */
+	/*
+	 * RFC 9051 SS9 forbids UID 0; with no ceiling on uidnext, exhaustion
+	 * would wrap it to 0 and silently reuse in-use UIDs (forbidden by
+	 * SS2.3.1.1, and breaking index_max_uid()'s ascending assumption), so
+	 * refuse here instead -- the RFC's real fix, changing UIDVALIDITY,
+	 * needs persistent state not yet kept (see 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) */
+	/*
+	 * 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 "
 		    "basename: %s", session_id, basename);
@@ -506,22 +619,29 @@ index_append(struct mbox_index *idx, uint32_t uid, con
 	return (0);
 }
 
-/* Rewrites index to STORE_INDEX_TMP_NAME, rename(2)s over STORE_INDEX_NAME so a reader never sees a torn file. */
+/*
+ * Rewrites index to STORE_INDEX_TMP_NAME, rename(2)s over STORE_INDEX_NAME so a
+ * reader never sees a torn file.
+ */
 int
-index_save(const struct mbox_index *idx)
+index_save(int dfd, const struct mbox_index *idx)
 {
 	FILE	*fp;
 	int	 fd;
 	size_t	 i;
 
-	/* O_EXCL so a pre-planted symlink can't be followed; unlink any stale temp from a prior crash first */
-	if (unlink(STORE_INDEX_TMP_NAME) == -1 && errno != ENOENT) {
+	/*
+	 * O_EXCL so a pre-planted symlink can't be followed; unlink any stale
+	 * temp from a prior crash first
+	 */
+	if (unlinkat(dfd, STORE_INDEX_TMP_NAME, 0) == -1 &&
+	    errno != ENOENT) {
 		log_warn("session %u: unlink %s", session_id,
 		    STORE_INDEX_TMP_NAME);
 		return (-1);
 	}
-	if ((fd = open(STORE_INDEX_TMP_NAME, O_WRONLY | O_CREAT | O_EXCL,
-	    0600)) == -1) {
+	if ((fd = openat(dfd, STORE_INDEX_TMP_NAME,
+	    O_WRONLY | O_CREAT | O_EXCL, 0600)) == -1) {
 		log_warn("session %u: open %s", session_id,
 		    STORE_INDEX_TMP_NAME);
 		return (-1);
@@ -566,26 +686,17 @@ index_save(const struct mbox_index *idx)
 		return (-1);
 	}
 
-	if (rename(STORE_INDEX_TMP_NAME, STORE_INDEX_NAME) == -1) {
+	if (renameat(dfd, STORE_INDEX_TMP_NAME, dfd,
+	    STORE_INDEX_NAME) == -1) {
 		log_warn("session %u: rename %s -> %s", session_id,
 		    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);
-		}
-	}
+	if (fsync(dfd) == -1)
+		log_warn("session %u: fsync mailbox directory "
+		    "(continuing)", session_id);
 	return (0);
 }
 
@@ -601,7 +712,10 @@ index_free(struct mbox_index *idx)
 	memset(idx, 0, sizeof(*idx));
 }
 
-/* RFC 7162 SS3.2.5.1 QRESYNC resync: streams VANISHED ranges then FETCH_META for messages with modseq > qresync_modseq. */
+/*
+ * 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 seq_range *ranges, uint32_t nranges,
@@ -611,11 +725,20 @@ qresync_send_resync(const struct imsg_mbox_select *req
 	uint32_t		nresolved, max_hi, i;
 
 	if (req->qresync_has_uids) {
-		/* known-uids is a full RFC 9051 SS9 sequence-set resolved the same way as any UID-space consumer; max is unused since "*" is already rejected upstream, and clamp_hi is 0 because a known UID above the current highest is exactly what RFC 7162 SS3.2.5.1 wants reported VANISHED, not dropped. */
+		/*
+		 * known-uids is a full RFC 9051 SS9 sequence-set resolved the
+		 * same way as any UID-space consumer; max is unused since "*"
+		 * is already rejected upstream, and clamp_hi is 0 because a
+		 * known UID above the current highest is exactly what RFC 7162
+		 * SS3.2.5.1 wants reported VANISHED, not 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) */
+		/*
+		 * SS3.2.5.1: no known-uids means "1:<uidnext-1>", empty if
+		 * uidnext == 1
+		 */
 		if (idx->uidnext <= 1)
 			return;
 		resolved[0].lo = 1;
@@ -624,7 +747,12 @@ qresync_send_resync(const struct imsg_mbox_select *req
 		nresolved = 1;
 	}
 
-	/* RFC 7162 SS3.2.6 requires VANISHED (EARLIER) precede FETCH; ordering is guaranteed by store_ipc.c's session_handle_mbox_selected(), which buffers and flushes VANISHED before FETCH, the same two-pass split and helper handle_mbox_fetch() uses. */
+	/*
+	 * RFC 7162 SS3.2.6 requires VANISHED (EARLIER) precede FETCH; ordering
+	 * is guaranteed by store_ipc.c's session_handle_mbox_selected(), which
+	 * buffers and flushes VANISHED before FETCH, the same two-pass split
+	 * and helper handle_mbox_fetch() uses.
+	 */
 	for (i = 0; i < nresolved; i++)
 		send_vanished_range(idx, resolved[i].lo, resolved[i].hi, iev);
 
@@ -633,7 +761,10 @@ qresync_send_resync(const struct imsg_mbox_select *req
 		struct index_rec	rec;
 
 		if (index_parse_line(idx->lines[i], &rec) == -1)
-			continue;	/* corrupt line, already logged, not reported either way */
+			/*
+			 * corrupt line, already logged, not reported either way
+			 */
+			continue;
 		if (rec.uid > max_hi)
 			break;
 		if (!seqset_contains(resolved, nresolved, rec.uid))
@@ -648,8 +779,8 @@ qresync_send_resync(const struct imsg_mbox_select *req
 			meta.seqno = i + 1;
 			meta.uid = rec.uid;
 			meta.modseq = rec.modseq;
-			if (locate_message_file(rec.basename, &size, suffix,
-			    sizeof(suffix)) == 0) {
+			if (locate_message_file(mailbox_dir_fd, rec.basename,
+			    &size, suffix, sizeof(suffix)) == 0) {
 				build_flags_string(suffix, rec.keywords,
 				    meta.flags, sizeof(meta.flags));
 			}
@@ -662,27 +793,43 @@ qresync_send_resync(const struct imsg_mbox_select *req
 	}
 }
 
-/* Takes the index lock (LOCK_EX/LOCK_SH) on STORE_INDEX_LOCK_NAME's stable inode before opening the index, so the descriptor can't refer to an inode a concurrent index_save() already renamed away; release with the idempotent index_lock_release(). */
+/*
+ * Takes the index lock (LOCK_EX/LOCK_SH) on STORE_INDEX_LOCK_NAME's stable
+ * inode before opening the index, so the descriptor can't refer to an inode a
+ * concurrent index_save() already renamed away; release with the idempotent
+ * index_lock_release().
+ *
+ * Returns 0 holding the lock, or -1 having taken nothing. A caller that
+ * added LOCK_NB can also get 1, meaning another process holds it: an
+ * ordinary answer rather than a failure, so it is not logged, and every
+ * blocking caller is unaffected because flock(2) cannot report EWOULDBLOCK
+ * without LOCK_NB (flock(2), sys/kern/kern_descrip.c).
+ */
 int
-index_lock_acquire(struct index_lock *il, int op)
+index_lock_acquire(int dfd, struct index_lock *il, int op)
 {
+	int	busy;
+
 	il->lockfd = -1;
 	il->fd = -1;
 
-	if ((il->lockfd = open(STORE_INDEX_LOCK_NAME, O_RDWR | O_CREAT,
-	    0600)) == -1) {
+	if ((il->lockfd = openat(dfd, 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);
+		busy = (op & LOCK_NB) && errno == EWOULDBLOCK;
+		if (!busy)
+			log_warn("session %u: flock %s", session_id,
+			    STORE_INDEX_LOCK_NAME);
 		close(il->lockfd);
 		il->lockfd = -1;
-		return (-1);
+		return (busy ? 1 : -1);
 	}
-	if ((il->fd = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) == -1) {
+	if ((il->fd = openat(dfd, 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);
@@ -692,7 +839,10 @@ index_lock_acquire(struct index_lock *il, int op)
 	return (0);
 }
 
-/* Drops whatever index_lock_acquire() took; safe to call twice, and safe on an INDEX_LOCK_INIT struct that was never acquired. */
+/*
+ * 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)
 {
@@ -700,7 +850,7 @@ index_lock_release(struct index_lock *il)
 		close(il->fd);
 		il->fd = -1;
 	}
-	/* lock last, so no other process can take it while our index fd is open */
+	/* lock last: no other process can take it while our index fd is open */
 	if (il->lockfd != -1) {
 		flock(il->lockfd, LOCK_UN);
 		close(il->lockfd);
@@ -708,7 +858,15 @@ index_lock_release(struct index_lock *il)
 	}
 }
 
-/* Issues and records a new UIDVALIDITY: RFC 9051 SS2.3.1.1 requires it strictly increase, so the timestamp is only a floor -- the value returned is max(clock, last-issued+1), the high-water mark is persisted at the maildir root (STORE_UIDVALIDITY_NAME, since per-mailbox state is gone after DELETE), and every failure degrades to a bare timestamp except an unparseable-but-present file, which is left untouched rather than overwritten with a lower floor. */
+/*
+ * Issues and records a new UIDVALIDITY: RFC 9051 SS2.3.1.1 requires it strictly
+ * increase, so the timestamp is only a floor -- the value returned is
+ * max(clock, last-issued+1), the high-water mark is persisted at the maildir
+ * root (STORE_UIDVALIDITY_NAME, since per-mailbox state is gone after DELETE),
+ * and every failure degrades to a bare timestamp except an
+ * unparseable-but-present file, which is left untouched rather than overwritten
+ * with a lower floor.
+ */
 uint32_t
 uidvalidity_next(void)
 {
@@ -722,14 +880,14 @@ uidvalidity_next(void)
 	ssize_t		 n;
 	int		 fd, writeback = 1;
 
-	/* The namespace is flat -- select_mailbox_dir() reaches 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;
+	/* one file per account, at the maildir root */
+	path = 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) {
+	if ((fd = openat(maildir_root_fd, 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);
@@ -741,7 +899,11 @@ uidvalidity_next(void)
 		return (val != 0 ? val : 1);
 	}
 
-	/* A zero-length file is the ordinary just-created case (floor 0 is correct); anything present but unreadable is damage and is left alone. */
+	/*
+	 * A zero-length file is the ordinary just-created case (floor 0 is
+	 * correct); 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)",
@@ -750,7 +912,11 @@ uidvalidity_next(void)
 		} 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 */
+			/*
+			 * 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' ||
@@ -768,7 +934,10 @@ uidvalidity_next(void)
 	if (writeback) {
 		if (val <= floor) {
 			if (floor == UINT32_MAX) {
-				/* 4 billion issued values, or a clock past 2106: nothing greater is representable. */
+				/*
+				 * 4 billion issued, or clock past 2106: nothing
+				 * greater representable.
+				 */
 				log_warnx("session %u: UIDVALIDITY floor is "
 				    "exhausted (%u); reusing it", session_id,
 				    floor);
@@ -791,7 +960,11 @@ uidvalidity_next(void)
 			    "may be issued again", session_id, path);
 	}
 
-	/* A successful floor consultation is otherwise silent, and a quietly-wrong UIDVALIDITY looks identical to a correct one to the client; -v logs what was read and what was issued. */
+	/*
+	 * A successful floor consultation is otherwise silent, and a
+	 * quietly-wrong UIDVALIDITY looks identical to a correct one to the
+	 * client; -v logs 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)");
@@ -800,18 +973,33 @@ uidvalidity_next(void)
 	return (val);
 }
 
-/* Walks new/ for undiscovered maildir deliveries: mutate==0 only answers "is there at least one?" without touching idx or the filesystem (safe under a shared lock, used by the frequent IDLE poll); mutate==1 indexes everything found and needs the exclusive lock. Returns 1 (found/added), 0, or -1 on error -- on error with mutate set, idx is already index_free()'d, per refresh_index()'s contract. */
+/*
+ * Walks new/ for undiscovered maildir deliveries: mutate==0 only answers "is
+ * there at least one?" without touching idx or the filesystem (safe under a
+ * shared lock, used by the frequent IDLE poll); mutate==1 indexes everything
+ * found and needs the exclusive lock. Returns 1 (found/added), 0, or -1 on
+ * error -- on error with mutate set, idx is already index_free()'d, per
+ * refresh_index()'s contract.
+ */
 static int
-index_scan_new(struct mbox_index *idx, int mutate)
+index_scan_new(int dfd, struct mbox_index *idx, int mutate)
 {
 	DIR		*dp;
 	struct dirent	*de;
 	int		 added = 0;
 
-	dp = opendir("new");
+	{
+		int	newfd;
+
+		dp = NULL;
+		newfd = openat(dfd, "new", O_RDONLY | O_DIRECTORY);
+		if (newfd != -1 && (dp = fdopendir(newfd)) == NULL)
+			close(newfd);
+	}
 	if (dp == NULL) {
 		if (errno == ENOENT)
-			return (0);	/* no new/ yet on a never-used mailbox, not an error */
+			/* no new/ yet on a never-used mailbox, not an error */
+			return (0);
 		log_warn("session %u: opendir new", session_id);
 		if (mutate)
 			index_free(idx);
@@ -819,10 +1007,22 @@ index_scan_new(struct mbox_index *idx, int mutate)
 	}
 	while ((de = readdir(dp)) != NULL) {
 		if (de->d_name[0] == '.')
-			continue;	/* ".", "..", and dotfiles, maildir delivery never creates the latter */
-		/* never index a filename with ':' or newline, would corrupt the index line format */
+			/*
+			 * ".", "..", and dotfiles -- maildir delivery never
+			 * creates the latter
+			 */
+			continue;
+		/*
+		 * never index a filename with ':' or newline, corrupts index
+		 * line format
+		 */
 		if (strpbrk(de->d_name, ":\r\n") != NULL) {
-			/* Logged only on the mutating pass -- the read-only pass runs every poll interval for an IDLE's whole life, and a badly-named file would otherwise fill the log forever. */
+			/*
+			 * Logged only on the mutating pass -- the read-only
+			 * pass runs every poll interval for an IDLE's whole
+			 * life, and a badly-named file would otherwise fill the
+			 * log forever.
+			 */
 			if (mutate)
 				log_warnx("session %u: skipping new/ file "
 				    "with unsafe name (contains ':' or "
@@ -833,7 +1033,8 @@ index_scan_new(struct mbox_index *idx, int mutate)
 			continue;
 		if (!mutate) {
 			closedir(dp);
-			return (1);	/* one is enough to answer the question */
+			/* one is enough to answer the question */
+			return (1);
 		}
 		if (index_append(idx, idx->uidnext, de->d_name) == -1) {
 			closedir(dp);
@@ -847,30 +1048,43 @@ index_scan_new(struct mbox_index *idx, int mutate)
 	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. */
+/*
+ * 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)
+refresh_index(int dfd, struct mbox_index *idx, int fd)
 {
 	int	added;
 
 	if (index_load(fd, idx) == -1)
 		return (-1);
 
-	if ((added = index_scan_new(idx, 1)) == -1)
+	if ((added = index_scan_new(dfd, idx, 1)) == -1)
 		return (-1);	/* index_scan_new() has already freed idx */
 
-	/* Skip index_save()'s cost on a no-op refresh, unless the header itself is new -- a freshly invented UIDVALIDITY that's never written down would just be invented again, differently, next call. */
+	/*
+	 * Skip index_save()'s cost on a no-op refresh, unless the header itself
+	 * is new -- a freshly invented UIDVALIDITY that's never written down
+	 * would just be invented again, differently, next call.
+	 */
 	if (!added && !idx->fresh)
 		return (0);
 
-	if (index_save(idx) == -1) {
+	if (index_save(dfd, idx) == -1) {
 		index_free(idx);
 		return (-1);
 	}
 	return (0);
 }
 
-/* Cheap change probe: two stat(2) calls (no lock, no read) on "." (moved by every index_save()-based mutation: APPEND/STORE/EXPUNGE/COPY/MOVE) and "new" (touched by an external MTA delivery before anything indexes it); sampled before the caller's work so a change is never missed, at the cost of one harmless extra refresh, modulo theoretical same-nanosecond races. */
+/*
+ * Cheap change probe: two stat(2) calls (no lock, no read) on "." (moved by
+ * every index_save()-based mutation: APPEND/STORE/EXPUNGE/COPY/MOVE) and "new"
+ * (touched by an external MTA delivery before anything indexes it); sampled
+ * before the caller's work so a change is never missed, at the cost of one
+ * harmless extra refresh, modulo theoretical same-nanosecond races.
+ */
 static struct {
 	int		 valid;
 	ino_t		 dir_ino;
@@ -879,13 +1093,124 @@ static struct {
 	struct timespec	 new_mtim;
 } idle_probe;
 
-/* Forces the next probe to report a change; call whenever cwd changes mailbox. */
+/* Forces next probe to report a change; call whenever cwd changes mailbox. */
 void
 idle_probe_reset(void)
 {
 	idle_probe.valid = 0;
 }
 
+/*
+ * The UID list this session was last told about, ascending, and what an IDLE
+ * refresh diffs against. It lives here rather than in the listener so that a
+ * change to one message costs one imsg instead of one per message in the
+ * mailbox, and one walk instead of a rescan per message. About 4 bytes per
+ * message.
+ */
+static struct {
+	uint32_t	*uids;
+	size_t		 n;
+	uint64_t	 modseq;	/* highest reported; above it is news */
+	int		 valid;
+} idle_baseline;
+
+/* Forces the next refresh to seed rather than diff; pairs with the above. */
+void
+idle_baseline_reset(void)
+{
+	free(idle_baseline.uids);
+	idle_baseline.uids = NULL;
+	idle_baseline.n = 0;
+	idle_baseline.modseq = 0;
+	idle_baseline.valid = 0;
+}
+
+/*
+ * One untagged EXPUNGE per UID that went away, in order; returns how many.
+ *
+ * RFC 9051 SS7.5.1: each EXPUNGE decrements the sequence numbers above it, so
+ * seqno counts only messages still present. Both lists are UID-ascending (see
+ * index_max_uid()), so one walk does it.
+ */
+static size_t
+idle_send_expunges(const uint32_t *old, size_t oldn, const uint32_t *cur,
+    size_t curn, struct imsgev *iev)
+{
+	struct imsg_mbox_idle_expunge	 item;
+	size_t				 i, j = 0, gone = 0;
+	uint32_t			 seqno = 1;
+
+	for (i = 0; i < oldn; i++) {
+		while (j < curn && cur[j] < old[i])
+			j++;
+		if (j < curn && cur[j] == old[i]) {
+			j++;
+			seqno++;
+			continue;
+		}
+		memset(&item, 0, sizeof(item));
+		item.seqno = seqno;
+		gone++;
+		if (imsg_compose(&iev->ibuf, IMSG_MBOX_IDLE_EXPUNGE, 0, 0, -1,
+		    &item, sizeof(item)) == -1)
+			log_warn("session %u: imsg_compose "
+			    "IMSG_MBOX_IDLE_EXPUNGE", session_id);
+	}
+	return (gone);
+}
+
+/*
+ * One untagged FETCH per message whose mod-sequence passed what was last
+ * reported; returns how many. RFC 9051 SS6.3.13 lists flag changes among
+ * what IDLE reports and requires an unsolicited FETCH to carry a UID item.
+ *
+ * Only messages the client already knows about: one that arrived since the
+ * last refresh is not in old, and EXISTS is all it gets. Flags live in the
+ * message file's name, so each one reported costs a lookup, which is why
+ * this walks the changed messages and not the mailbox.
+ */
+static size_t
+idle_send_flag_fetches(const struct mbox_index *idx, const uint32_t *old,
+    size_t oldn, uint64_t since, struct imsgev *iev)
+{
+	struct imsg_mbox_fetch_meta	 meta;
+	struct index_rec		 rec;
+	char				 suffix[64];
+	off_t				 size;
+	size_t				 i, j = 0, seqno = 0, sent = 0;
+
+	for (i = 0; i < idx->nlines; i++) {
+		if (index_parse_line(idx->lines[i], &rec) == -1)
+			continue;	/* malformed, skipped as everywhere */
+		seqno++;		/* position after the EXPUNGEs above */
+		if (rec.modseq <= since)
+			continue;
+		while (j < oldn && old[j] < rec.uid)
+			j++;
+		if (j == oldn || old[j] != rec.uid)
+			continue;	/* arrived since; EXISTS covers it */
+		if (locate_message_file(mailbox_dir_fd, rec.basename, &size,
+		    suffix, sizeof(suffix)) == -1) {
+			log_warnx("session %u: message %s (uid %u) indexed "
+			    "but missing on disk, no IDLE flag push",
+			    session_id, rec.basename, rec.uid);
+			continue;
+		}
+		memset(&meta, 0, sizeof(meta));
+		meta.seqno = (uint32_t)seqno;
+		meta.uid = rec.uid;
+		meta.modseq = rec.modseq;
+		build_flags_string(suffix, rec.keywords, meta.flags,
+		    sizeof(meta.flags));
+		sent++;
+		if (imsg_compose(&iev->ibuf, IMSG_MBOX_IDLE_FETCH, 0, 0, -1,
+		    &meta, sizeof(meta)) == -1)
+			log_warn("session %u: imsg_compose "
+			    "IMSG_MBOX_IDLE_FETCH", session_id);
+	}
+	return (sent);
+}
+
 static int
 tspec_eq(const struct timespec *a, const struct timespec *b)
 {
@@ -899,13 +1224,17 @@ 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. */
+	if (fstatat(mailbox_dir_fd, ".", &dst, 0) == -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 */
+	if (fstatat(mailbox_dir_fd, "new", &nst, 0) == -1)
+		/* absent new/ is a stable state */
+		memset(&nst, 0, sizeof(nst));
 
 	same = idle_probe.valid &&
 	    dst.st_ino == idle_probe.dir_ino &&
@@ -922,81 +1251,246 @@ idle_probe_unchanged(void)
 	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). */
+/*
+ * The UIDs an IDLE baseline records, in index order, and the mod-sequence
+ * to diff from next time. Returns -1, having allocated nothing, on failure.
+ */
+static int
+idle_uid_list(const struct mbox_index *idx, uint32_t **listp, size_t *np,
+    uint64_t *modseqp)
+{
+	struct index_rec	 rec;
+	uint32_t		*list;
+	uint64_t		 modseq = 0;
+	size_t			 i, n = 0;
+
+	/*
+	 * nlines + 1 so that an empty mailbox still asks for a nonzero
+	 * allocation, which keeps a NULL return meaning failure and nothing
+	 * else.
+	 */
+	if ((list = reallocarray(NULL, idx->nlines + 1, sizeof(*list))) ==
+	    NULL) {
+		log_warn("session %u: idle refresh: reallocarray", session_id);
+		return (-1);
+	}
+	for (i = 0; i < idx->nlines; i++) {
+		if (index_parse_line(idx->lines[i], &rec) == -1)
+			/* skip malformed line, don't fail the whole request */
+			continue;
+		list[n++] = rec.uid;
+		if (rec.modseq > modseq)
+			modseq = rec.modseq;
+	}
+
+	/*
+	 * The header's HIGHESTMODSEQ is what a client is told, but a
+	 * per-message value above it would then never be reported again, so
+	 * take whichever is greater as the mark for next time. A
+	 * pre-CONDSTORE index line parses with modseq defaulted to 1, as
+	 * index_parse_line does, which a header of 0 would otherwise make
+	 * look like a change on every refresh.
+	 */
+	if (idx->highestmodseq > modseq)
+		modseq = idx->highestmodseq;
+
+	*listp = list;
+	*np = n;
+	*modseqp = modseq;
+	return (0);
+}
+
+/*
+ * Seeds the IDLE baseline from the committed index without its lock, for
+ * an IDLE that found the lock busy. index_save() only ever replaces the
+ * index whole, by rename(2), so this reads one committed version, never a
+ * torn one. Deliveries still in new/ are left for a later refresh to
+ * index and report. Returns 0 seeded, with the count in reply, or -1.
+ */
+static int
+idle_seed_unlocked(struct imsg_mbox_idle_refreshed *reply)
+{
+	struct mbox_index	 idx;
+	uint32_t		*list;
+	uint64_t		 modseq;
+	size_t			 n;
+	int			 fd;
+
+	if ((fd = openat(mailbox_dir_fd, STORE_INDEX_NAME, O_RDONLY)) == -1) {
+		if (errno != ENOENT)
+			log_warn("session %u: open %s", session_id,
+			    STORE_INDEX_NAME);
+		return (-1);
+	}
+	if (index_load(fd, &idx) == -1) {
+		close(fd);
+		return (-1);
+	}
+	close(fd);
+	if (idle_uid_list(&idx, &list, &n, &modseq) == -1) {
+		index_free(&idx);
+		return (-1);
+	}
+	index_free(&idx);
+
+	free(idle_baseline.uids);
+	idle_baseline.uids = list;
+	idle_baseline.n = n;
+	idle_baseline.modseq = modseq;
+	idle_baseline.valid = 1;
+	reply->exists = (uint32_t)n;
+	return (0);
+}
+
+/* RFC 9051 SS6.3.4-SS6.3.6/SS6.3.9; re-checked vs listener.c (privsep). */
 void
-handle_mbox_idle_refresh(struct imsgev *iev)
+handle_mbox_idle_refresh(const struct imsg_mbox_idle_refresh *req,
+    struct imsgev *iev)
 {
 	struct mbox_index		 idx;
 	struct imsg_mbox_idle_refreshed reply;
-	struct imsg_mbox_idle_uid	 item;
 	struct index_lock		 il = INDEX_LOCK_INIT;
-	size_t				 i;
-	int				 pending;
-	struct index_rec		 rec;
+	size_t				 newn = 0, gone = 0, changed = 0;
+	int				 pending = 0, seeded, locked;
+	uint32_t			*newlist;
+	uint64_t			 seen_modseq = 0;
 
 	memset(&reply, 0, sizeof(reply));
+	/* a seed adopts what it finds rather than reporting it */
+	seeded = req->seed || !idle_baseline.valid;
 
-	/* Cheapest question first: on an untouched mailbox this is the whole job, with no lock, no index read, and no per-message IMSG_MBOX_IDLE_UID stream, which matters since the poll runs every few seconds. */
+	/*
+	 * Cheapest question first: on an untouched mailbox this is the whole
+	 * job, with no lock and no index read, which matters since the poll
+	 * runs every few seconds.
+	 */
 	if (idle_probe_unchanged()) {
 		reply.ok = 1;
 		reply.unchanged = 1;
-		/* The poll mechanism is otherwise silent and a dead one is indistinguishable from a healthy one (as the cross-session push bug showed); at -v these lines make each poll and any real work observable. */
+		/*
+		 * The poll mechanism is otherwise silent and a dead one is
+		 * indistinguishable from a healthy one (as the cross-session
+		 * push bug showed); at -v these lines make each poll and any
+		 * real work observable.
+		 */
 		log_debug("session %u: idle refresh: unchanged (probe: no "
 		    "change to . or new/)", session_id);
 		goto send;
 	}
 
-	/* Something moved, so the index must be read; take the shared lock since reading alone covers the common case, and escalate only when new/ actually holds a delivery to index. */
-	if (index_lock_acquire(&il, LOCK_SH) == -1)
+	/*
+	 * Something moved, so the index must be read; take the shared lock
+	 * since reading alone covers the common case, and escalate only when
+	 * new/ actually holds a delivery to index.
+	 */
+	locked = index_lock_acquire(mailbox_dir_fd, &il, LOCK_SH | LOCK_NB);
+	if (locked == 1)
+		goto busy;
+	if (locked == -1)
 		goto send;
 	if (index_load(il.fd, &idx) == -1) {
 		index_lock_release(&il);
 		goto send;
 	}
-	if ((pending = index_scan_new(&idx, 0)) == -1) {
+	if ((pending = index_scan_new(mailbox_dir_fd, &idx, 0)) == -1) {
 		index_free(&idx);
 		index_lock_release(&il);
 		goto send;
 	}
-	/* idx.fresh joins pending here: index_load() just invented a UIDVALIDITY for a header-less mailbox, and persisting it needs the exclusive lock just as indexing a delivery does. */
+	/*
+	 * idx.fresh joins pending here: index_load() just invented a
+	 * UIDVALIDITY for a header-less mailbox, and persisting it needs the
+	 * exclusive lock just as indexing a delivery does.
+	 */
 	if (pending || idx.fresh) {
-		/* Deliberately drop the shared lock and redo everything under LOCK_EX rather than upgrading in place -- flock(2) has no atomic upgrade, so another process could slip in between states and invalidate what was read under the shared lock. */
+		/*
+		 * Deliberately drop the shared lock and redo everything under
+		 * LOCK_EX rather than upgrading in place -- flock(2) has no
+		 * atomic upgrade, so another process could slip in between
+		 * states and invalidate what was read under the shared lock.
+		 */
 		index_free(&idx);
 		index_lock_release(&il);
-		if (index_lock_acquire(&il, LOCK_EX) == -1)
+		locked = index_lock_acquire(mailbox_dir_fd, &il,
+		    LOCK_EX | LOCK_NB);
+		if (locked == 1)
+			goto busy;
+		if (locked == -1)
 			goto send;
-		if (refresh_index(&idx, il.fd) == -1) {
+		if (refresh_index(mailbox_dir_fd, &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)
-			continue;	/* skip malformed line, don't fail the whole request */
-		memset(&item, 0, sizeof(item));
-		item.uid = rec.uid;
-		if (imsg_compose(&iev->ibuf, IMSG_MBOX_IDLE_UID, 0, 0, -1,
-		    &item, sizeof(item)) == -1)
-			log_warn("session %u: imsg_compose "
-			    "IMSG_MBOX_IDLE_UID", session_id);
+	if (idle_uid_list(&idx, &newlist, &newn, &seen_modseq) == -1) {
+		/*
+		 * reply.ok stays 0, so the listener keeps what it last told
+		 * the client and this poll simply reports nothing; the
+		 * baseline here is untouched for the same reason.
+		 */
+		index_free(&idx);
+		index_lock_release(&il);
+		goto send;
 	}
 
+	if (!seeded) {
+		gone = idle_send_expunges(idle_baseline.uids, idle_baseline.n,
+		    newlist, newn, iev);
+		changed = idle_send_flag_fetches(&idx, idle_baseline.uids,
+		    idle_baseline.n, idle_baseline.modseq, iev);
+		reply.exists_changed = newn != idle_baseline.n;
+	}
+	free(idle_baseline.uids);
+	idle_baseline.uids = newlist;
+	idle_baseline.n = newn;
+	idle_baseline.modseq = seen_modseq;
+	idle_baseline.valid = 1;
+
 	reply.ok = 1;
-	reply.exists = (uint32_t)idx.nlines;
-	reply.uidvalidity = idx.uidvalidity;
-	reply.uidnext = idx.uidnext;
-	reply.highestmodseq = idx.highestmodseq;
+	reply.exists = (uint32_t)newn;
 
-	log_debug("session %u: idle refresh: streamed %zu uid(s), "
-	    "highestmodseq %llu%s", session_id, idx.nlines,
-	    (unsigned long long)idx.highestmodseq,
+	log_debug("session %u: idle refresh: %zu uid(s), highestmodseq %llu, "
+	    "%zu expunge(s), %zu flag change(s)%s%s", session_id, newn,
+	    (unsigned long long)idx.highestmodseq, gone, changed,
+	    seeded ? " (baseline seeded)" : "",
 	    pending ? " (escalated to LOCK_EX, indexed new delivery)" : "");
 
 	index_free(&idx);
 	index_lock_release(&il);
+	goto send;
 
+busy:
+	/*
+	 * Another session holds the lock. A poll skips, and send: below makes
+	 * the next one look again. A seed cannot skip: the baseline left from
+	 * the last IDLE predates the client's own commands since, and diffing
+	 * against it would resend EXPUNGEs the client has already had (RFC
+	 * 9051 SS7.5.1). So a seed reads the committed index instead, and if
+	 * it cannot, drops the baseline so that the next refresh seeds.
+	 */
+	reply.busy = 1;
+	if (seeded && idle_seed_unlocked(&reply) == 0) {
+		reply.ok = 1;
+		/* so the next poll reads the index, not the probe */
+		idle_probe_reset();
+	} else if (seeded) {
+		idle_baseline_reset();
+	}
+	log_debug("session %u: idle refresh: index lock busy, %s", session_id,
+	    !seeded ? "skipped this poll" : reply.ok ? "seeded without it" :
+	    "next refresh seeds");
+
 send:
+	/*
+	 * A refresh that gives up has already consumed the change:
+	 * idle_probe_unchanged() records the new mtimes whatever its caller
+	 * does next, so without this the next poll reports "unchanged" for
+	 * something the client was never told.
+	 */
+	if (!reply.ok)
+		idle_probe_reset();
+
 	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",
blob - c359eac84115ad33f85b403204722cfa81cc2edc
blob + e97c389fb7ab36d090a992f9435cd362e94a1bd5
--- src/keymgr.c
+++ src/keymgr.c
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  * Copyright (c) 2014 Reyk Floeter <reyk@openbsd.org>
@@ -11,8 +13,7 @@
  * 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/
+ * imsg/privsep conventions rather than copied verbatim. 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.
@@ -41,7 +42,12 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* keymgr.c: holds the real TLS private key for listener.c's fake-key/imsg forwarding (docs/openimap-tls-privsep-design.md SS5); boot-time plumbing failures are fatal, but content failures (bad cert/key) and per-request failures degrade gracefully, keeping the process alive with no usable key rather than crashing. */
+/*
+ * keymgr.c: holds the real TLS private key for listener.c's fake-key/imsg
+ * forwarding; boot-time plumbing failures are fatal, but content failures
+ * (bad cert/key) and per-request failures degrade gracefully, keeping the
+ * process alive with no usable key rather than crashing.
+ */
 
 #include <sys/types.h>
 #include <sys/queue.h>
@@ -66,11 +72,19 @@
 #include "imapd.h"
 #include "log.h"
 
-/* Matches parent.c's read buffer size (8192), kept as a separate local constant rather than a shared imapd.h macro since nothing else needs to agree on the exact value. */
+/*
+ * Matches parent.c's read buffer size (8192), kept as a separate local constant
+ * rather than a shared imapd.h macro since nothing else needs to agree on the
+ * exact value.
+ */
 #define KEYMGR_CERT_MAX	8192
 #define KEYMGR_KEY_MAX	8192
 
-/* SS7: keymgr now serves every live connection's listener-worker via its own peer entry, wired in by IMSG_SETUP_PEER and torn down on channel close; named so keymgr_dispatch_parent() doesn't need a forward declaration. */
+/*
+ * keymgr serves every live connection's listener-worker via its own peer
+ * entry, wired in by IMSG_SETUP_PEER and torn down on channel close; named
+ * so keymgr_dispatch_parent() doesn't need a forward declaration.
+ */
 struct keymgr_peer {
 	uint32_t		 session_id;
 	struct imsgev		 iev;
@@ -80,20 +94,37 @@ 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 */
+/* fd 3, alive for the process's lifetime */
+static struct imsgev	 iev_parent;
 
-/* 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];
+/*
+ * 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];
+static char	 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. */
+/*
+ * 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. */
+/*
+ * The 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 void	 keymgr_key_free(void);
 static int	 keymgr_load(const char *, size_t, const char *, size_t);
 static void	 keymgr_try_reload(void);
 static int	 keymgr_pubkey_hash(X509 *, char *, size_t);
@@ -120,10 +151,19 @@ keymgr_main(void)
 	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 */
+	/*
+	 * 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);
 
-	/* Both IMSG_TLS_CERT and IMSG_KEYMGR_INIT must be read before the peer handshake so keymgr_got_init/keymgr_pkey are final before any signing request can arrive; sent as two imsgs (mirroring parent.c's send_tls_cert()/send_keymgr_key() split) rather than one, to stay comfortably under MAX_IMSGSIZE. */
+	/*
+	 * Both IMSG_TLS_CERT and IMSG_KEYMGR_INIT must be read before the peer
+	 * handshake so keymgr_got_init/keymgr_pkey are final before any signing
+	 * request can arrive; sent as two imsgs (mirroring parent.c's
+	 * send_tls_cert()/send_keymgr_key() split) rather than one, to stay
+	 * comfortably under MAX_IMSGSIZE.
+	 */
 	while (!got_cert || !got_key) {
 		if ((n = imsgbuf_get(&ibuf3, &imsg)) == -1)
 			fatal("imsgbuf_get");
@@ -183,12 +223,19 @@ keymgr_main(void)
 	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. */
+	/*
+	 * keymgr's own daemon-user identity: a dedicated 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. */
+	/*
+	 * 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)
@@ -199,7 +246,14 @@ keymgr_main(void)
 	    setresuid(pw->pw_uid, pw->pw_uid, pw->pw_uid) == -1)
 		fatal("cannot drop privileges to _imapkey");
 
-	/* SS7: keymgr stays the one boot-time, daemon-lifetime child, but no peer is wired to it at boot -- parent.c sends only IMSG_SETUP_DONE; every listener-worker peer arrives later over this same channel via IMSG_SETUP_PEER, handled below. */
+	setproctitle("keymgr");
+
+	/*
+	 * keymgr stays the one boot-time, daemon-lifetime child, but no peer is
+	 * wired to it at boot -- parent.c sends only IMSG_SETUP_DONE; every
+	 * listener-worker peer arrives later over this same channel via
+	 * IMSG_SETUP_PEER, handled below.
+	 */
 	setup_recv_done_and_ack(&ibuf3);
 
 	event_init();
@@ -208,7 +262,12 @@ keymgr_main(void)
 	    NULL);
 
 #ifdef __OpenBSD__
-	/* recvfd only: keymgr keeps receiving peer fds via IMSG_SETUP_PEER for its whole life but never sends one (only parent attaches descriptors to imsgs, and keymgr_reply() composes with fd == -1); no rpath either since keymgr touches no filesystem (SS5.5). */
+	/*
+	 * recvfd only: keymgr keeps receiving peer fds via IMSG_SETUP_PEER for
+	 * its whole life but never sends one (only parent attaches descriptors
+	 * to imsgs, and keymgr_reply() composes with fd == -1); no rpath either
+	 * since keymgr touches no filesystem.
+	 */
 	if (pledge("stdio recvfd", NULL) == -1)
 		fatal("pledge");
 #endif
@@ -217,12 +276,21 @@ keymgr_main(void)
 	fatalx("exited event loop");
 }
 
-/* Replicates smtpd's ssl.c hash_x509() byte-for-byte: SHA256 of the cert's DER SubjectPublicKeyInfo, formatted "SHA256:" plus lowercase hex -- the exact format is load-bearing (see file header), not cosmetic. */
+/*
+ * Replicates smtpd's ssl.c hash_x509() byte-for-byte: SHA256 of the cert's DER
+ * SubjectPublicKeyInfo, formatted "SHA256:" plus lowercase hex -- the exact
+ * format is load-bearing (see file header), not cosmetic.
+ */
 static int
 keymgr_pubkey_hash(X509 *cert, char *hash, size_t hashlen)
 {
 	static const char	hex[] = "0123456789abcdef";
-	/* Uses unsigned char/unsigned int, not smtpd hash_x509()'s signed types, to match X509_pubkey_digest(3)'s prototype and avoid -Wpointer-sign warnings; the emitted string is unchanged since digest[i] is already unsigned. */
+	/*
+	 * Uses unsigned char/unsigned int, not smtpd hash_x509()'s signed
+	 * types, to match X509_pubkey_digest(3)'s prototype and avoid
+	 * -Wpointer-sign warnings; the emitted string is unchanged since
+	 * digest[i] is already unsigned.
+	 */
 	unsigned char		digest[EVP_MAX_MD_SIZE];
 	size_t			off;
 	unsigned int		dlen, i;
@@ -241,7 +309,23 @@ keymgr_pubkey_hash(X509 *cert, char *hash, size_t hash
 	return (0);
 }
 
-/* Parses a new cert+key pair fully before replacing the live one, so a malformed SIGHUP reload leaves the last known-good key in place instead of none; not sourced from smtpd's ca.c reload logic. cert_buf/key_buf aren't retained past this call -- callers must scrub key_buf themselves. */
+/* Frees the loaded key; EVP_PKEY_free wipes it via BN_free */
+/* unnecessary -- freed pages are zeroed -- but right on every exit path */
+static void
+keymgr_key_free(void)
+{
+	if (keymgr_pkey != NULL) {
+		EVP_PKEY_free(keymgr_pkey);
+		keymgr_pkey = NULL;
+	}
+}
+
+/*
+ * Parses a new cert+key pair fully before replacing the live one, so a
+ * malformed SIGHUP reload leaves the last known-good key in place instead of
+ * none; not sourced from smtpd's ca.c reload logic. cert_buf/key_buf aren't
+ * retained past this call -- callers must scrub key_buf themselves.
+ */
 static int
 keymgr_load(const char *cert_buf, size_t cert_len, const char *key_buf,
     size_t key_len)
@@ -279,7 +363,12 @@ keymgr_load(const char *cert_buf, size_t cert_len, con
 		goto fail;
 	}
 
-	/* A cert and key can each parse fine yet not correspond to each other (e.g. a rotation that replaced only one) -- checked here via X509_check_private_key() before swapping, since the per-request hash check elsewhere can't catch a cert/key mismatch. */
+	/*
+	 * A cert and key can each parse fine yet not correspond to each other
+	 * (e.g. a rotation that replaced only one) -- checked here via
+	 * X509_check_private_key() before swapping, since the per-request hash
+	 * check elsewhere can't catch a cert/key mismatch.
+	 */
 	if (X509_check_private_key(cert, pkey) != 1) {
 		log_warnx("certificate and private key do not match, not "
 		    "(re)loading");
@@ -287,11 +376,14 @@ keymgr_load(const char *cert_buf, size_t cert_len, con
 	}
 
 	/* Both parsed; only now touch the live state. */
-	if (keymgr_pkey != NULL)
-		EVP_PKEY_free(keymgr_pkey);
+	keymgr_key_free();
 	keymgr_pkey = pkey;
 	pkey = NULL;
-	/* strlcpy, not memcpy of sizeof(): keymgr_pubkey_hash() only writes 72 of KEYMGR_HASH_MAX's 80 bytes, and memcpy would drag uninitialised stack bytes into a static. */
+	/*
+	 * strlcpy, not memcpy of sizeof(): keymgr_pubkey_hash() only writes 72
+	 * of KEYMGR_HASH_MAX's 80 bytes, and memcpy would drag uninitialised
+	 * stack bytes into a static.
+	 */
 	(void)strlcpy(keymgr_hash, hash, sizeof(keymgr_hash));
 
 	log_info("TLS key loaded (%s)", keymgr_hash);
@@ -313,7 +405,13 @@ fail:
 	return (-1);
 }
 
-/* Commits a SIGHUP reload once BOTH halves have arrived: IMSG_TLS_CERT and IMSG_KEYMGR_INIT can land in either order, so both cases call this and only the second one finds the pair complete. The key buffer is scrubbed whether or not the load succeeded, and both flags clear so the next reload starts from a clean pair rather than half of this one. */
+/*
+ * Commits a SIGHUP reload once BOTH halves have arrived: IMSG_TLS_CERT and
+ * IMSG_KEYMGR_INIT can land in either order, so both cases call this and only
+ * the second one finds the pair complete. The key buffer is scrubbed whether or
+ * not the load succeeded, and both flags clear so the next reload starts from a
+ * clean pair rather than half of this one.
+ */
 static void
 keymgr_try_reload(void)
 {
@@ -327,7 +425,14 @@ keymgr_try_reload(void)
 	reload_got_cert = reload_got_key = 0;
 }
 
-/* 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). */
+/*
+ * 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
+ * (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)
 {
@@ -343,8 +448,17 @@ keymgr_dispatch_parent(int fd, short event, void *arg)
 		if ((n = imsgbuf_read(&iev->ibuf)) == -1)
 			fatal("imsgbuf_read");
 		if (n == 0) {
-			/* Parent gone means this process is done: it used to linger serving existing peers, but keymgr is only ever consulted during a TLS handshake, so that only kept a key-holding process alive for as long as any client held a connection open; log_warnx (an operator should notice) and exit(0) (not a failure, just following the parent's death) rather than fatalx(). */
+			/*
+			 * Parent gone means this process is done: it used to
+			 * linger serving existing peers, but keymgr is only
+			 * ever consulted during a TLS handshake, so that only
+			 * kept a key-holding process alive for as long as any
+			 * client held a connection open; log_warnx (an operator
+			 * should notice) and exit(0) (not a failure, just
+			 * following the parent's death) rather than fatalx().
+			 */
 			log_warnx("parent closed channel, exiting");
+			keymgr_key_free();
 			exit(0);
 		}
 	}
@@ -356,6 +470,12 @@ keymgr_dispatch_parent(int fd, short event, void *arg)
 			break;
 
 		switch (imsg_get_type(&imsg)) {
+		case IMSG_KEYMGR_SHUTDOWN:
+			/* asked to go, unlike the EOF above; not a crash */
+			log_debug("asked to shut down, exiting");
+			imsg_free(&imsg);
+			keymgr_key_free();
+			exit(0);
 		case IMSG_SETUP_PEER: {
 			uint32_t		 sess_id = imsg_get_id(&imsg);
 			int			 peer_fd = imsg_get_fd(&imsg);
@@ -426,7 +546,11 @@ keymgr_dispatch_parent(int fd, short event, void *arg)
 	(void)fd;
 }
 
-/* LISTENER channel: handles the three signing/decrypt request types (SS5.2/SS6.1); arg is the owning struct keymgr_peer (not a bare imsgev) so the EOF path knows which of possibly many live peers just went away. */
+/*
+ * LISTENER channel: handles the three signing/decrypt request types;
+ * arg is the owning struct keymgr_peer (not a bare imsgev) so the EOF
+ * path knows which of possibly many live peers just went away.
+ */
 static void
 keymgr_dispatch_listener(int fd, short event, void *arg)
 {
@@ -435,7 +559,14 @@ keymgr_dispatch_listener(int fd, short event, void *ar
 	struct imsg		 imsg;
 	ssize_t			 n;
 
-	/* A transport failure on one listener-worker's channel drops only that peer, not the whole process -- fatal()ing here would turn one connection's worker dying into a daemon-wide TLS outage, since keymgr is never restarted by parent.c's reap_child(); keymgr_dispatch_parent() (the fd-3 channel) stays strict since losing the parent leaves keymgr with no future. */
+	/*
+	 * A transport failure on one listener-worker's channel drops only that
+	 * peer, not the whole process -- fatal()ing here would turn one
+	 * connection's worker dying into a daemon-wide TLS outage, since keymgr
+	 * is never restarted by parent.c's reap_child();
+	 * keymgr_dispatch_parent() (the fd-3 channel) stays strict since losing
+	 * the parent leaves keymgr with no future.
+	 */
 	if (event & EV_WRITE) {
 		if (imsgbuf_write(&iev->ibuf) == -1) {
 			log_warnx("session %u: write error on listener "
@@ -485,7 +616,12 @@ keymgr_dispatch_listener(int fd, short event, void *ar
 	(void)fd;
 }
 
-/* Drops one listener-worker peer: unregisters its event, closes and clears its channel, unlinks and frees it -- shared by every exit path in keymgr_dispatch_listener() so EOF and transport error give the same outcome; imsgbuf_clear() is required or imsgbuf_init()'s allocation leaks. */
+/*
+ * Drops one listener-worker peer: unregisters its event, closes and clears its
+ * channel, unlinks and frees it -- shared by every exit path in
+ * keymgr_dispatch_listener() so EOF and transport error give the same outcome;
+ * imsgbuf_clear() is required or imsgbuf_init()'s allocation leaks.
+ */
 static void
 keymgr_peer_teardown(struct keymgr_peer *kp)
 {
@@ -496,7 +632,13 @@ keymgr_peer_teardown(struct keymgr_peer *kp)
 	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. */
+/*
+ * 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)
@@ -518,15 +660,27 @@ keymgr_recv_trailing(struct imsg *imsg, uint32_t len, 
 	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. */
+/*
+ * 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];
+	unsigned char			 combined[sizeof(rep) +
+	    KEYMGR_DATA_MAX];
 
-	/* Every caller already bounds its own result, but combined[] is a fixed stack buffer holding the private key's output, so this function bound-checks independently rather than relying on that discipline holding forever; an oversized result is reported as a failed operation. */
+	/*
+	 * Every caller already bounds its own result, but combined[] is a fixed
+	 * stack buffer holding the private key's output, so this function
+	 * bound-checks independently rather than relying on that discipline
+	 * holding forever; an oversized result is reported as a failed
+	 * operation.
+	 */
 	if (ok && tolen > KEYMGR_DATA_MAX) {
 		log_warnx("keymgr_reply: %zu-byte result exceeds "
 		    "KEYMGR_DATA_MAX (%d), refusing", tolen,
@@ -546,11 +700,19 @@ keymgr_reply(struct imsgev *iev, uint32_t type, uint32
 	    sizeof(rep) + (ok ? tolen : 0)) == -1)
 		log_warn("imsg_compose reply");
 
-	/* imsg_compose() has copied the reply; this buffer may hold a decrypted premaster secret (on RSA_PRIVDEC), so it's scrubbed here like every other key-material buffer in this file. */
+	/*
+	 * imsg_compose() has copied the reply; this buffer may hold a decrypted
+	 * premaster secret (on RSA_PRIVDEC), so it's scrubbed here like every
+	 * other key-material buffer in this file.
+	 */
 	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. */
+/*
+ * 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 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)
@@ -568,7 +730,10 @@ keymgr_handle_rsa(struct imsgev *iev, struct imsg *ims
 		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). */
+	/*
+	 * 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))) {
@@ -578,7 +743,7 @@ keymgr_handle_rsa(struct imsgev *iev, struct imsg *ims
 
 	if (!keymgr_got_init) {
 		log_warnx("%s request before IMSG_KEYMGR_INIT "
-		    "completed, refusing (SS6.1)", opname);
+		    "completed, refusing", opname);
 		keymgr_reply(iev, type, id, 0, NULL, 0);
 		return;
 	}
@@ -617,11 +782,14 @@ keymgr_handle_rsa(struct imsgev *iev, struct imsg *ims
 		return;
 	}
 	keymgr_reply(iev, type, id, 1, to, (size_t)ret);
-	/* RSA_PRIVDEC's output is the session's decrypted premaster secret; don't leave it on this process's stack. */
+	/*
+	 * RSA_PRIVDEC's output is the session's decrypted premaster secret;
+	 * don't 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). */
+/* IMSG_KEYMGR_ECDSA_SIGN: mirrors ca_imsg()'s ECDSA_sign() (ca.c:255-279). */
 static void
 keymgr_handle_ecdsa(struct imsgev *iev, struct imsg *imsg, uint32_t id)
 {
@@ -646,7 +814,7 @@ keymgr_handle_ecdsa(struct imsgev *iev, struct imsg *i
 
 	if (!keymgr_got_init) {
 		log_warnx("ECDSA_SIGN request before "
-		    "IMSG_KEYMGR_INIT completed, refusing (SS6.1)");
+		    "IMSG_KEYMGR_INIT completed, refusing");
 		keymgr_reply(iev, IMSG_KEYMGR_ECDSA_SIGN, id, 0, NULL, 0);
 		return;
 	}
blob - 6d37b8daf020763e4daddf91c126a00a30bea7d4
blob + 1b39f7831b03ae04c5fdd26c4b5eeee105463eb1
--- src/listener.c
+++ src/listener.c
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  * Copyright (c) 2014 Reyk Floeter <reyk@openbsd.org>
@@ -8,11 +10,10 @@
  * (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
+ * independent structure, and this project's own licensing
  * 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
+ * implementation differs. 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.
@@ -30,7 +31,11 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* 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. */
+/*
+ * 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.
+ */
 
 #include <sys/types.h>
 #include <sys/queue.h>
@@ -69,16 +74,35 @@
 
 struct session_list	 sessions = TAILQ_HEAD_INITIALIZER(sessions);
 
-struct imsgev	 iev_auth;	/* Channel to the AUTH process; .ibuf.fd == -1 if this connection's auth-worker spawn failed, checked by auth_cmd.c's sasl_plain_finish() before sending IMSG_AUTH_REQUEST. */
-struct imsgev	 iev_search;	/* Channel to the search-oracle process (SS8.1); .ibuf.fd == -1 if spawn failed, checked by search_cmd.c's search_dispatch() before sending IMSG_SEARCH_PARSE_REQUEST. */
+/*
+ * Channel to the AUTH process; .ibuf.fd == -1 if this connection's
+ * auth-worker spawn failed, checked by auth_cmd.c's
+ * sasl_plain_finish() before sending IMSG_AUTH_REQUEST.
+ */
+struct imsgev	 iev_auth;
+/*
+ * Channel to the search-oracle process; .ibuf.fd == -1 if
+ * spawn failed, checked by search_cmd.c's search_dispatch() before
+ * sending IMSG_SEARCH_PARSE_REQUEST.
+ */
+struct imsgev	 iev_search;
 struct imsgev	 iev_parent;	/* fd 3, alive for the process's lifetime */
 
-/* Channel to the keymgr process: private-key ops are forwarded here synchronously from OpenSSL callbacks, never via imsgev dispatch, so it's a plain struct imsgbuf; see keymgr_forward_rsa()/keymgr_forward_ecdsa(). */
+/*
+ * Channel to the keymgr process: private-key ops are forwarded here
+ * synchronously from OpenSSL callbacks, never via imsgev dispatch, so
+ * it's a plain struct imsgbuf; 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 */
+/* NULL if TLS setup failed, no-TLS fallback */
+struct tls		*listener_tls_ctx;
+/* set from imsg */
+uint32_t		 listener_idle_poll_secs = IDLE_POLL_DEFAULT;
+uint32_t		 listener_login_grace_secs = LOGIN_GRACE_DEFAULT;
+uint64_t		 listener_append_max = APPEND_MAX_DEFAULT;
 
 /* Matches parent.c's send_tls_cert() read buffer size. */
 #define TLS_CERT_MAX	8192
@@ -102,7 +126,7 @@ struct imap_cmd_entry {
 	 (1U << SESSION_LISTING))
 #define ST_NOTAUTH	(1U << SESSION_NOT_AUTH)
 
-/* RFC 9051 SS9; transient in-flight states excluded (own pending_tag until their reply arrives). */
+/* RFC 9051 SS9; excludes transient states, which have their own pending_tag. */
 #define ST_AUTH \
 	((1U << SESSION_AUTHENTICATED) | (1U << SESSION_SELECTED))
 #define ST_SELECTED	(1U << SESSION_SELECTED)
@@ -145,10 +169,19 @@ 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 an internal, undeclared libtls symbol forward-declared here, same as smtpd's smtp.c does; see docs/openimap-tls-privsep-design.md SS9 on the risk of depending on it. */
+/*
+ * tls_config_use_fake_private_key() is an internal, undeclared libtls
+ * symbol forward-declared here, same as smtpd's smtp.c does. Being
+ * an internal symbol, it can change or vanish without notice.
+ */
 void	tls_config_use_fake_private_key(struct tls_config *);
 
-/* Installs libtls's placeholder private key plus the real certificate (smtp.c:187-193's call shape) in one function so listener_main()'s tls_config-building if/else-if chains need only one call per branch. */
+/*
+ * Installs libtls's placeholder private key plus the real certificate
+ * (smtp.c:187-193's call shape) in one function so listener_main()'s
+ * tls_config-building if/else-if chains need only one call per
+ * branch.
+ */
 static int
 keymgr_set_fake_keypair(struct tls_config *config, const char *cert_buf,
     size_t cert_len)
@@ -158,14 +191,24 @@ keymgr_set_fake_keypair(struct tls_config *config, con
 	    cert_len, NULL, 0);
 }
 
-/* RSA/ECDSA privsep engine, installed once process-wide: intercepts every private-key operation OpenSSL performs against this process's fake key and forwards it to keymgr; adapted from smtpd's ca.c (ca.c:289-558) with imapd's own imsg framing. */
+/*
+ * RSA/ECDSA privsep engine, installed once process-wide: intercepts
+ * every private-key operation OpenSSL performs against this
+ * process's fake key and forwards it to keymgr; adapted from smtpd's
+ * ca.c (ca.c:289-558) with imapd's own imsg framing.
+ */
 
 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 from inside an OpenSSL RSA_METHOD callback; unlike ca.c's rsae_send_imsg(), nothing else is ever multiplexed on this channel so there's no need to hand off unrelated imsgs. */
+/*
+ * Blocks reading keymgr_ibuf directly from inside an OpenSSL
+ * RSA_METHOD callback; unlike ca.c's rsae_send_imsg(), nothing else
+ * is ever multiplexed on this channel so there's no need to hand off
+ * unrelated imsgs.
+ */
 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)
@@ -232,7 +275,12 @@ keymgr_forward_rsa(uint32_t type, const char *hash, co
 			imsg_free(&imsg);
 			break;
 		}
-		/* Bound by tosize (OpenSSL's actual output buffer, e.g. 256 bytes for a 2048-bit key), not by the larger KEYMGR_DATA_MAX wire cap, or an oversized reply could overrun it; mirrors ca.c's own RSA_size() bound. */
+		/*
+		 * Bound by tosize (OpenSSL's actual output buffer, e.g. 256
+		 * bytes for a 2048-bit key), not by the larger
+		 * KEYMGR_DATA_MAX wire cap, or an oversized reply could
+		 * overrun it; mirrors ca.c's own RSA_size() bound.
+		 */
 		if (rep.ok && rep.tolen <= tosize &&
 		    imsg_get_len(&imsg) == rep.tolen) {
 			if (imsg_get_buf(&imsg, to, rep.tolen) == -1)
@@ -332,24 +380,45 @@ keymgr_forward_ecdsa(const char *hash, const unsigned 
 	return (sig);
 }
 
-/* Checks A, B and C1 (docs/OpenIMAPD-TLS-Review/Opus-5-DESIGN-B3-implementation.md) before any key op is forwarded to keymgr; fatalx(), not a log line, since a failure here means privilege separation isn't actually in effect. */
+/*
+ * Runs checks A, B and C1, each described at its own test below,
+ * before any key op is forwarded to keymgr; fatalx(), not a log
+ * line, since a failure here means privilege separation isn't
+ * actually in effect.
+ */
 static void
 keymgr_assert_fake_key(const char *hash, const BIGNUM *priv, const char *op)
 {
 	size_t	 i;
 
-	/* Check A: a public-key-only object from tls_config_use_fake_private_key() never has d/priv_key set, so a non-NULL priv here means libtls is no longer using the placeholder key. */
+	/*
+	 * Check A: a public-key-only object from
+	 * tls_config_use_fake_private_key() never has d/priv_key set,
+	 * so a non-NULL priv here means libtls is no longer using the
+	 * placeholder key.
+	 */
 	if (priv != NULL)
 		fatalx("%s: key object carries a private component -- "
 		    "libtls is no longer using a placeholder key, and this "
 		    "process is not separated from the TLS private key", op);
 
-	/* Check B: unlike smtpd's ca.c, listener configures exactly one keypair and never reaches this callback for an unrelated key, so a missing pubkey-hash tag is itself the regression, not a benign case to fall through on. */
+	/*
+	 * Check B: unlike smtpd's ca.c, listener configures exactly one
+	 * keypair and never reaches this callback for an unrelated
+	 * key, so a missing pubkey-hash tag is itself the regression,
+	 * not a benign case to fall through on.
+	 */
 	if (hash == NULL)
 		fatalx("%s: no pubkey-hash tag on the key object -- libtls's "
 		    "ex_data slot 0 tagging has changed", op);
 
-	/* Check C1, done before the tag is read as a string: strlcpy(3) has no bound once the destination is full, so this bounded loop (not memchr/strnlen) confirms the tag is NUL-terminated within KEYMGR_HASH_MAX bytes before anything trusts it. */
+	/*
+	 * Check C1, done before the tag is read as a string:
+	 * strlcpy(3) has no bound once the destination is full, so
+	 * this bounded loop (not memchr/strnlen) confirms the tag is
+	 * NUL-terminated within KEYMGR_HASH_MAX bytes before anything
+	 * trusts it.
+	 */
 	for (i = 0; i < KEYMGR_HASH_MAX; i++)
 		if (hash[i] == '\0')
 			return;
@@ -385,7 +454,10 @@ keymgr_ecdsa_do_sign(const unsigned char *dgst, int dg
 {
 	const char	*hash = EC_KEY_get_ex_data(eckey, 0);
 
-	/* inv/rp are ECDSA_sign_setup() precomputation, unused since keymgr performs the operation, not this process. */
+	/*
+	 * inv/rp: ECDSA_sign_setup() precomputation, unused; keymgr does the
+	 * op.
+	 */
 	(void)inv;
 	(void)rp;
 
@@ -436,7 +508,12 @@ keymgr_ecdsa_engine_init(void)
 	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). */
+/*
+ * 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)
 {
@@ -444,7 +521,7 @@ keymgr_engine_init(void)
 	keymgr_ecdsa_engine_init();
 }
 
-/* Builds and starts this process's one and only session; defined below, forward-declared here since listener_main() calls it. */
+/* Builds/starts this one session; forward-declared for listener_main(). */
 static void	 listener_start_session(uint32_t, int, int,
 		    const struct sockaddr_storage *, socklen_t);
 
@@ -453,22 +530,33 @@ listener_main(void)
 {
 	struct imsgbuf				 ibuf3;
 	struct passwd				*pw;
-	int					 auth_peer_fd = -1, keymgr_peer_fd = -1;
-	int					 search_peer_fd = -1;	/* SS8.1 */
+	int					 auth_peer_fd = -1;
+	int					 keymgr_peer_fd = -1;
+	int					 search_peer_fd = -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;
+	int		 got_cert = 0, got_session_init = 0;
+	int		 got_keymgr_peer = 0;
 
 	memset(&sinit, 0, sizeof(sinit));
 
-	/* fd-passing is allowed on this channel: it receives fd-passed peer/session messages below; see imsgev_ibuf_init()'s own comment */
+	/*
+	 * fd-passing allowed here: receives fd-passed peer/session
+	 * messages below; see imsgev_ibuf_init().
+	 */
 	imsgev_ibuf_init(&ibuf3, 3);
 
-	/* SS7: this process is spawned fresh per connection, so the peer handshake is drained in this same synchronous loop rather than separate blocking calls; the auth peer may never arrive, and IMSG_SETUP_PEER's id (0 vs session_id) tells auth from keymgr, matching parent.c's setup_peer_send(). */
+	/*
+	 * This process is spawned fresh per connection, so the
+	 * peer handshake is drained in this same synchronous loop
+	 * rather than separate blocking calls; the auth peer may never
+	 * arrive, and IMSG_SETUP_PEER's id (0 vs session_id) tells
+	 * auth from keymgr, matching parent.c's setup_peer_send().
+	 */
 	while (!got_cert || !got_session_init || !got_keymgr_peer) {
 		if ((n = imsgbuf_get(&ibuf3, &imsg)) == -1)
 			fatal("imsgbuf_get");
@@ -490,7 +578,13 @@ listener_main(void)
 				    "carried no fd");
 				break;
 			}
-			/* A repeat can't happen today, but this runs pre-pledge/pre-privdrop where trusting the parent matters most, so state the invariant rather than silently overwrite; imsg_get_fd(3) already handed us peer_fd, so the duplicate must be closed here. */
+			/*
+			 * A repeat can't happen today, but this runs
+			 * pre-pledge/pre-privdrop where trusting the parent
+			 * matters most, so state the invariant rather than
+			 * silently overwrite; imsg_get_fd(3) already handed
+			 * us peer_fd, so the duplicate must be closed here.
+			 */
 			if (id == 0) {
 				if (auth_peer_fd != -1) {
 					log_warnx("listener: duplicate auth "
@@ -514,7 +608,11 @@ listener_main(void)
 		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 */
+			/*
+			 * 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");
@@ -547,7 +645,12 @@ listener_main(void)
 				log_warnx("bad IMSG_LISTENER_SESSION_INIT");
 				break;
 			}
-			/* Refuse before claiming, unlike the peer cases above: an unclaimed fd on this imsg is closed by imsg_free() below, so there's nothing to clean up by hand. */
+			/*
+			 * Refuse before claiming, unlike the peer cases
+			 * above: an unclaimed fd on this imsg is closed by
+			 * imsg_free() below, so there's nothing to clean up
+			 * by hand.
+			 */
 			if (client_fd != -1) {
 				log_warnx("listener: duplicate "
 				    "IMSG_LISTENER_SESSION_INIT, ignoring");
@@ -577,16 +680,22 @@ listener_main(void)
 	if (chdir("/") == -1)
 		fatal("chdir /");
 
-	/* _imapd: ordinary daemon user, listener-role counterpart to auth.c's _imapauth. */
+	/* _imapd: ordinary daemon user, counterpart to auth.c's _imapauth. */
 	if (setgroups(1, &pw->pw_gid) == -1 ||
 	    setresgid(pw->pw_gid, pw->pw_gid, pw->pw_gid) == -1 ||
 	    setresuid(pw->pw_uid, pw->pw_uid, pw->pw_uid) == -1)
 		fatal("cannot drop privileges to _imapd");
 
-	/* Installs the process-wide RSA_METHOD/EC_KEY_METHOD override before any tls_config touches a key. */
+	/*
+	 * Installs RSA_METHOD/EC_KEY_METHOD override before tls_config touches
+	 * key.
+	 */
 	keymgr_engine_init();
 
-	/* Failure here isn't fatal, degrades to no-TLS, checked via listener_tls_ctx == NULL below. */
+	/*
+	 * Failure isn't fatal; degrades to no-TLS, checked via
+	 * listener_tls_ctx.
+	 */
 	if (cert_len == 0) {
 		log_warnx("listener: no TLS cert received, TLS "
 		    "disabled for this session");
@@ -628,7 +737,12 @@ listener_main(void)
 
 	event_init();
 
-	/* auth_peer_fd may be -1 (no auth-worker spawned); iev_auth.ibuf.fd is left at -1 rather than defaulting to fd 0, and auth_cmd.c's sasl_plain_finish() checks that before composing to it. */
+	/*
+	 * auth_peer_fd may be -1 (no auth-worker spawned); iev_auth.
+	 * ibuf.fd is left at -1 rather than defaulting to fd 0, and
+	 * auth_cmd.c's sasl_plain_finish() checks that before composing
+	 * to it.
+	 */
 	if (auth_peer_fd != -1)
 		imsgev_init(&iev_auth, auth_peer_fd, listener_dispatch_auth,
 		    NULL);
@@ -639,9 +753,15 @@ listener_main(void)
 		    "connection gets one", sinit.session_id);
 	}
 
-	/* SS8.1: search_peer_fd may be -1 (no search-oracle spawned, independent of auth's fork); iev_search.ibuf.fd is left at -1, checked by search_cmd.c's search_dispatch() before composing to it, mirroring iev_auth above. */
+	/*
+	 * search_peer_fd may be -1 (no search-oracle spawned,
+	 * independent of auth's fork); iev_search.ibuf.fd is left at
+	 * -1, checked by search_cmd.c's search_dispatch() before
+	 * composing to it, mirroring iev_auth above.
+	 */
 	if (search_peer_fd != -1)
-		imsgev_init(&iev_search, search_peer_fd, listener_dispatch_search,
+		imsgev_init(&iev_search, search_peer_fd,
+		    listener_dispatch_search,
 		    NULL);
 	else {
 		iev_search.ibuf.fd = -1;
@@ -654,17 +774,35 @@ listener_main(void)
 		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. */
+	/*
+	 * Reuses fd 3's populated ibuf; imsgbuf_init() would drop buffered
+	 * bytes.
+	 */
 	imsgev_init_from_ibuf(&iev_parent, &ibuf3, listener_dispatch_parent,
 	    NULL);
 
-	/* This process's one and only session, built from what boot just drained; must run after event_init() and the TLS setup above since session_tls_start()/session_arm_client_read() register libevent events needing listener_tls_ctx already set. */
+	/*
+	 * This process's one and only session, built from what boot
+	 * just drained; must run after event_init() and the TLS setup
+	 * above since session_tls_start()/session_arm_client_read()
+	 * register libevent events needing listener_tls_ctx already
+	 * set.
+	 */
 	listener_idle_poll_secs = sinit.idle_poll_secs;
+	listener_login_grace_secs = sinit.login_grace_secs;
+	listener_append_max = sinit.append_max;
 
 	listener_start_session(sinit.session_id, client_fd,
 	    sinit.implicit_tls, &sinit.remote_ss, sinit.remote_sslen);
 
-	/* pledge(2) promises: no socket/connect/bind/listen/accept call remains here (SS7 moved them to parent.c), so "inet" is dropped since getnameinfo(3) below only formats already-numeric bytes; "recvfd" stays for the store child's peer fd arriving later; "sendfd" goes since this process never attaches a descriptor to an imsg. */
+	/*
+	 * pledge(2) promises: no socket/connect/bind/listen/accept call
+	 * remains here (parent.c owns them), so "inet" is
+	 * dropped since getnameinfo(3) below only formats
+	 * already-numeric bytes; "recvfd" stays for the store child's
+	 * peer fd arriving later; "sendfd" goes since this process
+	 * never attaches a descriptor to an imsg.
+	 */
 #ifdef __OpenBSD__
 	if (pledge("stdio recvfd", NULL) == -1)
 		fatal("pledge");
@@ -674,8 +812,65 @@ listener_main(void)
 	fatalx("listener: exited event loop");
 }
 
-/* Builds and starts this process's one and only session from the IMSG_LISTENER_SESSION_INIT payload drained at boot (SS7), doing what the old accept()-driven listener_accept() did: build struct session, format remote_addr, log, then begin the TLS handshake or send the plaintext greeting. */
+/*
+ * A connection that completes TCP and then says nothing held a
+ * listener-worker, an auth-worker and a search-oracle for ever, and at
+ * MaxStartups "full" that refuses every later connection. RFC 9051 SS5.4
+ * permits a shortened pre-authentication timer for exactly this; its 30
+ * minute floor governs a post-authentication autologout, which this server
+ * does not have. sshd's LoginGraceTime and smtpd's SMTPD_SESSION_TIMEOUT
+ * are the base-system analogues, and both time out in the process holding
+ * the client descriptor, as this does.
+ */
 static void
+session_login_grace_expired(int fd, short event, void *arg)
+{
+	struct session	*s = arg;
+
+	(void)fd;
+	(void)event;
+
+	log_info("session %u: closing peer=%.200s, no authentication within "
+	    "%u seconds", s->id, s->remote_addr, listener_login_grace_secs);
+	session_teardown(s, "login-grace");
+}
+
+/*
+ * Covers every pre-authentication stall, not just a missing command: an
+ * implicit-TLS connection that never sends a ClientHello never reaches the
+ * command path at all, so a timeout armed on one event would miss it.
+ */
+void
+session_login_grace_init(struct session *s)
+{
+	struct timeval	 tv;
+
+	evtimer_set(&s->grace_ev, session_login_grace_expired, s);
+	if (listener_login_grace_secs == 0)
+		return;		/* "login grace 0": disabled by config */
+
+	tv.tv_sec = (time_t)listener_login_grace_secs;
+	tv.tv_usec = 0;
+	if (evtimer_add(&s->grace_ev, &tv) == -1)
+		log_warnx("session %u: evtimer_add (login grace); this "
+		    "session will not be closed if it never authenticates",
+		    s->id);
+}
+
+void
+session_login_grace_disarm(struct session *s)
+{
+	evtimer_del(&s->grace_ev);
+}
+
+/*
+ * Builds and starts this process's one and only session from the
+ * IMSG_LISTENER_SESSION_INIT payload drained at boot, doing
+ * what the old accept()-driven listener_accept() did: build struct
+ * session, format remote_addr, log, then begin the TLS handshake or
+ * send the plaintext greeting.
+ */
+static void
 listener_start_session(uint32_t session_id, int client_fd, int implicit_tls,
     const struct sockaddr_storage *ss, socklen_t sslen)
 {
@@ -685,20 +880,35 @@ listener_start_session(uint32_t session_id, int client
 	if (s == NULL) {
 		log_warn("calloc");
 		close(client_fd);
-		/* Exit directly rather than return: nothing else will ever run in this process, and returning would park it in event_dispatch() forever holding a MaxStartups slot with no client and no session to tear down. */
+		/*
+		 * Exit directly rather than return: nothing else will
+		 * ever run in this process, and returning would park it
+		 * in event_dispatch() forever holding a MaxStartups slot
+		 * with no client and no session to tear down.
+		 */
 		exit(1);
 	}
+	s->pending_body_fd = -1;	/* calloc(3)'s 0 is a real descriptor */
 	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 = implicit_tls;
+	session_login_grace_init(s);
+	/* CLOCK_MONOTONIC; see connected_at in listener.h. */
+	if (clock_gettime(CLOCK_MONOTONIC, &s->connected_at) == -1)
+		log_warn("session %u: clock_gettime", s->id);
 	TAILQ_INSERT_TAIL(&sessions, s, entry);
 
 	{
 		char hbuf[NI_MAXHOST], sbuf[NI_MAXSERV];
 
-		/* NI_NUMERIC*: pure formatting of already-numeric address bytes, no resolver or network I/O -- the fact listener_main()'s pledge() comment rests dropping "inet" on. */
+		/*
+		 * NI_NUMERIC*: pure formatting of already-numeric address
+		 * bytes, no resolver or network I/O -- the fact
+		 * listener_main()'s pledge() comment rests dropping
+		 * "inet" on.
+		 */
 		if (getnameinfo((const struct sockaddr *)ss, sslen, hbuf,
 		    sizeof(hbuf), sbuf, sizeof(sbuf),
 		    NI_NUMERICHOST | NI_NUMERICSERV) == 0)
@@ -709,15 +919,20 @@ listener_start_session(uint32_t session_id, int client
 			strlcpy(s->remote_addr, "?", sizeof(s->remote_addr));
 	}
 
-	log_debug("session %u: accepted from %s (%s)", s->id,
+	/* %.200s bounds untrusted text, the way sshd's auth.c does */
+	/* tls= is how it was accepted; the close line has the final state */
+	log_info("session %u: connected peer=%.200s tls=%s", s->id,
 	    s->remote_addr,
-	    s->implicit_tls ? "implicit TLS" : "cleartext/STARTTLS");
+	    s->implicit_tls ? "implicit" : "none");
 
+	/* id and stage only: ps titles are world-readable */
+	setproctitle("session %u [accepted]", s->id);
+
 	if (s->implicit_tls) {
 		if (listener_tls_ctx == NULL) {
 			log_warnx("session %u: implicit-TLS port, but TLS "
 			    "isn't configured, closing", s->id);
-			session_teardown(s);
+			session_teardown(s, "tls-error");
 			return;
 		}
 		s->pending_greeting = 1;
@@ -729,7 +944,7 @@ listener_start_session(uint32_t session_id, int client
 	session_send_greeting(s);
 }
 
-/* (Re-)registers client_ev for steady-state reads; guarded for handshake-repurposed re-registration. */
+/* (Re-)registers client_ev for steady-state reads; guards re-registration. */
 void
 session_arm_client_read(struct session *s)
 {
@@ -741,7 +956,7 @@ session_arm_client_read(struct session *s)
 	s->client_ev_added = 1;
 }
 
-/* Creates the per-connection struct tls (non-blocking), then arms client_ev to drive the handshake. */
+/* Creates per-conn struct tls (non-blocking), arms client_ev to drive it. */
 void
 session_tls_start(struct session *s)
 {
@@ -749,7 +964,7 @@ session_tls_start(struct session *s)
 	    != 0) {
 		log_warnx("session %u: tls_accept_socket: %s", s->id,
 		    tls_error(listener_tls_ctx));
-		session_teardown(s);
+		session_teardown(s, "tls-error");
 		return;
 	}
 
@@ -761,7 +976,7 @@ session_tls_start(struct session *s)
 	s->client_ev_added = 1;
 }
 
-/* Drives a non-blocking TLS handshake, re-arming client_ev for whichever direction it wants next. */
+/* Drives non-blocking TLS handshake, re-arms client_ev for wanted direction. */
 void
 session_tls_handshake(int fd, short event, void *arg)
 {
@@ -801,7 +1016,7 @@ session_tls_handshake(int fd, short event, void *arg)
 
 	log_warnx("session %u: tls_handshake: %s (peer %s)", s->id,
 	    tls_error(s->tls_ctx), s->remote_addr);
-	session_teardown(s);
+	session_teardown(s, "tls-error");
 }
 
 /* RFC 9051 SS7.1.1's example OK-response text, used verbatim. */
@@ -813,14 +1028,19 @@ session_send_greeting(struct session *s)
 	session_write(s, greeting, sizeof(greeting) - 1);
 }
 
-/* Forward decls: session_is_busy()/session_enqueue_cmd() are defined below session_dispatch_client() but called from it. */
+/* Forward decls: defined below session_dispatch_client() but called from it. */
 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` ends in a non-synchronizing literal announcement "{n+}" (RFC 9051 SS4.3), whose octets are already in flight and must be accounted for regardless of the command's fate; octet count returned in *lenp. */
+/*
+ * True if `line` ends in a non-synchronizing literal announcement
+ * "{n+}" (RFC 9051 SS4.3), whose octets are already in flight and
+ * must be accounted for regardless of the command's fate; octet
+ * count returned in *lenp.
+ */
 static int
 line_nonsync_literal(const char *line, uint64_t *lenp)
 {
@@ -839,7 +1059,8 @@ line_nonsync_literal(const char *line, uint64_t *lenp)
 	open++;
 	dlen = (size_t)(stop - open);
 	if (dlen >= sizeof(digits) || *open < '0' || *open > '9')
-		return (0);		/* also rejects strtoull(3)'s sign/space forms */
+		/* also rejects strtoull(3)'s sign/space forms */
+		return (0);
 	memcpy(digits, open, dlen);
 	digits[dlen] = '\0';
 
@@ -851,7 +1072,11 @@ line_nonsync_literal(const char *line, uint64_t *lenp)
 	return (1);
 }
 
-/* RFC 9051 SS9: tag = 1*<ASTRING-CHAR except "+">; previously only length was checked, so a tag of "+" could turn session_reply()'s own reply into a command continuation request. */
+/*
+ * RFC 9051 SS9: tag = 1*<ASTRING-CHAR except "+">; previously only
+ * length was checked, so a tag of "+" could turn session_reply()'s
+ * own reply into a command continuation request.
+ */
 static int
 tag_is_valid(const char *tag)
 {
@@ -868,20 +1093,25 @@ tag_is_valid(const char *tag)
 	return (1);
 }
 
-/* Splits s->inbuf into CRLF lines (bare LF isn't one, RFC 9051 SS2.2); session_handle_line() can free *s* (LOGOUT). */
+/* Splits s->inbuf into CRLF lines (bare LF isn't one, SS2.2); may free *s*. */
 void
 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(); re-armed at the end of this function -- named tls_want, not want, to avoid shadowing the literal-assembly loop's own uint64_t want (-Wshadow). */
+	/*
+	 * Bytes offered to tls_read(); re-armed at the end of this
+	 * function -- named tls_want, not want, to avoid shadowing the
+	 * literal-assembly loop's own uint64_t want (-Wshadow).
+	 */
+	size_t		 tls_want = 0;
 
 	(void)event;
 
 	if (s->write_failed) {
 		/* A prior session_write() couldn't finish; see listener.h. */
-		session_teardown(s);
+		session_teardown(s, "io-error");
 		return;
 	}
 
@@ -889,7 +1119,10 @@ session_dispatch_client(int fd, short event, void *arg
 		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. */
+			/*
+			 * tls_read() can want to write (renegotiation); re-arm
+			 * for what it needs.
+			 */
 			event_del(&s->client_ev);
 			event_set(&s->client_ev, s->client_fd,
 			    (n == TLS_WANT_POLLIN) ? EV_READ : EV_WRITE,
@@ -900,27 +1133,31 @@ session_dispatch_client(int fd, short event, void *arg
 		if (n == -1) {
 			log_warnx("session %u: tls_read: %s", s->id,
 			    tls_error(s->tls_ctx));
-			session_teardown(s);
+			session_teardown(s, "tls-error");
 			return;
 		}
-		session_arm_client_read(s);	/* restore steady-state EV_READ|EV_PERSIST; harmless if already correct */
+		/* restore EV_READ|EV_PERSIST; harmless if OK */
+		session_arm_client_read(s);
 	} else {
 		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(). */
+			/*
+			 * 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);
+			session_teardown(s, "io-error");
 			return;
 		}
 	}
 
 	if (n == 0) {
 		log_debug("session %u: client closed connection", s->id);
-		session_teardown(s);
+		session_teardown(s, "client-closed");
 		return;
 	}
 	s->inbuflen += (size_t)n;
@@ -930,7 +1167,12 @@ session_dispatch_client(int fd, short event, void *arg
 		size_t		consumed, linelen;
 		int		alive;
 
-		/* Octets of a refused non-synchronizing literal (bad syntax, over cap, session busy, or not APPEND) are already on the wire, so swallow them instead of parsing the message body as further IMAP commands. */
+		/*
+		 * Octets of a refused non-synchronizing literal (bad syntax,
+		 * over cap, session busy, or not APPEND) are already on the
+		 * wire, so swallow them instead of parsing the message body
+		 * as further IMAP commands.
+		 */
 		if (s->literal_discard > 0) {
 			uint64_t	take;
 
@@ -944,7 +1186,13 @@ session_dispatch_client(int fd, short event, void *arg
 			}
 			if (s->literal_discard > 0)
 				break;	/* need more data */
-			/* Swallow the announcing command line's trailing CRLF so the line parser doesn't see a spurious zero-length line and answer "* BAD Empty command line" after every refused literal; anything besides CRLF is left for the parser. */
+			/*
+			 * Swallow the announcing command line's trailing CRLF
+			 * so the line parser doesn't see a spurious
+			 * zero-length line and answer "* BAD Empty command
+			 * line" after every refused literal; anything besides
+			 * CRLF is left for the parser.
+			 */
 			if (s->inbuflen >= 2 && s->inbuf[0] == '\r' &&
 			    s->inbuf[1] == '\n') {
 				memmove(s->inbuf, s->inbuf + 2,
@@ -954,7 +1202,10 @@ session_dispatch_client(int fd, short event, void *arg
 			continue;
 		}
 
-		/* RFC 9051 SS4.3 literal in flight; checked before CRLF search since raw octets can contain CRLF. */
+		/*
+		 * RFC 9051 SS4.3 literal in flight; checked before CRLF, may
+		 * contain one.
+		 */
 		if (s->literal_pending) {
 			uint64_t	want, take;
 
@@ -963,9 +1214,15 @@ session_dispatch_client(int fd, short event, void *arg
 			    (uint64_t)s->inbuflen : want;
 
 			if (take > 0) {
-				memcpy(s->literal_buf +
-				    (s->literal_len - s->literal_remaining),
-				    s->inbuf, (size_t)take);
+				/* on to the store; take <= SESSION_INBUF_MAX */
+				if (imsg_compose(&s->store_iev->ibuf,
+				    IMSG_MBOX_APPEND_DATA, 0, 0, -1, s->inbuf,
+				    (size_t)take) == -1) {
+					log_warn("session %u: imsg_compose "
+					    "IMSG_MBOX_APPEND_DATA", s->id);
+					session_teardown(s, "io-error");
+					return;
+				}
 				s->literal_remaining -= take;
 				memmove(s->inbuf, s->inbuf + take,
 				    s->inbuflen - (size_t)take);
@@ -975,14 +1232,17 @@ session_dispatch_client(int fd, short event, void *arg
 			if (s->literal_remaining > 0)
 				break;	/* need more data */
 
-			/* Literal body received; `command` still needs its trailing CRLF (RFC 9051 SS9). */
+			/*
+			 * Literal body received; `command` still needs its CRLF
+			 * (RFC 9051 SS9).
+			 */
 			if (s->inbuflen < 2)
 				break;	/* trailing CRLF hasn't arrived yet */
 			if (s->inbuf[0] != '\r' || s->inbuf[1] != '\n') {
 				/* no reliable resync point, give up */
 				log_warnx("session %u: expected CRLF after "
 				    "literal data, closing", s->id);
-				session_teardown(s);
+				session_teardown(s, "protocol-error");
 				return;
 			}
 			memmove(s->inbuf, s->inbuf + 2, s->inbuflen - 2);
@@ -1002,7 +1262,13 @@ session_dispatch_client(int fd, short event, void *arg
 		linelen = (size_t)(crlf - s->inbuf);
 		consumed = linelen + 2;
 
-		/* RFC 9051 SS2.2/SS9: CR/LF only appear as the CRLF terminator and NUL isn't an ASTRING-CHAR; this is the one chokepoint enforcing that before client text gets echoed back or silently truncated by the C-string parsers, so a violation closes the connection. */
+		/*
+		 * RFC 9051 SS2.2/SS9: CR/LF only appear as the CRLF
+		 * terminator and NUL isn't an ASTRING-CHAR; this is the one
+		 * chokepoint enforcing that before client text gets echoed
+		 * back or silently truncated by the C-string parsers, so a
+		 * violation closes the connection.
+		 */
 		if (memchr(s->inbuf, '\r', linelen) != NULL ||
 		    memchr(s->inbuf, '\n', linelen) != NULL ||
 		    memchr(s->inbuf, '\0', linelen) != NULL) {
@@ -1012,13 +1278,18 @@ session_dispatch_client(int fd, short event, void *arg
 			log_warnx("session %u: bare CR/LF/NUL in command "
 			    "line, closing", s->id);
 			session_write(s, bad, sizeof(bad) - 1);
-			session_teardown(s);
+			session_teardown(s, "protocol-error");
 			return;
 		}
 
 		*crlf = '\0';
 
-		/* Note a trailing "{n+}" before dispatch since its octets follow immediately on the wire; over RFC 9051 SS4.3's 4096-octet cap the client is already out of spec and we can't guess how much to skip, so close instead. */
+		/*
+		 * Note a trailing "{n+}" before dispatch since its octets
+		 * follow immediately on the wire; over RFC 9051 SS4.3's
+		 * 4096-octet cap the client is already out of spec and we
+		 * can't guess how much to skip, so close instead.
+		 */
 		nonsync_len = 0;
 		if (line_nonsync_literal(s->inbuf, &nonsync_len) &&
 		    nonsync_len > IMAP_NONSYNC_LITERAL_MAX) {
@@ -1030,19 +1301,29 @@ session_dispatch_client(int fd, short event, void *arg
 			    "literal (%llu), closing", s->id,
 			    (unsigned long long)nonsync_len);
 			session_write(s, bad, sizeof(bad) - 1);
-			session_teardown(s);
+			session_teardown(s, "limit-exceeded");
 			return;
 		}
 
-		/* SASL continuation (auth_cont) and IDLE's "DONE" (idling) route around the tag/name/args parser and the pipeline queue below. */
+		/*
+		 * SASL continuation, IDLE's DONE route around tag/name/args
+		 * parser/queue.
+		 */
 		if (s->auth_cont) {
-			/* the line is the user's password in base64, see below */
+			/* the line is the user's base64 password, 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)) {
-			/* Queue rather than reject a command while one is already in flight (RFC 9051 SS5.5 pipelining), except one carrying a non-synchronizing literal: its octets are arriving now but cmd_append() wouldn't enter literal-read mode until dequeued, so refuse and swallow instead. */
+			/*
+			 * Queue rather than reject a command while one is
+			 * already in flight (RFC 9051 SS5.5 pipelining), except
+			 * one carrying a non-synchronizing literal: its octets
+			 * are arriving now but cmd_append() wouldn't enter
+			 * literal-read mode until dequeued, so refuse and
+			 * swallow instead.
+			 */
 			if (nonsync_len > 0) {
 				session_reply(s, "*", "BAD",
 				    "non-synchronizing literal not accepted on "
@@ -1057,7 +1338,7 @@ session_dispatch_client(int fd, short event, void *arg
 				log_warnx("session %u: pipelined command "
 				    "queue full, closing", s->id);
 				session_write(s, bad, sizeof(bad) - 1);
-				session_teardown(s);
+				session_teardown(s, "limit-exceeded");
 				return;
 			} else
 				alive = 1;
@@ -1067,15 +1348,24 @@ session_dispatch_client(int fd, short event, void *arg
 		if (alive == 0)
 			return;	/* s was torn down (LOGOUT), do not touch */
 
-		/* Anything cmd_append() didn't take over (via literal_pending) gets swallowed rather than parsed. */
+		/*
+		 * Anything cmd_append() didn't take (literal_pending) is
+		 * swallowed here.
+		 */
 		if (nonsync_len > 0 && !s->literal_pending)
 			s->literal_discard = nonsync_len;
 
-		/* cmd_starttls() zeroes inbuflen to discard pipelined plaintext, clamp to avoid underflow. */
+		/*
+		 * cmd_starttls() zeroes inbuflen to discard plaintext; clamps
+		 * 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. */
+		/*
+		 * Don't leave a SASL response (base64 password) in a long-lived
+		 * buffer.
+		 */
 		if (s->scrub_inbuf) {
 			explicit_bzero(s->inbuf, consumed);
 			s->scrub_inbuf = 0;
@@ -1086,24 +1376,41 @@ session_dispatch_client(int fd, short event, void *arg
 	}
 
 	if (s->inbuflen == sizeof(s->inbuf)) {
-		/* Buffer full, no CRLF, matches the spirit of RFC 9051 SS7.1.3's example text. */
+		/*
+		 * Buffer full, no CRLF; matches RFC 9051 SS7.1.3's example
+		 * text.
+		 */
 		static const char bad[] = "* BAD command line too long\r\n";
 
 		log_warnx("session %u: command line too long, closing",
 		    s->id);
-		/* was a raw write(2), wrong on a TLS session, bytes would land unencrypted on the wire */
+		/*
+		 * was a raw write(2): wrong on a TLS session, bytes would land
+		 * unencrypted
+		 */
 		session_write(s, bad, sizeof(bad) - 1);
-		session_teardown(s);
+		session_teardown(s, "limit-exceeded");
 		return;
 	}
 
-	/* A TLS record can hold more than inbuf's SESSION_INBUF_MAX, and level-triggered EV_READ won't refire for bytes libtls is still holding, so a full read re-queues this callback via event_active() (ncalls=1) to drain the rest; this terminates once tls_read() returns TLS_WANT_POLLIN. */
+	/*
+	 * A TLS record can hold more than inbuf's SESSION_INBUF_MAX,
+	 * and level-triggered EV_READ won't refire for bytes libtls is
+	 * still holding, so a full read re-queues this callback via
+	 * event_active() (ncalls=1) to drain the rest; this terminates
+	 * once tls_read() returns TLS_WANT_POLLIN.
+	 */
 	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(): retries EAGAIN/TLS_WANT_POLL* via poll(2) up to SESSION_WRITE_POLL_TIMEOUT_MS; on error or timeout it only records the failure in s->write_failed rather than tearing s down itself -- see listener.h and session_dispatch_client(). */
+/*
+ * Blocking write(2)/tls_write(): retries EAGAIN/TLS_WANT_POLL* via
+ * poll(2) up to SESSION_WRITE_POLL_TIMEOUT_MS; on error or timeout it
+ * only records the failure in s->write_failed rather than tearing s
+ * down itself -- see listener.h and session_dispatch_client().
+ */
 #define SESSION_WRITE_POLL_TIMEOUT_MS	5000
 
 void
@@ -1114,7 +1421,13 @@ session_write(struct session *s, const char *buf, size
 	if (s->write_failed)
 		return;
 
-	/* Permanent outbound-traffic diagnostic, gated on log_getverbose() since building/scrubbing dbuf is real work otherwise done on every write; session_write() carries every outbound byte including literal FETCH payloads, so -v -v is a message-content-exposure decision, not just a logging knob. */
+	/*
+	 * Permanent outbound-traffic diagnostic, gated on
+	 * log_getverbose() since building/scrubbing dbuf is real work
+	 * otherwise done on every write; session_write() carries every
+	 * outbound byte including literal FETCH payloads, so -v -v is a
+	 * message-content-exposure decision, not just a logging knob.
+	 */
 	if (log_getverbose() > 0) {
 		char	dbuf[301];
 		size_t	dlen = len < sizeof(dbuf) - 1 ? len : sizeof(dbuf) - 1;
@@ -1142,7 +1455,8 @@ session_write(struct session *s, const char *buf, size
 					pfd.fd = s->client_fd;
 					pfd.events = POLLOUT;
 					if (poll(&pfd, 1,
-					    SESSION_WRITE_POLL_TIMEOUT_MS) <= 0) {
+					    SESSION_WRITE_POLL_TIMEOUT_MS)
+					    <= 0) {
 						log_warnx("session %u: write: "
 						    "timed out or poll error",
 						    s->id);
@@ -1195,7 +1509,17 @@ session_write(struct session *s, const char *buf, size
 }
 
 
-/* Formats one complete response line into a 512-byte buffer and writes it. The one thing this must never do is emit a line the client cannot frame, so on overflow it forces the last two bytes back to CRLF rather than send a truncated, unterminated line -- that fixup is the whole reason session_reply() and session_untagged() are two lines each instead of fifteen: they differed only in their format string, and this is everything else they had in common. fuzz/fuzz_session_reply.c exists to hold exactly this invariant and carries its own stub copy of all three. */
+/*
+ * Formats one complete response line into a 512-byte buffer and
+ * writes it. The one thing this must never do is emit a line the
+ * client cannot frame, so on overflow it forces the last two bytes
+ * back to CRLF rather than send a truncated, unterminated line --
+ * that fixup is the whole reason session_reply() and
+ * session_untagged() are two lines each instead of fifteen: they
+ * differed only in their format string, and this is everything else
+ * they had in common. fuzz/fuzz_session_reply.c exists to hold
+ * exactly this invariant and carries its own stub copy of all three.
+ */
 static void	 session_writef(struct session *, const char *, ...)
 		    __attribute__((__format__ (printf, 2, 3)));
 
@@ -1234,7 +1558,13 @@ session_untagged(struct session *s, const char *text)
 	session_writef(s, "* %s\r\n", text);
 }
 
-/* Shared client-composing send for every "fixed request struct + N elemsize-sized trailing elements" imsg to s->store_iev, plus the degenerate no-trailing-array form CREATE/DELETE/RENAME/LIST/STATUS use; malloc failure and imsg_compose failure are both reported back; the caller replies NO and restores s->state. */
+/*
+ * Shared client-composing send for every "fixed request struct + N
+ * elemsize-sized trailing elements" imsg to s->store_iev, plus the
+ * degenerate no-trailing-array form CREATE/DELETE/RENAME/LIST/STATUS
+ * use; 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,
@@ -1243,7 +1573,13 @@ send_mbox_request(struct session *s, int imsg_type, co
 	size_t	 bodylen = (size_t)nelems * elemsize;
 	char	*combined;
 
-	/* Nothing trailing: compose the caller's request where it already sits rather than malloc a copy of it just to hand it straight to imsg_compose(). Reached by the five fixed-size commands (LIST with req NULL and reqlen 0, which is the no-payload compose IMSG_MBOX_LIST wants), and by SELECT/EXPUNGE whenever their sequence-set is legitimately empty. */
+	/*
+	 * Nothing trailing: compose the caller's request where it
+	 * already sits rather than malloc a copy of it just to hand it
+	 * straight to imsg_compose(). Reached by the fixed-size commands,
+	 * and by SELECT/EXPUNGE whenever their sequence-set is
+	 * legitimately empty.
+	 */
 	if (bodylen == 0) {
 		if (imsg_compose(&s->store_iev->ibuf, imsg_type, 0, 0, -1,
 		    req, reqlen) == -1) {
@@ -1261,7 +1597,14 @@ send_mbox_request(struct session *s, int imsg_type, co
 	memcpy(combined, req, reqlen);
 	memcpy(combined + reqlen, elems, bodylen);
 
-	/* Report a compose failure via return value instead of just logging it: previously s->state stayed at the busy value with nothing in flight, so session_is_busy() blocked forever waiting for a reply that would never be sent; every caller already handles a 0 return by replying NO and restoring state. */
+	/*
+	 * Report a compose failure via return value instead of just
+	 * logging it: previously s->state stayed at the busy value with
+	 * nothing in flight, so session_is_busy() blocked forever
+	 * waiting for a reply that would never be sent; every caller
+	 * already handles a 0 return by replying NO and restoring
+	 * state.
+	 */
 	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);
@@ -1272,7 +1615,7 @@ send_mbox_request(struct session *s, int imsg_type, co
 	return (1);
 }
 
-/* RFC 7162 SS3.1: marks the session CONDSTORE-aware; emits an unsolicited HIGHESTMODSEQ OK if selected. */
+/* RFC 7162 SS3.1: marks CONDSTORE-aware; emits unsolicited HIGHESTMODSEQ. */
 void
 session_condstore_enable(struct session *s)
 {
@@ -1289,7 +1632,7 @@ session_condstore_enable(struct session *s)
 	}
 }
 
-/* Splits a CRLF-stripped line into tag/name/args (RFC 9051 `command = tag SP ...`); lenient on spaces. */
+/* Splits a CRLF-stripped line into tag/name/args (RFC 9051 `tag SP ...`). */
 int
 parse_command_line(char *line, char **tag, char **name, char **args)
 {
@@ -1333,7 +1676,11 @@ parse_command_line(char *line, char **tag, char **name
 	return (0);
 }
 
-/* True only for the post-auth async-round-trip states; pre-auth states (AUTHENTICATING/STORE_PENDING) deliberately excluded, see SESSION_CMD_QUEUE_MAX's comment. */
+/*
+ * True only for the post-auth async-round-trip states; pre-auth
+ * states (AUTHENTICATING/STORE_PENDING) deliberately excluded, see
+ * SESSION_CMD_QUEUE_MAX's comment.
+ */
 static int
 session_is_busy(const struct session *s)
 {
@@ -1357,7 +1704,7 @@ session_is_busy(const struct session *s)
 	}
 }
 
-/* Appends a pipelined line to s->cmd_queue; returns 0 on a full queue or strdup(3) failure, caller must teardown. */
+/* Appends a line to s->cmd_queue; 0 on a full queue or strdup(3) failure. */
 static int
 session_enqueue_cmd(struct session *s, const char *line)
 {
@@ -1375,7 +1722,7 @@ session_enqueue_cmd(struct session *s, const char *lin
 	return (1);
 }
 
-/* Dispatches queued pipelined commands while the session stays idle; returns 0 if one of them tore *s* down (LOGOUT). */
+/* Dispatches queued commands while idle; returns 0 if one tore *s* down. */
 int
 session_dequeue_next(struct session *s)
 {
@@ -1396,7 +1743,7 @@ session_dequeue_next(struct session *s)
 	return (1);
 }
 
-/* Returns 1 if the session is still alive, 0 if torn down (LOGOUT), caller must not touch *s* if 0. */
+/* Returns 1 if alive, 0 if torn down; caller must not touch *s* if 0. */
 int
 session_handle_line(struct session *s, char *line)
 {
@@ -1409,7 +1756,12 @@ session_handle_line(struct session *s, char *line)
 		return (1);
 	}
 
-	/* AUTHENTICATE's optional initial response is the base64 of the user's cleartext password (RFC 4616 SS2), and LOGIN sends it in the clear when LOGINDISABLED is ignored, so log the command but never its arguments. */
+	/*
+	 * AUTHENTICATE's optional initial response is the base64 of the
+	 * user's cleartext password (RFC 4616 SS2), and LOGIN sends it
+	 * in the clear when LOGINDISABLED is ignored, so log the
+	 * command but never its arguments.
+	 */
 	if (name != NULL && (strcasecmp(name, "AUTHENTICATE") == 0 ||
 	    strcasecmp(name, "LOGIN") == 0))
 		log_debug("session %u: <<< %s %s <redacted>", s->id, tag,
@@ -1419,7 +1771,10 @@ session_handle_line(struct session *s, char *line)
 		    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. */
+	/*
+	 * Checked once here, not per strlcpy(3) site: tag must not echo
+	 * truncated.
+	 */
 	if (strlen(tag) >= IMAP_TAG_MAX) {
 		session_reply(s, "*", "BAD", "Tag too long");
 		return (1);
@@ -1443,7 +1798,10 @@ session_handle_line(struct session *s, char *line)
 		return (1);
 	}
 	if (!(imap_cmds[i].states & (1U << s->state))) {
-		/* RFC 9051 SS3: BAD or NO for wrong-state command, BAD chosen here. */
+		/*
+		 * RFC 9051 SS3: BAD or NO for wrong-state command, BAD chosen
+		 * here.
+		 */
 		session_reply(s, tag, "BAD",
 		    "Command not permitted in this state");
 		return (1);
@@ -1459,7 +1817,10 @@ listener_dispatch_auth(int fd, short event, void *arg)
 	struct imsg	 imsg;
 	ssize_t		 n;
 
-	/* Without this, a queued imsg_compose() never flushes and the EV_WRITE arm imsgev_on_compose() installed busy-loops. */
+	/*
+	 * Without this, a queued imsg_compose() never flushes; EV_WRITE
+	 * busy-loops.
+	 */
 	if (event & EV_WRITE) {
 		if (imsgbuf_write(&iev->ibuf) == -1)
 			fatal("imsgbuf_write");
@@ -1469,7 +1830,14 @@ listener_dispatch_auth(int fd, short event, void *arg)
 		if ((n = imsgbuf_read(&iev->ibuf)) == -1)
 			fatal("imsgbuf_read");
 		if (n == 0) {
-			/* SS7: this session's auth-worker may die independently of the session (already authenticated, or unable to AUTHENTICATE again); close and mark it dead rather than just event_del(), or a later AUTHENTICATE would compose onto a dead fd and the client would hang waiting for a reply. */
+			/*
+			 * This session's auth-worker may die
+			 * independently of the session (already
+			 * authenticated, or unable to AUTHENTICATE again);
+			 * close and mark it dead rather than just event_del(),
+			 * or a later AUTHENTICATE would compose onto a dead fd
+			 * and the client would hang waiting for a reply.
+			 */
 			log_warnx("auth-worker closed channel");
 			event_del(&iev->ev);
 			close(iev->ibuf.fd);
@@ -1501,12 +1869,16 @@ listener_dispatch_auth(int fd, short event, void *arg)
 			}
 			if (!res.ok) {
 				s->state = SESSION_NOT_AUTH;
+				/* user= prints only if this stays set */
+				explicit_bzero(s->user, sizeof(s->user));
 				session_reply(s, s->pending_tag, "NO",
-				    "[AUTHENTICATIONFAILED] authentication failed");
+				    "[AUTHENTICATIONFAILED] authentication "
+				    "failed");
 				break;
 			}
-			
+
 			s->state = SESSION_STORE_PENDING;
+			setproctitle("session %u [authenticated]", s->id);
 			break;
 		}
 		default:
@@ -1520,7 +1892,13 @@ listener_dispatch_auth(int fd, short event, void *arg)
 	(void)fd;
 }
 
-/* SEARCH-ORACLE channel (SS8.1): at most one IMSG_SEARCH_PARSE_REQUEST/RESULT round trip is ever in flight for this process's one session, so TAILQ_FIRST(&sessions) is unambiguously it; session_id lookup elsewhere is kept only for parity with auth.c's imsg shape. */
+/*
+ * SEARCH-ORACLE channel: at most one
+ * IMSG_SEARCH_PARSE_REQUEST/RESULT round trip is ever in flight for
+ * this process's one session, so TAILQ_FIRST(&sessions) is
+ * unambiguously it; session_id lookup elsewhere is kept only for
+ * parity with auth.c's imsg shape.
+ */
 void
 listener_dispatch_search(int fd, short event, void *arg)
 {
@@ -1538,7 +1916,13 @@ listener_dispatch_search(int fd, short event, void *ar
 		if ((n = imsgbuf_read(&iev->ibuf)) == -1)
 			fatal("imsgbuf_read");
 		if (n == 0) {
-			/* SS8.1: this session's search-oracle may die independently of the session, same fail-soft shape as listener_dispatch_auth()'s channel-EOF handling -- an in-flight SEARCH gets a synthesized NO, and a later one fails fast via iev_search.ibuf.fd == -1. */
+			/*
+			 * This session's search-oracle may die
+			 * independently of the session, same fail-soft shape
+			 * as listener_dispatch_auth()'s channel-EOF handling --
+			 * an in-flight SEARCH gets a synthesized NO, and a
+			 * later one fails fast via iev_search.ibuf.fd == -1.
+			 */
 			log_warnx("search-oracle closed channel");
 			event_del(&iev->ev);
 			close(iev->ibuf.fd);
@@ -1551,7 +1935,8 @@ listener_dispatch_search(int fd, short event, void *ar
 				    "[UNAVAILABLE] search temporarily "
 				    "unavailable");
 				if (!session_dequeue_next(s))
-					return; /* s torn down by a queued LOGOUT */
+					/* s torn down by a queued LOGOUT */
+					return;
 			}
 			return;
 		}
@@ -1583,7 +1968,13 @@ listener_dispatch_search(int fd, short event, void *ar
 				    "[SERVERBUG] internal error");
 				break;
 			}
-			/* imsg_get_buf() guarantees size, not NUL termination, and this field goes straight to session_reply() via snprintf("%s"), so treat the least-trusted process's framing as untrusted, same as auth.c's username/password and parent.c's maildir. */
+			/*
+			 * imsg_get_buf() guarantees size, not NUL termination,
+			 * and this field goes straight to session_reply() via
+			 * snprintf("%s"), so treat the least-trusted process's
+			 * framing as untrusted, same as auth.c's
+			 * username/password and parent.c's maildir.
+			 */
 			res.errmsg[sizeof(res.errmsg) - 1] = '\0';
 
 			if (res.rc == 0) {
@@ -1592,9 +1983,9 @@ listener_dispatch_search(int fd, short event, void *ar
 				    bodylen != (size_t)res.nnodes *
 				    sizeof(struct search_node)) {
 					log_warnx("bad "
-					    "IMSG_SEARCH_PARSE_RESULT (nnodes "
-					    "%u, %zu trailing bytes)", res.nnodes,
-					    bodylen);
+					    "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");
@@ -1604,8 +1995,9 @@ listener_dispatch_search(int fd, short event, void *ar
 					if ((nodes = malloc(bodylen)) == NULL) {
 						log_warn("malloc SEARCH nodes");
 						s->state = SESSION_SELECTED;
-						session_reply(s, s->pending_tag,
-						    "NO", "[SERVERBUG] internal "
+						session_reply(s,
+						    s->pending_tag, "NO",
+						    "[SERVERBUG] internal "
 						    "error");
 						break;
 					}
@@ -1616,8 +2008,9 @@ listener_dispatch_search(int fd, short event, void *ar
 						    "(nodes)");
 						free(nodes);
 						s->state = SESSION_SELECTED;
-						session_reply(s, s->pending_tag,
-						    "NO", "[SERVERBUG] internal "
+						session_reply(s,
+						    s->pending_tag, "NO",
+						    "[SERVERBUG] internal "
 						    "error");
 						break;
 					}
@@ -1644,7 +2037,15 @@ listener_dispatch_search(int fd, short event, void *ar
 	}
 }
 
-/* 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. */
+/*
+ * 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: 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)
 {
@@ -1662,9 +2063,14 @@ listener_dispatch_parent(int fd, short event, void *ar
 		if ((n = imsgbuf_read(&iev->ibuf)) == -1)
 			fatal("imsgbuf_read");
 		if (n == 0) {
-			log_warnx("parent closed channel");
-			event_del(&iev->ev);
-			return;
+			struct session	*s = TAILQ_FIRST(&sessions);
+
+			/* the parent is stopping us: end the session */
+			log_debug("listener-worker: parent closed "
+			    "channel, shutting down");
+			if (s != NULL)
+				session_teardown(s, "shutdown");
+			exit(0);
 		}
 	}
 
@@ -1716,6 +2122,12 @@ listener_dispatch_parent(int fd, short event, void *ar
 			imsgev_init(s->store_iev, store_fd,
 			    session_store_dispatch, s);
 			s->state = SESSION_AUTHENTICATED;
+			/*
+			 * Authenticated: stop the pre-authentication timer.
+			 * This is the same point parent.c marks the session
+			 * authenticated for MaxStartups.
+			 */
+			session_login_grace_disarm(s);
 			log_debug("session %u: store peer wired", sess_id);
 			/* RFC 9051 SS6.2.2's PLAIN example text, verbatim. */
 			session_reply(s, s->pending_tag, "OK",
@@ -1745,9 +2157,10 @@ session_find(uint32_t id)
 	return (NULL);
 }
 
-/* Best-effort IMSG_STORE_SHUTDOWN to the store child, then closes fds, unregisters events, frees. */
+/* Best-effort IMSG_STORE_SHUTDOWN to store child; closes fds, unregisters. */
+/* reason: one of eight fixed tokens, never NULL, never attacker text */
 void
-session_teardown(struct session *s)
+session_teardown(struct session *s, const char *reason)
 {
 	if (s->store_iev != NULL) {
 		if (imsg_compose(&s->store_iev->ibuf, IMSG_STORE_SHUTDOWN,
@@ -1763,6 +2176,7 @@ session_teardown(struct session *s)
 	}
 
 	session_idle_poll_disarm(s);
+	session_login_grace_disarm(s);
 
 	if (s->client_ev_added)
 		event_del(&s->client_ev);
@@ -1770,42 +2184,76 @@ session_teardown(struct session *s)
 	if (s->tls_ctx != NULL) {
 		int	ret = tls_close(s->tls_ctx);
 
-		/* tls_close() closes the fd itself except when close_notify wants another round trip. */
+		/*
+		 * tls_close() closes the fd unless close_notify wants one more
+		 * round trip.
+		 */
 		if (ret == TLS_WANT_POLLIN || ret == TLS_WANT_POLLOUT)
 			close(s->client_fd);
+		/* debug: a missing close_notify is routine; httpd ignores it */
 		else if (ret != 0)
-			log_warnx("session %u: tls_close: %s", s->id,
+			log_debug("session %u: tls_close: %s", s->id,
 			    tls_error(s->tls_ctx));
 		tls_free(s->tls_ctx);
 	} else {
 		close(s->client_fd);
 	}
 
-	/* All NULL-safe, can still be set on a mid-stream teardown (FETCH/SEARCH/etc. in flight). */
-	free(s->literal_buf);
+	/*
+	 * All NULL-safe; may be set on a mid-stream teardown (FETCH/SEARCH
+	 * etc).
+	 */
 	free(s->search_matches);
 	free(s->vanished_ranges);
 	free(s->qresync_fetches);
 	free(s->store_modified);
 	free(s->pending_header_buf);
-	free(s->pending_body_buf);
+	if (s->pending_body_fd != -1)
+		close(s->pending_body_fd);
 	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. */
+	/*
+	 * Commands pipelined behind the in-flight one when 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 (peer %s)", s->id, s->remote_addr);
+	{
+		struct timespec	 now;
+		const char	*who = "-";
+		long long	 dur = -1;
 
-	/* inbuf can still hold a base64 SASL response; don't hand it back to the allocator intact. */
+		if (clock_gettime(CLOCK_MONOTONIC, &now) != -1 &&
+		    s->connected_at.tv_sec != 0)
+			dur = (long long)(now.tv_sec -
+			    s->connected_at.tv_sec);
+		/* user= stays "-" until past authenticating; see listener.h */
+		if (s->user[0] != '\0' && s->state != SESSION_AUTHENTICATING)
+			who = s->user;
+		/* tls= is the final state, unlike the connect line's */
+		log_info("session %u: closed peer=%.200s user=%.64s "
+		    "reason=%s tls=%s duration=%lld", s->id,
+		    s->remote_addr, who, reason,
+		    s->tls_active ? "yes" : "no", dur);
+	}
+
+	/*
+	 * inbuf may hold a base64 SASL response; don't hand it to allocator
+	 * intact.
+	 */
 	explicit_bzero(s, sizeof(*s));
 	free(s);
 
-	/* SS7: this process serves exactly this one session and never another, so exit rather than idle forever in event_dispatch() leaking a process per finished connection; parent.c's reap_child() already treats this exit as expected, matching store.c's store_shutdown(). */
+	/*
+	 * This process serves exactly this one session and never
+	 * another, so exit rather than idle forever in event_dispatch()
+	 * leaking a process per finished connection; parent.c's
+	 * reap_child() already treats this exit as expected, matching
+	 * store.c's store_shutdown().
+	 */
 	log_debug("listener-worker: session closed, exiting");
 	exit(0);
 }
blob - 6fe957dc56d5fbcb2f274002f2b0ee09b78cece3
blob + 8755fed3f2e8207aa45e1e8304bd299107a67642
--- src/listener.h
+++ src/listener.h
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  *
@@ -14,11 +16,8 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/*
- * listener.h, internal, listener-process-only shared declarations, so
- * listener.c's split files can all see struct session, enum
- * session_state, and each other's entry points.
- */
+/* listener.h: declarations private to the listener process, so */
+/* listener.c's split files can see struct session and each other. */
 
 #ifndef LISTENER_H
 #define LISTENER_H
@@ -28,6 +27,7 @@
 
 #include <event.h>
 #include <stdint.h>
+#include <time.h>
 #include <tls.h>
 
 enum session_state {
@@ -46,12 +46,12 @@ enum session_state {
 	SESSION_EXPUNGING,	/* IMSG_MBOX_EXPUNGE sent (EXPUNGE, or CLOSE
 				 * with silent=1), awaiting IMSG_MBOX_EXPUNGED
 				 * stream + terminal IMSG_MBOX_RESULT */
-	SESSION_APPENDING,	/* IMSG_MBOX_APPEND sent, awaiting the single
-				 * terminal IMSG_MBOX_APPENDED reply. Distinct
-				 * from the client-literal-read phase
+	SESSION_APPENDING,	/* IMSG_MBOX_APPEND_END sent, awaiting the
+				 * single terminal IMSG_MBOX_APPENDED reply.
+				 * Distinct 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
+	SESSION_SEARCH_PARSING, /* IMSG_SEARCH_PARSE_REQUEST sent
 				 * to the per-connection search-oracle,
 				 * awaiting IMSG_SEARCH_PARSE_RESULT --
 				 * precedes SESSION_SEARCHING below, a SEARCH
@@ -79,81 +79,53 @@ enum session_state {
 				 * branches to
 				 * session_finish_copy_or_move(). */
 
-	/*
-	 * RFC 9051 SS6.3.4-SS6.3.6/SS6.3.9 additions (flat multi-mailbox
-	 * support). All four are command-auth and never change s->state's
-	 * SELECTED-ness; s->mbox_op_prev_state records the state to
-	 * restore (one shared field, only one of these four is ever in
-	 * flight per session).
-	 */
+	/* RFC 9051 SS6.3.4-SS6.3.9. All six are command-auth and never */
+	/* change s->state's SELECTED-ness; mbox_op_prev_state records what */
+	/* to restore, and only one is in flight per session. */
 	SESSION_CREATING,	/* IMSG_MBOX_CREATE sent, single terminal
 				 * IMSG_MBOX_RESULT reply */
 	SESSION_DELETING,	/* IMSG_MBOX_DELETE sent, same shape as
 				 * SESSION_CREATING */
 	SESSION_RENAMING,	/* IMSG_MBOX_RENAME sent, same shape as
 				 * SESSION_CREATING */
-	SESSION_LISTING		/* IMSG_MBOX_LIST sent, awaiting the
+	SESSION_LISTING,	/* IMSG_MBOX_LIST sent, awaiting the
 				 * IMSG_MBOX_LIST_ITEM stream + terminal
 				 * IMSG_MBOX_RESULT; branches to
 				 * session_finish_list(). */
+	SESSION_SUBSCRIBING,	/* IMSG_MBOX_SUBSCRIBE sent, same shape as
+				 * SESSION_CREATING */
+	SESSION_UNSUBSCRIBING	/* IMSG_MBOX_UNSUBSCRIBE sent, same shape as
+				 * SESSION_CREATING */
 };
 
-/*
- * Line-length cap for the raw CRLF-delimited read buffer below. RFC 9051
- * doesn't mandate a specific limit, but any real server needs one to
- * bound memory for a client that never sends CRLF.
- */
+/* Line-length cap for the raw read buffer. RFC 9051 mandates no limit, */
+/* but one is needed to bound a client that never sends CRLF. */
 #define SESSION_INBUF_MAX	8192
 
-/*
- * Bound for a client-chosen tag remembered across an async IMSG_AUTH_
- * REQUEST/IMSG_AUTH_RESULT round trip. RFC 9051 SS9 specifies no tag
- * length limit; truncated via strlcpy() rather than rejected.
- */
+/* Bound on a client-chosen tag held across an async round trip. RFC */
+/* 9051 SS9 sets no limit; an over-long tag is truncated, not rejected. */
 #define IMAP_TAG_MAX	64
 
-/*
- * Bound on pipelined command lines ahead of the one awaiting an async
- * store round trip (RFC 9051 SS5.5 permits pipelining as long as the
- * server processes them in order). A session that queues past this is
- * disconnected rather than given unbounded memory. Does NOT apply to
- * the pre-authentication states (see session_is_busy()).
- */
+/* Bound on lines pipelined ahead of one awaiting a store round trip */
+/* (RFC 9051 SS5.5). Queueing past this disconnects the session rather */
+/* than granting unbounded memory. Not applied before authentication. */
 #define SESSION_CMD_QUEUE_MAX	8
 
-/*
- * Cap on the verbatim label text (e.g. "HEADER.FIELDS (DATE FROM)")
- * stashed on struct session's pending_header_label and echoed back as
- * "BODY[<label>]". Listener-local, since this text never crosses the
- * imsg boundary. Sized above HEADER_FIELDS_MAX (256, imapd.h) to cover
- * the wrapper text around the field-name list.
- */
+/* Cap on the verbatim label echoed back as "BODY[<label>]". Sized above */
+/* HEADER_FIELDS_MAX to cover the wrapper around the field-name list. */
 #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.
- */
+/* Worst case for quote_mailbox(): two DQUOTEs, a NUL, and a backslash */
+/* before every byte of a MBOX_NAME_MAX-1 name of quoted-specials. */
 #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.
- */
+/* RFC 7162 SS7's ceiling on a mod-sequence, a positive 63-bit integer. */
+/* The three client-facing parsers bound strtoull(3) by this; whether 0 */
+/* is legal varies by parser, and each 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
- * session_finish_search() also tests these bits.
- */
+/* RFC 9051 SS6.4.4 SEARCH result options, as ESEARCH return items. */
+/* SAVE ("$") is recognized but rejected with a flagged NO. */
 #define SEARCH_RETURN_MIN	(1U << 0)
 #define SEARCH_RETURN_MAX	(1U << 1)
 #define SEARCH_RETURN_ALL	(1U << 2)
@@ -169,531 +141,251 @@ 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 */
+	/* numeric "host:port", set once in listener_start_session() with */
+	/* NI_NUMERICHOST|NI_NUMERICSERV, so no resolver pledge is needed */
+	char			 remote_addr[64];
+	/* close-line username, cleared on auth failure; printable ASCII */
+	char			 user[AUTH_USERNAME_MAX];
+	/* CLOCK_MONOTONIC, so a duration cannot run backwards on a step */
+	struct timespec		 connected_at;
 	struct event		 client_ev;
 	enum session_state	 state;
-	int			 implicit_tls;	/* accepted on port 993, per
-						 * RFC 8314, vs. port 143,
-						 * where TLS only starts after
-						 * STARTTLS */
-	struct imsgev		*store_iev;	/* NULL until STORE_PENDING
-						 * resolves */
-	int			 client_ev_added; /* client_ev is only safe to
-						   * event_del() once this is
-						   * set, see listener_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
-						 * CAPABILITY/AUTHENTICATE */
-	struct tls		*tls_ctx;	/* NULL until tls_accept_socket();
-						 * non-NULL during handshake too */
-	int			 pending_greeting; /* implicit-TLS only: the
-						 * greeting is deferred until
-						 * the handshake completes (RFC
-						 * 8314 forbids protocol bytes
-						 * before TLS) */
+	int			 implicit_tls;	/* accepted on port 993 */
+	struct imsgev		*store_iev;	/* NULL until STORE_PENDING */
+	/* client_ev is only safe to event_del() once this is set */
+	int			 client_ev_added;
+	/* set by session_write() on write error or timeout; checked at the */
+	/* top of session_dispatch_client(), which tears down rather than */
+	/* read another command it could not answer */
+	int			 write_failed;
+	int			 tls_active;	/* 1 once TLS is up */
+	struct tls		*tls_ctx;	/* non-NULL during handshake */
+	/* implicit-TLS only: RFC 8314 forbids protocol bytes before TLS, so */
+	/* the greeting waits for the handshake */
+	int			 pending_greeting;
 	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
-						 * command; set by
-						 * cmd_authenticate(), cleared
-						 * by session_handle_auth_
-						 * continuation() */
-	int			 idling;	/* 1 while the next raw client
-						 * line is IDLE's "DONE" (RFC
-						 * 9051 SS6.3.13); same shape as
-						 * auth_cont, checked second in
-						 * the read loop */
-	char			*cmd_queue[SESSION_CMD_QUEUE_MAX]; /* malloc(3)'d
-						 * command lines pipelined while
-						 * session_is_busy() (RFC 9051
-						 * SS5.5). Entries 0..cmd_queue_n-1
-						 * are valid, always compacted;
-						 * session_enqueue_cmd()/
-						 * session_dequeue_next() manage
-						 * it. Bypassed entirely for
-						 * auth_cont/idling continuation
-						 * lines. */
-	uint32_t		 cmd_queue_n;	/* count of valid entries in
-						 * cmd_queue, 0..SESSION_CMD_
-						 * QUEUE_MAX */
-	char			 pending_tag[IMAP_TAG_MAX]; /* copy of whichever
-						 * command's tag is waiting on an
-						 * async imsg round trip, the
-						 * tag itself points into inbuf,
-						 * which gets overwritten by the
-						 * next read() before the reply
-						 * arrives. Only one round trip is
-						 * ever in flight per session, so
-						 * one field is enough. */
-	uint32_t		 fetch_attrs;	/* MBOX_FETCH_* bitmask for
-						 * the in-flight FETCH, needed
-						 * because imsg_mbox_fetch_meta's
-						 * fields are always populated by
-						 * store regardless of what was
-						 * requested */
-	char			*pending_header_buf; /* malloc(3)'d raw header
-						 * bytes from the most recent
-						 * IMSG_MBOX_FETCH_HEADER, held
-						 * until the following
-						 * IMSG_MBOX_FETCH_META folds it
-						 * into one FETCH response line;
-						 * freed and NULL'd there.
-						 * session_teardown() also frees
-						 * it defensively. */
-	uint32_t		 pending_header_len; /* valid only alongside
-						 * pending_header_buf != NULL */
-	int			 pending_header_found; /* 0 = this message's
-						 * header couldn't be read; 1 =
-						 * pending_header_buf/_len valid.
-						 * Distinguishes "requested but
-						 * none to give" from "not
-						 * requested". */
+	size_t			 inbuflen;	/* unparsed bytes in inbuf */
+	/* 1 when the line being consumed carried SASL credentials and must */
+	/* be explicit_bzero(3)'d out of inbuf once dispatched */
+	int			 scrub_inbuf;
+	/* 1 while the next raw line is a SASL continuation response */
+	int			 auth_cont;
+	/* 1 while the next raw line is IDLE's DONE (RFC 9051 SS6.3.13); */
+	/* same shape as auth_cont, checked second in the read loop */
+	int			 idling;
+	/* malloc(3)'d lines pipelined while session_is_busy() (RFC 9051 */
+	/* SS5.5). Entries 0..cmd_queue_n-1 valid, always compacted. */
+	/* Continuation lines bypass it entirely. */
+	char			*cmd_queue[SESSION_CMD_QUEUE_MAX];
+	uint32_t		 cmd_queue_n;
+	/* copy of the tag waiting on an async round trip: the tag itself */
+	/* points into inbuf, which the next read() overwrites. One field is */
+	/* enough, since only one round trip is in flight per session. */
+	char			 pending_tag[IMAP_TAG_MAX];
+	/* MBOX_FETCH_* bitmask for the in-flight FETCH, needed because the */
+	/* store populates imsg_mbox_fetch_meta regardless of what was asked */
+	uint32_t		 fetch_attrs;
+
+	/*
+	 * The pending_* stashes below each hold one store reply until the
+	 * following IMSG_MBOX_FETCH_META folds it into one FETCH response
+	 * line, which frees the malloc(3)'d ones. session_teardown() frees
+	 * them defensively too. Each _found flag distinguishes "asked for
+	 * and unavailable" from "not asked for"; each _len is valid only
+	 * while its buffer is non-NULL; each _label is echoed back verbatim
+	 * rather than reconstructed, since the response must repeat what the
+	 * client typed.
+	 */
+	char			*pending_header_buf;
+	uint32_t		 pending_header_len;
+	int			 pending_header_found;
 	char			 pending_header_label[HEADER_FIELDS_LABEL_MAX];
-						/* verbatim client-typed section
-						 * spec ("HEADER", or "HEADER.FIELDS
-						 * (DATE FROM)"), echoed as-is in
-						 * "BODY[<label>]" rather than
-						 * reconstructed. Set once in
-						 * fetch_dispatch(), read by
-						 * session_send_fetch_response();
-						 * fixed-size, no free needed. */
-	char			*pending_body_buf; /* same stash-until-next-
-						 * IMSG_MBOX_FETCH_META shape as
-						 * pending_header_buf, for
-						 * IMSG_MBOX_FETCH_BODY
-						 * (BODY.PEEK[]/[TEXT]) */
-	uint32_t		 pending_body_len; /* valid only alongside
-						 * pending_body_buf != NULL */
-	int			 pending_body_found; /* same found/not-found
-						 * distinction as pending_header_
-						 * found */
+	/* BODY[...] arrives as a read-only descriptor and an octet range */
+	int			 pending_body_fd;	/* -1 when none */
+	uint64_t		 pending_body_off;
+	uint64_t		 pending_body_len;
+	int			 pending_body_found;
 	char			 pending_body_label[SECTION_PART_MAX];
-						/* verbatim section text for
-						 * "BODY[<label>]", "" for
-						 * whole message, "TEXT", or a
-						 * dotted-numeric part path.
-						 * Set once in fetch_dispatch()
-						 * from whichever of WHOLE/TEXT/
-						 * PART attrs selects (same
-						 * precedence as store.c's
-						 * handle_mbox_fetch()). */
-	int			 pending_body_has_partial; /* 1 if the
-						 * client's BODY.PEEK[...] token
-						 * carried a <<start.count>>
-						 * suffix (RFC 9051 SS6.4.5). Only
-						 * the origin is echoed back, never
-						 * the count. */
+	/* 1 if the BODY.PEEK[...] token carried <<start.count>> (SS6.4.5); */
+	/* only the origin is echoed back, never the count */
+	int			 pending_body_has_partial;
 	uint32_t		 pending_body_partial_origin;
-	char			*pending_envelope_buf; /* same stash-until-
-						 * next shape as pending_header_buf,
-						 * for IMSG_MBOX_FETCH_ENVELOPE.
-						 * Holds already-formatted "(...)"
-						 * text, so session_send_fetch_
-						 * response() writes it directly
-						 * rather than wrapping it in a
-						 * literal. */
-	uint32_t		 pending_envelope_len; /* valid only
-						 * alongside pending_envelope_buf
-						 * != NULL */
-	int			 pending_envelope_found; /* same found/
-						 * not-found distinction as
-						 * pending_header_found */
-	char			*pending_bodystructure_buf; /* same shape as
-						 * pending_envelope_buf, for
-						 * IMSG_MBOX_FETCH_BODYSTRUCTURE */
-	uint32_t		 pending_bodystructure_len; /* valid only
-						 * alongside pending_
-						 * bodystructure_buf != NULL */
-	int			 pending_bodystructure_found; /* same found/
-						 * not-found distinction as
-						 * pending_header_found */
-	char			 pending_bodystructure_label[16]; /* "BODY"
-						 * or "BODYSTRUCTURE", both set
-						 * the same bit and produce
-						 * identical text, but the
-						 * response label must match what
-						 * was asked for */
-	int			 close_after_expunge; /* 1 if the in-flight
-						 * SESSION_EXPUNGING round trip
-						 * was started by CLOSE, not a
-						 * real EXPUNGE, both send the
-						 * identical imsg pair, so this is
-						 * the only way to tell which next
-						 * state applies: CLOSE ->
-						 * SESSION_AUTHENTICATED, EXPUNGE
-						 * -> SESSION_SELECTED */
-	int			 literal_pending; /* 1 while the next bytes off
-						 * the wire are a client
-						 * literal's raw octets (RFC 9051
-						 * SS4.3), not a CRLF-delimited
-						 * line, set by cmd_append() on
-						 * a trailing "{n}"/"{n+}",
-						 * cleared once literal_buf_len
-						 * reaches literal_len. Checked at
-						 * the top of session_dispatch_
-						 * client()'s read loop, before the
-						 * CRLF search, so no ST_* dispatch
-						 * exclusion is needed: session_
-						 * handle_line() is never reached
-						 * while it's set. */
-	char			*literal_buf;	/* malloc(3)'d accumulator for
-						 * the literal currently being
-						 * read, literal_len bytes, freed
-						 * once handed to session_finish_
-						 * append() (or on early teardown) */
-	uint64_t		 literal_len;	/* total announced literal size
-						 * ("{n}"), what literal_remaining
-						 * 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
-						 * is read, held here until
-						 * session_finish_append() builds
-						 * IMSG_MBOX_APPEND */
-	uint32_t		 append_sysflags;
-	char			 append_keywords[MBOX_FLAGS_MAX];
-	int			 append_has_date;
-	int64_t			 append_date;
-	enum session_state	 append_prev_state; /* SESSION_AUTHENTICATED
-						 * or SESSION_SELECTED, whichever
-						 * s->state was when cmd_append()
-						 * was called, so session_handle_
-						 * mbox_appended() knows which to
-						 * restore, unlike FETCH/STORE/
-						 * EXPUNGE, APPEND's return state
-						 * isn't fixed */
-	uint32_t		 status_attrs;	/* STATUS_ATT_* bitmask for the
-						 * in-flight STATUS, which
-						 * values to include, in
-						 * listener.c's fixed order; store.c
-						 * always computes MESSAGES/
-						 * UIDNEXT/UIDVALIDITY/
-						 * HIGHESTMODSEQ regardless */
-	char			 status_mailbox[MBOX_NAME_MAX]; /* mailbox
-						 * name STATUS was asked about,
-						 * stashed so session_handle_
-						 * mbox_status_result() can echo
-						 * it in the untagged response --
-						 * store.c's reply has no mailbox
-						 * field of its own */
-	enum session_state	 status_prev_state; /* SESSION_AUTHENTICATED
-						 * or SESSION_SELECTED, same
-						 * reasoning as append_prev_state */
-	enum session_state	 mbox_op_prev_state; /* SESSION_AUTHENTICATED
-						 * or SESSION_SELECTED, shared
-						 * across SESSION_CREATING/
-						 * DELETING/RENAMING/LISTING
-						 * (only one in flight at a time).
-						 * session_finish_mbox_op()/
-						 * session_finish_list() restore
-						 * it. */
-	char			 list_pattern[2 * MBOX_NAME_MAX]; /* canonical
-						 * LIST/LSUB pattern (reference +
-						 * mailbox pattern, concatenated
-						 * by list_dispatch()), tested
-						 * against each IMSG_MBOX_LIST_
-						 * ITEM name as it streams in --
-						 * unlike search_matches, nothing
-						 * needs accumulating first, since
-						 * each match becomes its
-						 * untagged response immediately */
-	int			 list_is_lsub;	/* 1 if the in-flight LISTING
-						 * round trip was started by LSUB
-						 * rather than LIST, picks the
-						 * untagged keyword and tagged
-						 * completion text */
-	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 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
-						 * include in the ESEARCH
-						 * response; store.c doesn't need
-						 * this, it only computes matches */
-	uint32_t		*search_matches; /* malloc(3)'d/realloc(3)'d,
-						 * growable, ascending sequence
-						 * numbers streamed via
-						 * IMSG_MBOX_SEARCH_MATCH, kept in
-						 * full (not range-compacted until
-						 * formatting) so MIN/MAX/COUNT/ALL
-						 * can all derive from one array.
-						 * Freed by session_finish_search()
-						 * or session_teardown(). */
-	uint32_t		 search_nmatches; /* entries actually in
-						 * search_matches */
-	uint32_t		 search_matches_cap; /* allocated capacity of
-						 * search_matches, >= search_
-						 * nmatches */
-	int			 search_alloc_failed; /* 1 if a realloc(3) in
-						 * session_handle_mbox_search_
-						 * match() ever failed, makes
-						 * session_finish_search() send
-						 * a NO instead of a silently
-						 * incomplete ESEARCH response */
-	int			 search_used_modseq; /* 1 if the in-flight
-						 * SEARCH program contained a
-						 * MODSEQ search-key (RFC 7162
-						 * SS3.1.5/SS3.1.8: a CONDSTORE-
-						 * enabling command); makes
-						 * session_finish_search() append
-						 * "(MODSEQ n)" using the running
-						 * max below */
-	uint64_t		 search_max_modseq; /* highest modseq seen
-						 * across matches of the
-						 * in-flight SEARCH; store.c
-						 * always sends modseq per match
-						 * (cheap), so this is just a
-						 * running max */
+	/* envelope and bodystructure hold already-formatted "(...)" text, */
+	/* written directly rather than wrapped in a literal */
+	char			*pending_envelope_buf;
+	uint32_t		 pending_envelope_len;
+	int			 pending_envelope_found;
+	char			*pending_bodystructure_buf;
+	uint32_t		 pending_bodystructure_len;
+	int			 pending_bodystructure_found;
+	/* "BODY" or "BODYSTRUCTURE": same bit, identical text, but the */
+	/* response label must match what was asked for */
+	char			 pending_bodystructure_label[16];
 
-	/* RFC 7162 (CONDSTORE/QRESYNC) session state. condstore_enabled/
-	 * qresync_enabled are sticky for the whole connection once set
-	 * (SS3.1, SS3.2.3), never cleared. qresync_enabled implies
-	 * condstore_enabled (SS3.2.3).
+	/*
+	 * 1 if a requested item could not be produced for some message, so
+	 * the tagged reply is NO (RFC 9051 SS6.4.5) rather than OK. Sticky
+	 * across the command, cleared when the FETCH is dispatched.
 	 */
+	int			 fetch_incomplete;
+
+	/* 1 if the in-flight SESSION_EXPUNGING round trip came from CLOSE */
+	/* rather than EXPUNGE. Both send the same imsg pair, so this is the */
+	/* only way to tell which next state applies. */
+	int			 close_after_expunge;
+
+	/* literal_pending: 1 while the next bytes are a client literal's */
+	/* raw octets (RFC 9051 SS4.3) rather than a CRLF line. Checked */
+	/* before the CRLF search, so session_handle_line() is never reached */
+	/* while it is set and no ST_* exclusion is needed. */
+	int			 literal_pending;
+	uint64_t		 literal_len;	/* announced size, "{n}" */
+	uint64_t		 literal_remaining;
+	/* octets of a NON-synchronizing literal whose command was refused */
+	/* or deferred: already on the wire, so they are swallowed rather */
+	/* than parsed as commands. Mutually exclusive with literal_pending. */
+	uint64_t		 literal_discard;
+
+	/* APPEND's target, for the EXISTS decision on the reply */
+	char			 append_mailbox[MBOX_NAME_MAX];
+	/* APPEND's return state is not fixed, unlike FETCH/STORE/EXPUNGE */
+	enum session_state	 append_prev_state;
+
+	/* STATUS_ATT_* bitmask for the in-flight STATUS, in listener.c's */
+	/* own fixed order; the store computes the cheap ones regardless */
+	uint32_t		 status_attrs;
+	/* stashed so the untagged response can echo it: the store's reply */
+	/* has no mailbox field of its own */
+	char			 status_mailbox[MBOX_NAME_MAX];
+	enum session_state	 status_prev_state;
+
+	/* shared across SESSION_CREATING/DELETING/RENAMING/LISTING and the */
+	/* two SUBSCRIBING states, since only one is in flight at a time */
+	enum session_state	 mbox_op_prev_state;
+	/* canonical LIST/LSUB pattern, tested against each list item as it */
+	/* streams in; nothing needs accumulating first */
+	char			 list_pattern[2 * MBOX_NAME_MAX];
+	int			 list_is_lsub;	/* LSUB, not LIST */
+	/* 1 if the in-flight LISTING asked the store for subscribed names */
+	/* rather than for what is on disk: LIST (SUBSCRIBED) or LSUB. */
+	/* Picks the attribute list on each untagged response. */
+	int			 list_subscribed_only;
+	/* the mailbox the in-flight CREATE/DELETE/RENAME/SUBSCRIBE/ */
+	/* UNSUBSCRIBE names (RENAME's source), compared against */
+	/* selected_mailbox when the terminal reply arrives: RENAME follows */
+	/* the selection to rename_newname, DELETE deselects. */
+	char			 mbox_op_name[MBOX_NAME_MAX];
+	char			 rename_newname[MBOX_NAME_MAX];
+
+	/* SEARCH_RETURN_* bitmask: which of MIN/MAX/ALL/COUNT the ESEARCH */
+	/* response includes. The store only computes matches. */
+	uint32_t		 search_return_opts;
+	/* growable ascending sequence numbers, kept in full rather than */
+	/* range-compacted so MIN/MAX/COUNT/ALL all derive from one array */
+	uint32_t		*search_matches;
+	uint32_t		 search_nmatches;
+	uint32_t		 search_matches_cap;
+	/* 1 if a realloc(3) failed: the response is refused rather than */
+	/* sent silently incomplete */
+	int			 search_alloc_failed;
+	/* 1 if the SEARCH program contained a MODSEQ key (RFC 7162 */
+	/* SS3.1.5), which appends "(MODSEQ n)" from the running max below */
+	int			 search_used_modseq;
+	uint64_t		 search_max_modseq;
+
+	/* RFC 7162 SS3.1/SS3.2.3: sticky for the whole connection once set, */
+	/* never cleared. qresync_enabled implies condstore_enabled. */
 	int			 condstore_enabled;
 	int			 qresync_enabled;
-
-	/* Best-known HIGHESTMODSEQ of the currently selected mailbox,
-	 * cached from every reply that carries one, regardless of whether
-	 * this session is CONDSTORE-aware yet. Exists for session_
-	 * condstore_enable()'s "enabling command issued while a mailbox
-	 * is already selected" case (RFC 7162 SS3.1 requires an
-	 * unsolicited HIGHESTMODSEQ OK at that moment).
-	 */
+	/* best-known HIGHESTMODSEQ of the selected mailbox, cached from */
+	/* every reply carrying one, for SS3.1's unsolicited OK when an */
+	/* enabling command arrives with a mailbox already selected */
 	uint64_t		 mbox_highestmodseq;
-
-	/*
-	 * RFC 9051 SS6.3.3: whether the selected mailbox was opened via
-	 * EXAMINE (1) rather than SELECT (0); meaningful while s->state ==
-	 * SESSION_SELECTED (and transiently during the SELECT/EXPUNGE
-	 * round trip). Set synchronously by select_or_examine() before the
-	 * round trip starts, since it's derived from the client's command,
-	 * not store.c's reply. Consulted for READ-ONLY/READ-WRITE/
-	 * PERMANENTFLAGS and by every command that would mutate the
-	 * mailbox's permanent state, to refuse per SS6.3.3 (with SS6.4.1's
-	 * CLOSE exception).
-	 */
+	/* RFC 9051 SS6.3.3: opened via EXAMINE rather than SELECT. Set */
+	/* synchronously, since it comes from the command not the reply. */
+	/* Consulted to refuse anything that would mutate permanent state. */
 	int			 mbox_readonly;
-	char			 selected_mailbox[MBOX_NAME_MAX]; /* which
-					 * mailbox SESSION_SELECTED refers to.
-					 * Written optimistically by select_
-					 * or_examine() when s->state moves to
-					 * SESSION_SELECTING, safe because
-					 * every reader also gates on s->state
-					 * == SESSION_SELECTED, and a failed
-					 * SELECT/EXAMINE never reaches that
-					 * state. Consulted by session_handle_
-					 * mbox_appended()'s "did APPEND
-					 * target the selected mailbox" check. */
+	/* which mailbox SESSION_SELECTED refers to. Written optimistically */
+	/* at SESSION_SELECTING; every reader also gates on SELECTED, which */
+	/* a failed SELECT never reaches. */
+	char			 selected_mailbox[MBOX_NAME_MAX];
 
 	/*
-	 * 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", 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()
-	 * compute correct seqno-based EXPUNGE lines for dropped UIDs.
-	 *
-	 * idle_refresh_pending/idle_refresh_again exist because a refresh
-	 * is itself an async round trip: if a second trigger arrives while
-	 * one is in flight, idle_refresh_again remembers to immediately
-	 * re-request once the in-flight reply lands, rather than blocking
-	 * or dropping the second trigger.
+	 * RFC 9051 SS6.3.13 IDLE: EXISTS, EXPUNGE and flag-change FETCH. The
+	 * UID list the client has been told about, and the mod-sequence it
+	 * has been told about, both live in the store child, which sends the
+	 * lines to print already worked out, so nothing about the mailbox is
+	 * kept here. idle_refresh_again remembers a
+	 * trigger that arrived while a refresh was in flight, and
+	 * idle_refresh_again_seed remembers that the folded-in trigger was a
+	 * seeding one.
 	 */
-	uint32_t		*idle_known_uids;
-	uint32_t		 idle_known_nuids;
-	uint32_t		 idle_known_cap;
-	int			 idle_baseline_valid;
 	int			 idle_refresh_pending;
 	int			 idle_refresh_again;
-
-	/*
-	 * 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.
-	 */
+	int			 idle_refresh_again_seed;
+	/* evtimer_set() once in listener_start_session() so evtimer_del() */
+	/* is always safe, then armed only between "+ idling" and DONE. An */
+	/* evtimer is one-shot, so session_idle_poll() re-arms itself. */
 	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
-	 * lists at once to diff them.
-	 */
-	uint32_t		*idle_incoming_uids;
-	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
-	 * resync. Already pre-compacted and ascending, collected here so
-	 * session_handle_mbox_selected() can join them into one combined
-	 * response line once IMSG_MBOX_SELECTED arrives. RFC 7162 SS3.2.6
-	 * requires VANISHED (EARLIER) to precede any FETCH in the same
-	 * resync, guaranteed only by holding everything until the terminal
-	 * reply.
+	 * evtimer_set() once in listener_start_session() so evtimer_del() is
+	 * always safe, then armed until the session authenticates. One shot:
+	 * it fires at most once, and firing tears the session down.
 	 */
+	struct event		 grace_ev;
+
+	/* VANISHED (EARLIER) ranges from a QRESYNC SELECT resync, already */
+	/* compacted and ascending, held until IMSG_MBOX_SELECTED so RFC */
+	/* 7162 SS3.2.6's VANISHED-before-FETCH ordering is guaranteed */
 	struct vanished_range	*vanished_ranges;
 	uint32_t		 vanished_nranges;
 	uint32_t		 vanished_cap;
-
-	/* Accumulates the QRESYNC resync's per-message FETCH-with-UID-and-
-	 * MODSEQ data, held back for the same VANISHED-before-FETCH
-	 * ordering reason as vanished_ranges. Holds full struct
-	 * imsg_mbox_fetch_meta copies (not just seqno/uid) since
-	 * session_send_qresync_fetch_response() needs FLAGS too.
-	 */
+	/* the resync's per-message FETCH data, held back for the same */
+	/* ordering reason. Full meta copies, since the response needs FLAGS. */
 	struct imsg_mbox_fetch_meta *qresync_fetches;
 	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.
-	 */
+	/* Set when either accumulator above dropped an entry. Nothing has */
+	/* reached the client yet, so the SELECT is failed: completing it */
+	/* would advertise a HIGHESTMODSEQ the client adopts as its 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
-	 * (src_uid/dest_uid, already ascending), range-compacted into
-	 * COPYUID's two UID sets once the terminal reply arrives.
-	 * cmd_is_move distinguishes COPY from MOVE (both share
-	 * SESSION_COPYING, differing only in terminal formatting).
-	 * move_expunged buffers each IMSG_MBOX_EXPUNGED from a MOVE rather
-	 * than writing it immediately, since SS6.4.8 requires COPYUID to
-	 * precede any EXPUNGE/VANISHED for the same operation.
-	 */
+	/* COPY/MOVE (RFC 9051 SS6.4.7/SS6.4.8), SESSION_COPYING. Two */
+	/* parallel ascending arrays, range-compacted into COPYUID's two UID */
+	/* sets once the terminal reply arrives. move_expunged buffers each */
+	/* EXPUNGED because SS6.4.8 requires COPYUID to precede them. */
 	uint32_t		*copy_src_uids;
 	uint32_t		*copy_dest_uids;
 	uint32_t		 copy_n;
 	uint32_t		 copy_cap;
-	int			 copy_alloc_failed; /* same reasoning as
-						 * s->search_alloc_failed */
+	int			 copy_alloc_failed;
 	int			 cmd_is_move;
 	struct imsg_mbox_expunged *move_expunged;
 	uint32_t		 move_expunged_n;
 	uint32_t		 move_expunged_cap;
 
-	/* Accumulates messages that failed a STORE's UNCHANGEDSINCE test
-	 * (IMSG_MBOX_STORE_MODIFIED), range-compacted by session_handle_
-	 * mbox_result() into the tagged response's MODIFIED code (RFC 7162
-	 * SS3.1.3). Holds sequence numbers for a plain STORE, UIDs for a
-	 * UID STORE, a single array, since only one is ever meaningful
-	 * per STORE (s->cmd_by_uid picks which).
-	 */
+	/* messages that failed a STORE's UNCHANGEDSINCE test, compacted */
+	/* into the tagged MODIFIED code (RFC 7162 SS3.1.3). Sequence */
+	/* numbers for STORE, UIDs for UID STORE; cmd_by_uid picks which. */
 	uint32_t		*store_modified;
 	uint32_t		 store_modified_n;
 	uint32_t		 store_modified_cap;
-	int			 store_modified_alloc_failed; /* 1 if a
-						 * realloc(3) in session_handle_
-						 * store_modified() ever failed,
-						 * or the response itself could
-						 * not be composed. RFC 7162
-						 * SS3.1.3 requires MODIFIED to
-						 * list ALL messages that failed
-						 * UNCHANGEDSINCE, and a client
-						 * never retries one it doesn't
-						 * see there, so an incomplete
-						 * set is refused rather than
-						 * sent -- same reasoning as
-						 * s->search_alloc_failed and
-						 * s->qresync_alloc_failed */
+	/* SS3.1.3 requires MODIFIED to list EVERY failure, and a client */
+	/* never retries one it cannot see, so an incomplete set is refused */
+	int			 store_modified_alloc_failed;
 
-	/*
-	 * RFC 9051 SS6.4.9 (UID command): 1 if the in-flight async
-	 * operation (FETCHING/STORING/SEARCHING/EXPUNGING) was dispatched
-	 * via UID <cmd> rather than the bare command. Every entry point
-	 * that starts one of those states sets this explicitly, so no
-	 * separate reset is needed.
-	 *
-	 * Consulted by session_send_store_fetch_response() (UID STORE's
-	 * FETCH echo must include UID), session_handle_mbox_search_match()/
-	 * session_finish_search() (UID SEARCH reports UIDs, plus the "UID"
-	 * ESEARCH correlator), session_handle_store_modified() (MODIFIED
-	 * lists UIDs for UID STORE), and session_handle_mbox_result()
-	 * (tagged completion text becomes "UID <CMD> completed"). Plain
-	 * FETCH doesn't need this, forcing MBOX_FETCH_UID into
-	 * s->fetch_attrs at dispatch time already handles it.
-	 */
+	/* RFC 9051 SS6.4.9: 1 if the in-flight operation was dispatched as */
+	/* UID <cmd>. Every entry point sets it explicitly, so it needs no */
+	/* reset. Picks UIDs over sequence numbers in SEARCH and MODIFIED, */
+	/* forces UID into a STORE's FETCH echo, and names the tagged reply. */
 	int			 cmd_by_uid;
 
 	TAILQ_ENTRY(session)	 entry;
 };
 
-/* Named (not anonymous) so the type matches between this extern
- * declaration and listener.c's definition, an anonymous
- * TAILQ_HEAD(, session) would be a redefinition error once split across
- * a header and a .c file.
- */
+/* Named, not anonymous: an anonymous TAILQ_HEAD would be a redefinition */
+/* error once the declaration and the definition are in separate files. */
 TAILQ_HEAD(session_list, session);
 
 /*
@@ -702,215 +394,220 @@ 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_search;	/* declared in listener.c */
 extern struct imsgev		 iev_parent;
 extern struct tls		*listener_tls_ctx;
 /* "idle poll" seconds, from IMSG_LISTENER_SESSION_INIT; 0 disables polling */
+/*
+ * RFC 9051 SS7.1 INUSE, the one spelling of "another session holds the
+ * mailbox's index lock", shared so every command that can be refused for
+ * it answers alike.
+ */
+#define IMAP_BUSY_TEXT	"[INUSE] mailbox busy, try again"
+
 extern uint32_t			 listener_idle_poll_secs;
+/* "login grace" seconds, from IMSG_LISTENER_SESSION_INIT; 0 disables it */
+extern uint32_t			 listener_login_grace_secs;
+/* "append max" octets, from IMSG_LISTENER_SESSION_INIT */
+extern uint64_t			 listener_append_max;
 
 /* 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_dispatch_auth(int, short, void *);
- void	 listener_dispatch_search(int, short, void *);
- void	 listener_dispatch_parent(int, short, void *);
- void	 session_dispatch_client(int, short, void *);
- void	 session_store_dispatch(int, short, void *);
- void	 session_handle_mbox_selected(struct session *,
+void	 listener_dispatch_auth(int, short, void *);
+void	 listener_dispatch_search(int, short, void *);
+void	 listener_dispatch_parent(int, short, void *);
+void	 session_dispatch_client(int, short, void *);
+void	 session_store_dispatch(int, short, void *);
+void	 session_handle_mbox_selected(struct session *,
 		    const struct imsg_mbox_selected *);
- void	 session_handle_mbox_status_result(struct session *,
+void	 session_handle_mbox_status_result(struct session *,
 		    const struct imsg_mbox_status_result *);
- void	 session_send_fetch_response(struct session *,
+void	 session_send_fetch_response(struct session *,
 		    struct imsg_mbox_fetch_meta *);
- void	 session_send_store_fetch_response(struct session *,
+void	 session_send_store_fetch_response(struct session *,
 		    const struct imsg_mbox_fetch_meta *);
- void	 session_send_expunge_response(struct session *,
+void	 session_send_expunge_response(struct session *,
 		    const struct imsg_mbox_expunged *);
- void	 session_handle_mbox_result(struct session *,
+void	 session_handle_mbox_result(struct session *,
 		    struct imsg_mbox_result *);
- int	 session_request_expunge(struct session *, const char *,
+int	 session_request_expunge(struct session *, const char *,
 		    int, int, const struct seq_range *, uint32_t);
- int	 session_finish_append(struct session *);
- void	 session_handle_mbox_appended(struct session *,
+int	 session_finish_append(struct session *);
+void	 session_handle_mbox_appended(struct session *,
 		    const struct imsg_mbox_appended *);
 /* Definition lives in fetch_cmd.c alongside format_internaldate();
  * append_cmd.c's parse_date_time() and search_cmd.c's parse_search_date()
  * reuse it for the reverse (name-to-index) direction.
  */
 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_sequence_set(const char *,
+void	 format_internaldate(int64_t, char *, size_t);
+int	 parse_nz_number(const char *, uint32_t *);
+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 *,
+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 **);
- int	 parse_header_fields_att(const char *, int *, char *, size_t);
- int	 section_part_valid(const char *);
- int	 parse_partial_suffix(const char *, int *, uint32_t *,
+int	 parse_header_fields_att(const char *, int *, char *, size_t);
+int	 section_part_valid(const char *);
+int	 parse_partial_suffix(const char *, int *, uint32_t *,
 		    uint32_t *);
- int	 parse_store_flags(char *, uint32_t *, char *, size_t,
+int	 parse_store_flags(char *, uint32_t *, char *, size_t,
 		    const char **);
- int	 parse_date_time(const char *, int64_t *);
+int	 parse_date_time(const char *, int64_t *);
 struct append_parsed;
- int	 parse_append_args(char *, struct append_parsed *,
+int	 parse_append_args(char *, struct append_parsed *,
 		    const char **);
- int	 parse_search_date(const char *, int64_t *);
+int	 parse_search_date(const char *, int64_t *);
 struct search_parse_ctx;
- int	 parse_search_key(char **, struct search_parse_ctx *,
+int	 parse_search_key(char **, struct search_parse_ctx *,
 		    const char **);
- int	 parse_search_key_inner(char **, struct search_parse_ctx *,
+int	 parse_search_key_inner(char **, struct search_parse_ctx *,
 		    const char **);
- int	 parse_search_key_list(char **, 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 *,
+int	 parse_search_return_opts(char **, uint32_t *, const char **);
+/* search_oracle_parse()'s prototype is in imapd.h, not here: */
+/* search_oracle.c is a separate role and must not pull in this */
+/* header's session declarations. */
+void	 session_handle_mbox_search_match(struct session *,
 		    struct imsg_mbox_search_match *);
- void	 session_finish_search(struct session *,
+void	 session_finish_search(struct session *,
 		    struct imsg_mbox_result *);
- void	 session_send_greeting(struct session *);
- void	 session_teardown(struct session *);
- struct session	*session_find(uint32_t);
+void	 session_send_greeting(struct session *);
+void	 session_teardown(struct session *, const char *);
+struct session	*session_find(uint32_t);
 
 /* RFC 7162 (CONDSTORE/QRESYNC) helpers, added this pass. */
- void	 session_condstore_enable(struct session *);
- size_t	 format_seq_list(char *, size_t, const uint32_t *, uint32_t,
+void	 session_condstore_enable(struct session *);
+size_t	 format_seq_list(char *, size_t, const uint32_t *, uint32_t,
 		    int *);
- size_t	 format_range_list(char *, size_t,
+size_t	 format_range_list(char *, size_t,
 		    const struct vanished_range *, uint32_t, int *);
- void	 session_send_qresync_fetch_response(struct session *,
+void	 session_send_qresync_fetch_response(struct session *,
 		    const struct imsg_mbox_fetch_meta *);
- void	 session_handle_select_vanished(struct session *,
+void	 session_handle_select_vanished(struct session *,
 		    const struct imsg_mbox_select_vanished *);
- void	 session_handle_select_fetch(struct session *,
+void	 session_handle_select_fetch(struct session *,
 		    const struct imsg_mbox_fetch_meta *);
- void	 session_handle_store_modified(struct session *,
+void	 session_handle_store_modified(struct session *,
 		    struct imsg_mbox_store_modified *);
- void	 session_handle_idle_uid(struct session *,
-		    const struct imsg_mbox_idle_uid *);
- void	 session_handle_idle_refreshed(struct session *,
+void	 session_handle_idle_expunge(struct session *,
+		    const struct imsg_mbox_idle_expunge *);
+void	 session_handle_idle_fetch(struct session *,
+		    const struct imsg_mbox_fetch_meta *);
+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);
- int	 session_handle_idle_continuation(struct session *, const char *);
- int	 parse_select_params(char *, struct imsg_mbox_select *,
+void	 session_request_idle_refresh(struct session *, int);
+void	 session_idle_poll_init(struct session *);
+void	 session_login_grace_init(struct session *);
+void	 session_login_grace_disarm(struct session *);
+void	 session_idle_poll_arm(struct session *);
+void	 session_idle_poll_disarm(struct session *);
+int	 session_handle_idle_continuation(struct session *, const char *);
+int	 parse_select_params(char *, struct imsg_mbox_select *,
 		    const struct session *, struct seq_range[SEQSET_MAX_RANGES],
 		    uint32_t *, int *, const char **);
- int	 parse_qresync_group(char *, struct imsg_mbox_select *,
+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 *,
+char	*split_trailing_modifiers(char *);
+int	 parse_fetch_modifiers(char *, struct imsg_mbox_fetch *,
 		    const struct session *, int, int *, const char **);
- int	 parse_store_modifiers(char *, struct imsg_mbox_store *,
+int	 parse_store_modifiers(char *, struct imsg_mbox_store *,
 		    const char **);
 
 /* RFC 9051 SS6.4.9 (UID command) helpers, added this pass. */
- 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);
+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 *,
+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 *,
+int	 uid_expunge_dispatch(struct session *, const char *, const char *);
+void	 session_handle_fetch_vanished(struct session *,
 		    const struct imsg_mbox_select_vanished *);
 
 /* RFC 9051 SS6.4.7/SS6.4.8 (COPY/MOVE) helpers, added this pass. */
- int	 copy_move_dispatch(struct session *, const char *, char *,
+int	 copy_move_dispatch(struct session *, const char *, char *,
 		    int, int);
- int	 listener_mailbox_name_valid(const char *);
- int	 listener_reject_bad_utf8(struct session *, const char *, const char *);
- int	 list_pattern_match(const char *, const char *, int);
- 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 *,
+int	 listener_mailbox_name_valid(const char *);
+int	 listener_reject_bad_utf8(struct session *, const char *, const char *);
+int	 list_pattern_match(const char *, const char *, int);
+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_finish_mbox_op(struct session *,
 		    const struct imsg_mbox_result *);
- void	 session_finish_list(struct session *,
+void	 session_finish_list(struct session *,
 		    const struct imsg_mbox_result *);
- void	 session_handle_mbox_list_item(struct session *,
+void	 session_handle_mbox_list_item(struct session *,
 		    const struct imsg_mbox_list_item *);
- void	 session_handle_mbox_copy_mapping(struct session *,
+void	 session_handle_mbox_copy_mapping(struct session *,
 		    const struct imsg_mbox_copy_mapping *);
- void	 session_finish_copy_or_move(struct session *,
+void	 session_finish_copy_or_move(struct session *,
 		    const struct imsg_mbox_result *);
 
- void	 session_tls_start(struct session *);
- void	 session_tls_handshake(int, short, void *);
- void	 session_arm_client_read(struct session *);
+void	 session_tls_start(struct session *);
+void	 session_tls_handshake(int, short, void *);
+void	 session_arm_client_read(struct session *);
 
- void	 session_write(struct session *, const char *, size_t);
- void	 session_reply(struct session *, const char *, const char *,
+void	 session_write(struct session *, const char *, size_t);
+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 *,
+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 *);
- int	 session_handle_auth_continuation(struct session *, const char *);
- int	 sasl_plain_finish(struct session *, const char *,
+int	 parse_command_line(char *, char **, char **, char **);
+int	 session_handle_line(struct session *, char *);
+int	 session_dequeue_next(struct session *);
+int	 session_handle_auth_continuation(struct session *, const char *);
+int	 sasl_plain_finish(struct session *, const char *,
 		    const char *, int);
 
- int	 cmd_capability(struct session *, const char *, char *);
- int	 cmd_noop(struct session *, const char *, char *);
- int	 cmd_logout(struct session *, const char *, char *);
- int	 cmd_id(struct session *, const char *, char *);
- int	 cmd_login(struct session *, const char *, char *);
- int	 cmd_starttls(struct session *, const char *, char *);
- int	 cmd_authenticate(struct session *, const char *, char *);
+int	 cmd_capability(struct session *, const char *, char *);
+int	 cmd_noop(struct session *, const char *, char *);
+int	 cmd_logout(struct session *, const char *, char *);
+int	 cmd_id(struct session *, const char *, char *);
+int	 cmd_login(struct session *, const char *, char *);
+int	 cmd_starttls(struct session *, const char *, char *);
+int	 cmd_authenticate(struct session *, const char *, char *);
 
-/* command-auth (RFC 9051 SS6.3, valid in Authenticated or Selected
- * state). stub_not_implemented() remains for any future command added
- * to the dispatch table before its handler is real, "recognized,
- * can't do it right now" (NO) is spec-meaningful, distinct from
- * "unknown command" (BAD). */
- int	 stub_not_implemented(struct session *, const char *,
+/* command-auth (RFC 9051 SS6.3, Authenticated or Selected state). */
+/* stub_not_implemented() remains for a command added to the dispatch */
+/* table before its handler is real: NO means "recognized, cannot do it */
+/* now", which is distinct from BAD. */
+int	 stub_not_implemented(struct session *, const char *,
 		    const char *);
- int	 cmd_enable(struct session *, const char *, char *);
- int	 cmd_select(struct session *, const char *, char *);
- int	 cmd_examine(struct session *, const char *, char *);
- int	 cmd_create(struct session *, const char *, char *);
- int	 cmd_delete(struct session *, const char *, char *);
- int	 cmd_rename(struct session *, const char *, char *);
- int	 cmd_subscribe(struct session *, const char *, char *);
- int	 cmd_unsubscribe(struct session *, const char *, char *);
- int	 cmd_list(struct session *, const char *, char *);
- int	 cmd_lsub(struct session *, const char *, char *);
- int	 cmd_namespace(struct session *, const char *, char *);
- int	 cmd_status(struct session *, const char *, char *);
- int	 cmd_append(struct session *, const char *, char *);
- int	 cmd_idle(struct session *, const char *, char *);
+int	 cmd_enable(struct session *, const char *, char *);
+int	 cmd_select(struct session *, const char *, char *);
+int	 cmd_examine(struct session *, const char *, char *);
+int	 cmd_create(struct session *, const char *, char *);
+int	 cmd_delete(struct session *, const char *, char *);
+int	 cmd_rename(struct session *, const char *, char *);
+int	 cmd_subscribe(struct session *, const char *, char *);
+int	 cmd_unsubscribe(struct session *, const char *, char *);
+int	 cmd_list(struct session *, const char *, char *);
+int	 cmd_lsub(struct session *, const char *, char *);
+int	 cmd_namespace(struct session *, const char *, char *);
+int	 cmd_status(struct session *, const char *, char *);
+int	 cmd_append(struct session *, const char *, char *);
+int	 cmd_idle(struct session *, const char *, char *);
 
 /* command-select (RFC 9051 SS6.4, valid only in Selected state). */
- int	 cmd_close(struct session *, const char *, char *);
- int	 cmd_unselect(struct session *, const char *, char *);
- int	 cmd_expunge(struct session *, const char *, char *);
- int	 cmd_search(struct session *, const char *, char *);
- int	 cmd_fetch(struct session *, const char *, char *);
- int	 cmd_store_cmd(struct session *, const char *, char *);
- int	 cmd_copy(struct session *, const char *, char *);
- int	 cmd_move(struct session *, const char *, char *);
- int	 cmd_uid(struct session *, const char *, char *);
+int	 cmd_close(struct session *, const char *, char *);
+int	 cmd_unselect(struct session *, const char *, char *);
+int	 cmd_expunge(struct session *, const char *, char *);
+int	 cmd_search(struct session *, const char *, char *);
+int	 cmd_fetch(struct session *, const char *, char *);
+int	 cmd_store_cmd(struct session *, const char *, char *);
+int	 cmd_copy(struct session *, const char *, char *);
+int	 cmd_move(struct session *, const char *, char *);
+int	 cmd_uid(struct session *, const char *, char *);
 
 #endif /* LISTENER_H */
blob - 775c41c683dd3c567bd827e802d85a74da8b772f
blob + 5fa792a8c473ea2d6ca2f3d1800138abfc52c3a6
--- src/log.c
+++ src/log.c
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  * Copyright (c) 2003, 2004 Henning Brauer <henning@openbsd.org>
@@ -43,7 +45,12 @@ static int	 log_foreground = 1;
 static int	 log_verbose = 0;
 static char	 log_procname[32] = "imapd";
 
-/* log_escape_ctl(): vis(3)-style caret/meta-escapes control bytes for the -d/stderr trace only (syslogd already escapes syslog output), done once here rather than at ~90 call sites; other high bytes and backslash pass through so UTF-8 and flag names stay legible and readable one-way. */
+/*
+ * log_escape_ctl(): vis(3)-style caret/meta-escapes control bytes for the
+ * -d/stderr trace only (syslogd already escapes syslog output), done once here
+ * rather than at ~90 call sites; other high bytes and backslash pass through so
+ * UTF-8 and flag names stay legible and readable one-way.
+ */
 static char *
 log_escape_ctl(const char *s)
 {
@@ -75,14 +82,34 @@ log_escape_ctl(const char *s)
 	return (out);
 }
 
+/* One write(2) per line: fprintf(3) may split one, and every */
+/* process in a -d run writes to this same stderr. */
+static void
+log_write_line(const char *line, size_t len)
+{
+	ssize_t	 n;
+
+	while (len > 0) {
+		if ((n = write(STDERR_FILENO, line, len)) == -1) {
+			if (errno == EINTR)
+				continue;
+			return;	/* nowhere left to report this */
+		}
+		line += n;
+		len -= (size_t)n;
+	}
+}
+
 void
 log_init(int foreground, int verbose)
 {
 	log_foreground = foreground;
 	log_verbose = verbose;
 
+	/* LOG_MAIL, as smtpd uses: a site's mail log rules catch both */
+	/* tag is the daemon name, not the role; vlog() adds the role */
 	if (!log_foreground)
-		openlog(log_procname, LOG_PID | LOG_NDELAY, LOG_DAEMON);
+		openlog("imapd", LOG_PID | LOG_NDELAY, LOG_MAIL);
 
 	tzset();
 }
@@ -90,7 +117,10 @@ log_init(int foreground, int verbose)
 void
 log_procinit(const char *name)
 {
-	/* truncation just shortens the prefix; name is argv[0], not attacker-influenced */
+	/*
+	 * truncation just shortens the prefix; name is argv[0], not attacker
+	 * input
+	 */
 	if (name != NULL)
 		(void)strlcpy(log_procname, name, sizeof(log_procname));
 }
@@ -113,25 +143,54 @@ vlog(int pri, const char *fmt, va_list ap)
 	int	 saved_errno = errno;
 
 	if (log_foreground) {
-		char	*msg = NULL, *safe = NULL;
+		char	*msg = NULL, *safe = NULL, *line = NULL;
 
-		/* Formats first via vasprintf(3) (untrusted text only arrives through `ap`, and truncation would lose part of a command echo) then escapes; reports allocation failure explicitly rather than falling back to an unescaped write. */
+		/*
+		 * Formats first via vasprintf(3) (untrusted text only arrives
+		 * through `ap`, and truncation would lose part of a command
+		 * echo) then escapes; reports allocation failure explicitly
+		 * rather than falling back to an unescaped write.
+		 */
 		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);
+		/* compose first, escape having already run: a newline */
+		/* added before log_escape_ctl() would come out "^J" */
+		/* free()d here, not below: asprintf(3) leaves *ret */
+		/* undefined on failure, so it is only ours on success */
+		if (safe != NULL && asprintf(&line, "%s: %s\n",
+		    log_procname, safe) != -1) {
+			log_write_line(line, strlen(line));
+			free(line);
+		} else {
+			char	 buf[128];
+			int	 len;
+
+			/* a stack buffer: this path must not allocate */
+			len = snprintf(buf, sizeof(buf), "%s: (message "
+			    "dropped: out of memory while logging)\n",
+			    log_procname);
+			if (len > 0)
+				log_write_line(buf, (size_t)len >= sizeof(buf) ?
+				    sizeof(buf) - 1 : (size_t)len);
+		}
 		free(safe);
 		free(msg);
-		fflush(stderr);
-	} else
-		/* no escaping here: syslogd(8) vis(3)-encodes everything it receives */
-		vsyslog(pri, fmt, ap);
+	} else {
+		char	*nfmt;
 
+		/* role prefix; the tag is the daemon name, see log_init() */
+		/* log_procname is main.c's fixed role text, safe as a format */
+		/* no escaping: syslogd(8) vis(3)-encodes all it receives */
+		if (asprintf(&nfmt, "%s: %s", log_procname, fmt) == -1)
+			vsyslog(pri, fmt, ap);
+		else {
+			vsyslog(pri, nfmt, ap);
+			free(nfmt);
+		}
+	}
+
 	errno = saved_errno;
 }
 
@@ -152,14 +211,23 @@ log_warn(const char *emsg, ...)
 	va_list	 ap;
 	int	 saved_errno = errno;
 
-	/* Uses saved_errno rather than bare errno since asprintf(3)/vfprintf(3)/free(3) can clobber it before it's restored for the caller, and since the asprintf-failure path below needs to log strerror() again after errno has already changed. */
+	/*
+	 * Uses saved_errno rather than bare errno since
+	 * asprintf(3)/vfprintf(3)/free(3) can clobber it before it's restored
+	 * for the caller, and since the asprintf-failure path below needs to
+	 * log strerror() again after errno has already changed.
+	 */
 	if (emsg == NULL)
 		logit(LOG_ERR, "%s", strerror(saved_errno));
 	else {
 		/* best-effort in appending strerror() after the format */
 		if (asprintf(&nfmt, "%s: %s", emsg,
 		    strerror(saved_errno)) == -1) {
-			/* On asprintf() failure, log the caller's message and the errno text on separate lines so the reason isn't lost just because asprintf() itself failed. */
+			/*
+			 * On asprintf() failure, log the caller's message and
+			 * the errno text on separate lines so the reason isn't
+			 * lost just because asprintf() itself failed.
+			 */
 			va_start(ap, emsg);
 			vlog(LOG_ERR, emsg, ap);
 			va_end(ap);
blob - e9c855b6203bc27068a064fdffffcec766212edb
blob + 035822f360ad2cff9af8122f82cf23d637164bc1
--- src/log.h
+++ src/log.h
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  * Copyright (c) 2003, 2004 Henning Brauer <henning@openbsd.org>
blob - 4b2d958666ef48c612a6dc7c94f1c41b61bf0e10
blob + e4b386b44a9a792c718718931b05f8fd535b9263
--- src/mailbox_cmd.c
+++ src/mailbox_cmd.c
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  *
@@ -14,7 +16,7 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* mailbox_cmd.c, SELECT/EXAMINE/CREATE/DELETE/RENAME/SUBSCRIBE/LIST/NAMESPACE/STATUS handlers. */
+/* mailbox_cmd.c: mailbox selection/management command handlers. */
 
 #include <sys/types.h>
 #include <sys/queue.h>
@@ -80,7 +82,11 @@ parse_qresync_group(char *inner, struct imsg_mbox_sele
 		*errmsg = "QRESYNC requires uidvalidity and mod-sequence";
 		return (-1);
 	}
-	/* RFC 7162 mod-sequence-value is 1..2^63-1; strtoull(3) accepts a leading sign so "-1" would parse as ULLONG_MAX, so enforce a leading-digit check plus the nonzero and 63-bit-ceiling requirements. */
+	/*
+	 * RFC 7162 mod-sequence-value is 1..2^63-1; strtoull(3) accepts a
+	 * leading sign so "-1" would parse as ULLONG_MAX, so enforce a
+	 * leading-digit check plus the nonzero and 63-bit-ceiling requirements.
+	 */
 	if (*tok < '0' || *tok > '9') {
 		*errmsg = "invalid QRESYNC mod-sequence";
 		return (-1);
@@ -117,7 +123,11 @@ parse_qresync_group(char *inner, struct imsg_mbox_sele
 	{
 		uint32_t	i;
 
-		/* known-uids is a full RFC 9051 sequence-set (SS3.2.5.1), parsed like any other sequence-set; only the "*" restriction below is specific to this call site. */
+		/*
+		 * known-uids is a full RFC 9051 sequence-set (SS3.2.5.1),
+		 * parsed like any other sequence-set; 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++) {
@@ -170,7 +180,7 @@ parse_qresync_group(char *inner, struct imsg_mbox_sele
 	return (0);
 }
 
-/* SELECT/EXAMINE select-params (RFC 4466 + RFC 7162 SS3.1.8/SS3.2.5 CONDSTORE/QRESYNC); p modified in place */
+/* SELECT/EXAMINE select-params (RFC 4466/7162); p modified in place */
 int
 parse_select_params(char *p, struct imsg_mbox_select *req,
     const struct session *s, struct seq_range ranges[SEQSET_MAX_RANGES],
@@ -233,7 +243,8 @@ parse_select_params(char *p, struct imsg_mbox_select *
 				return (-1);
 
 			req->qresync = 1;
-			*want_condstore = 1;	/* SS3.2.3: QRESYNC implies CONDSTORE */
+			/* SS3.2.3: QRESYNC implies CONDSTORE */
+			*want_condstore = 1;
 			p = end + 1;
 			continue;
 		}
@@ -245,7 +256,7 @@ parse_select_params(char *p, struct imsg_mbox_select *
 	return (0);
 }
 
-/* RFC 9051 SS6.3.2/SS6.3.3; shared by cmd_select()/cmd_examine(), only quoted-string mailbox form handled */
+/* RFC 9051 SS6.3.2/6.3.3; shared by SELECT/EXAMINE, quoted-string form only */
 static int
 select_or_examine(struct session *s, const char *tag, char *args, int readonly)
 {
@@ -254,7 +265,8 @@ select_or_examine(struct session *s, const char *tag, 
 	char				*params, *p;
 	const char			*errmsg = NULL;
 	int				 want_condstore = 0;
-	const char			*cmdname = readonly ? "EXAMINE" : "SELECT";
+	const char			*cmdname = readonly ? "EXAMINE" :
+	    "SELECT";
 	struct seq_range		 ranges[SEQSET_MAX_RANGES];
 	uint32_t			 nranges = 0;
 
@@ -267,7 +279,11 @@ select_or_examine(struct session *s, const char *tag, 
 		return (1);
 	}
 
-	/* Previously scanned the argument end and stripped quotes separately, so an unterminated quote left the leading DQUOTE attached and reached the store as a bogus mailbox name. */
+	/*
+	 * Previously scanned the argument end and stripped quotes separately,
+	 * so an unterminated quote left the leading DQUOTE attached and reached
+	 * the store as a bogus mailbox name.
+	 */
 	p = args;
 	if (parse_mailbox_name(&p, mailbox, sizeof(mailbox), &errmsg) == -1) {
 		session_reply(s, tag, "BAD", errmsg);
@@ -277,13 +293,17 @@ select_or_examine(struct session *s, const char *tag, 
 		p++;
 	params = (*p != '\0') ? p : NULL;
 
+	/* "" parses as RFC 9051 SS9 quoted, so this is NO, not BAD */
 	if (mailbox[0] == '\0') {
-		session_reply(s, tag, "BAD", "empty mailbox name");
+		session_reply(s, tag, "NO", "[NONEXISTENT] no such mailbox");
 		return (1);
 	}
 
 	if (s->store_iev == NULL) {
-		/* should already be wired here, ST_AUTH requires SESSION_AUTHENTICATED/SELECTED */
+		/*
+		 * should already be wired, ST_AUTH requires
+		 * SESSION_AUTHENTICATED/SELECTED
+		 */
 		log_warnx("session %u: %s with no store channel wired",
 		    s->id, cmdname);
 		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
@@ -315,14 +335,17 @@ select_or_examine(struct session *s, const char *tag, 
 		}
 	}
 
-	/* RFC 7162 SS3.1.8/SS3.2.3: not via session_condstore_enable(), SELECT's own reply has HIGHESTMODSEQ */
+	/*
+	 * RFC 7162 SS3.1.8/3.2.3: not session_condstore_enable(); has
+	 * HIGHESTMODSEQ
+	 */
 	if (want_condstore)
 		s->condstore_enabled = 1;
 
-	/* RFC 9051 SS6.3.2: SELECT auto-deselects the current mailbox with untagged OK [CLOSED], so its IDLE snapshot must go too, else a stale one gets diffed against the new mailbox and misreports expunges. */
+	/* RFC 9051 SS6.3.2: SELECT auto-deselects the current mailbox. */
 	if (s->state == SESSION_SELECTED)
-		session_untagged(s, "OK [CLOSED] Previous mailbox is now closed");
-	session_reset_idle_baseline(s);
+		session_untagged(s,
+		    "OK [CLOSED] Previous mailbox is now closed");
 
 	if (strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
 	    sizeof(s->pending_tag) ||
@@ -358,14 +381,26 @@ cmd_examine(struct session *s, const char *tag, char *
 	return select_or_examine(s, tag, args, 1);
 }
 
-/* Why the listener validates a mailbox name at all, given the store validates it again: answering BAD/NO here saves a round trip for a name that can never be valid. This used to be a hand-copied duplicate of store.c's rule and had already drifted once; both sides now call the one predicate in mboxname.c, and testing/mailbox_name_test.c drives both entry points over one table so a future one-sided edit fails there. */
+/*
+ * Why the listener validates a mailbox name at all, given the store validates
+ * it again: answering NO here saves a round trip for a name that can never be
+ * valid. This used to be a hand-copied duplicate of store.c's rule and had
+ * already drifted once; both sides now call the one predicate in mboxname.c,
+ * and testing/mailbox_name_test.c drives both entry points over one table so a
+ * future one-sided edit fails there.
+ */
 int
 listener_mailbox_name_valid(const char *name)
 {
 	return (mailbox_name_syntax_ok(name));
 }
 
-/* Shared refusal for a non-UTF-8 mailbox name on CREATE/RENAME-dest/COPY-MOVE-target; existing-name commands use NONEXISTENT instead, and NO (not BAD) matches SS6.3.4's CREATE failure wording and RFC 5530's CANNOT semantics. Returns 1 if it replied and the caller should stop. */
+/*
+ * Shared refusal for a non-UTF-8 mailbox name on
+ * CREATE/RENAME-dest/COPY-MOVE-target; existing-name commands use NONEXISTENT
+ * instead, and NO (not BAD) matches SS6.3.4's CREATE failure wording and RFC
+ * 5530's CANNOT semantics. Returns 1 if it replied and the caller should stop.
+ */
 int
 listener_reject_bad_utf8(struct session *s, const char *tag, const char *name)
 {
@@ -379,7 +414,7 @@ listener_reject_bad_utf8(struct session *s, const char
 	return (1);
 }
 
-/* RFC 9051 SS6.3.4 CREATE; "already exists" is store.c's call (mkdir(2) EEXIST, handle_mbox_create()) */
+/* RFC 9051 SS6.3.4 CREATE; "exists" is store.c's call (mbox_create() EEXIST) */
 int
 cmd_create(struct session *s, const char *tag, char *args)
 {
@@ -405,12 +440,17 @@ cmd_create(struct session *s, const char *tag, char *a
 	}
 	if (listener_reject_bad_utf8(s, tag, mailbox))
 		return (1);
+	/* a name the server refuses is RFC 5530 SS3 NO [CANNOT], not BAD */
 	if (!listener_mailbox_name_valid(mailbox)) {
-		session_reply(s, tag, "BAD", "invalid mailbox name");
+		session_reply(s, tag, "NO", "[CANNOT] invalid mailbox name");
 		return (1);
 	}
 
-	/* CREATE takes no arguments after the mailbox name; reject trailing garbage rather than silently ignoring it as `CREATE "a"b` used to. (Future CREATE-SPECIAL-USE, RFC 6154, would check here.) */
+	/*
+	 * CREATE takes no arguments after the mailbox name; reject trailing
+	 * garbage rather than silently ignoring it as `CREATE "a"b` used to.
+	 * (Future CREATE-SPECIAL-USE, RFC 6154, would check here.)
+	 */
 	while (*p == ' ')
 		p++;
 	if (*p != '\0') {
@@ -439,7 +479,13 @@ 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 leave s->state stuck at SESSION_CREATING, so session_is_busy() blocked forever waiting for a reply that would never come; reset state and answer instead, same fail-soft shape every other send_mbox_request() caller uses. The helper logs the failure itself. */
+	/*
+	 * A failed compose used to leave s->state stuck at SESSION_CREATING, so
+	 * session_is_busy() blocked forever waiting for a reply that would
+	 * never come; reset state and answer instead, same fail-soft shape
+	 * every other send_mbox_request() caller uses. The helper logs the
+	 * failure itself.
+	 */
 	if (!send_mbox_request(s, IMSG_MBOX_CREATE, "CREATE",
 	    "IMSG_MBOX_CREATE", &req, sizeof(req), NULL, 0, 0)) {
 		s->state = s->mbox_op_prev_state;
@@ -450,7 +496,7 @@ cmd_create(struct session *s, const char *tag, char *a
 	return (1);
 }
 
-/* RFC 9051 SS6.3.5 DELETE; same split as CREATE, existence is store.c's call (handle_mbox_delete()) */
+/* RFC 9051 SS6.3.5 DELETE; existence check is store.c's handle_mbox_delete() */
 int
 cmd_delete(struct session *s, const char *tag, char *args)
 {
@@ -475,12 +521,16 @@ cmd_delete(struct session *s, const char *tag, char *a
 		return (1);
 	}
 	if (!listener_mailbox_name_valid(mailbox)) {
-		/* RFC 5530 NONEXISTENT: an invalid name can never have existed */
+		/* RFC 5530 NONEXISTENT: an invalid name never existed */
 		session_reply(s, tag, "NO", "[NONEXISTENT] no such mailbox");
 		return (1);
 	}
 
-	/* DELETE takes no arguments after the mailbox name; reject trailing garbage rather than silently ignoring it as `DELETE "a"b` used to. (Future CREATE-SPECIAL-USE, RFC 6154, would check here.) */
+	/*
+	 * DELETE takes no arguments after the mailbox name; reject trailing
+	 * garbage rather than silently ignoring it as `DELETE "a"b` used to.
+	 * (Future CREATE-SPECIAL-USE, RFC 6154, would check here.)
+	 */
 	while (*p == ' ')
 		p++;
 	if (*p != '\0') {
@@ -520,7 +570,7 @@ cmd_delete(struct session *s, const char *tag, char *a
 	return (1);
 }
 
-/* RFC 9051 SS6.3.6 RENAME; renaming *from* INBOX refused client-side, per the RFC's sanctioned carve-out */
+/* RFC 9051 SS6.3.6 RENAME; *from* INBOX refused client-side, RFC-sanctioned */
 int
 cmd_rename(struct session *s, const char *tag, char *args)
 {
@@ -547,7 +597,10 @@ cmd_rename(struct session *s, const char *tag, char *a
 	}
 
 	if (mailbox_name_is_inbox(oldname)) {
-		/* RFC 9051 SS6.3.6 sanctions this refusal; RFC 5530 CANNOT is the closest fit */
+		/*
+		 * RFC 9051 SS6.3.6 sanctions this refusal; RFC 5530 CANNOT is
+		 * closest fit
+		 */
 		session_reply(s, tag, "NO", "[CANNOT] cannot rename INBOX");
 		return (1);
 	}
@@ -557,12 +610,18 @@ cmd_rename(struct session *s, const char *tag, char *a
 	}
 	if (listener_reject_bad_utf8(s, tag, newname))
 		return (1);
-	if (mailbox_name_is_inbox(newname) || !listener_mailbox_name_valid(newname)) {
-		session_reply(s, tag, "BAD", "invalid mailbox name");
+	/* a name the server refuses is RFC 5530 SS3 NO [CANNOT], not BAD */
+	if (mailbox_name_is_inbox(newname) ||
+	    !listener_mailbox_name_valid(newname)) {
+		session_reply(s, tag, "NO", "[CANNOT] invalid mailbox name");
 		return (1);
 	}
 
-	/* RENAME takes no arguments after the mailbox name; reject trailing garbage rather than silently ignoring it as `RENAME "a"b` used to. (Future CREATE-SPECIAL-USE, RFC 6154, would check here.) */
+	/*
+	 * RENAME takes no arguments after the mailbox name; reject trailing
+	 * garbage rather than silently ignoring it as `RENAME "a"b` used to.
+	 * (Future CREATE-SPECIAL-USE, RFC 6154, would check here.)
+	 */
 	while (*p == ' ')
 		p++;
 	if (*p != '\0') {
@@ -607,31 +666,131 @@ cmd_rename(struct session *s, const char *tag, char *a
 }
 
 
+/*
+ * RFC 9051 SS6.3.7 (SUBSCRIBE) and SS6.3.8 (UNSUBSCRIBE): one mailbox name,
+ * one terminal IMSG_MBOX_RESULT, same shape as CREATE.
+ */
+static int
+subscribe_dispatch(struct session *s, const char *tag, char *args,
+    int is_unsub)
+{
+	struct imsg_mbox_subscribe	 req;
+	char				 mailbox[MBOX_NAME_MAX];
+	char				*p;
+	const char			*errmsg = NULL;
+	const char			*cmdname;
+	const char			*imsgname;
+	char				 text[64];
+
+	cmdname = is_unsub ? "UNSUBSCRIBE" : "SUBSCRIBE";
+	imsgname = is_unsub ? "IMSG_MBOX_UNSUBSCRIBE" : "IMSG_MBOX_SUBSCRIBE";
+
+	if (args == NULL) {
+		snprintf(text, sizeof(text), "%s requires a mailbox name",
+		    cmdname);
+		session_reply(s, tag, "BAD", text);
+		return (1);
+	}
+
+	p = args;
+	if (parse_mailbox_name(&p, mailbox, sizeof(mailbox), &errmsg) == -1) {
+		session_reply(s, tag, "BAD", errmsg);
+		return (1);
+	}
+	if (listener_reject_bad_utf8(s, tag, mailbox))
+		return (1);
+
+	/*
+	 * INBOX is permanently subscribed: SS5.1 guarantees it exists and
+	 * nothing can delete it, so it is never named in the subscription
+	 * file. SUBSCRIBE is the no-op that succeeds (SS6.3.7 returns OK for
+	 * an already-subscribed name); UNSUBSCRIBE is the refusal SS6.3.8's
+	 * result table allows. Answered here rather than round-tripping,
+	 * since the store would reach these two answers from the same two
+	 * facts.
+	 */
+	if (mailbox_name_is_inbox(mailbox)) {
+		if (is_unsub) {
+			session_reply(s, tag, "NO",
+			    "[CANNOT] cannot unsubscribe INBOX");
+			return (1);
+		}
+		session_reply(s, tag, "OK", "SUBSCRIBE completed");
+		return (1);
+	}
+	/* a name the server refuses is RFC 5530 SS3 NO [CANNOT], not BAD */
+	if (!listener_mailbox_name_valid(mailbox)) {
+		session_reply(s, tag, "NO", "[CANNOT] invalid mailbox name");
+		return (1);
+	}
+
+	/* no arguments after the mailbox name, as CREATE also refuses */
+	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: %s with no store channel wired", s->id,
+		    cmdname);
+		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+		return (1);
+	}
+
+	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");
+		return (1);
+	}
+	s->mbox_op_prev_state = s->state;
+	s->state = is_unsub ? SESSION_UNSUBSCRIBING : SESSION_SUBSCRIBING;
+
+	/* see cmd_create()'s comment on this failure path */
+	if (!send_mbox_request(s, is_unsub ? IMSG_MBOX_UNSUBSCRIBE :
+	    IMSG_MBOX_SUBSCRIBE, cmdname, imsgname, &req, sizeof(req), NULL,
+	    0, 0)) {
+		s->state = s->mbox_op_prev_state;
+		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+		return (1);
+	}
+
+	return (1);
+}
+
+
 int
 cmd_subscribe(struct session *s, const char *tag, char *args)
 {
-	(void)args;
-	return stub_not_implemented(s, tag, "SUBSCRIBE");
+	return subscribe_dispatch(s, tag, args, 0);
 }
 
 
 int
 cmd_unsubscribe(struct session *s, const char *tag, char *args)
 {
-	(void)args;
-	return stub_not_implemented(s, tag, "UNSUBSCRIBE");
+	return subscribe_dispatch(s, tag, args, 1);
 }
 
-/* RFC 9051 SS6.3.9 wildcards, collapsed to "zero or more of anything", flat namespace has no delimiter */
+/* RFC 9051 SS6.3.9 wildcards, "zero+ of anything"; flat namespace, no delim */
 int
 list_pattern_match(const char *pat, const char *name, int ci)
 {
 	const char	*p = pat;
 	const char	*s = name;
-	const char	*star_p = NULL;	/* pat position just past the most recently seen wildcard run */
-	const char	*star_s = NULL;	/* name position that wildcard has absorbed through so far */
+	/* pat pos past most recent wildcard run */
+	const char	*star_p = NULL;
+	/* name pos wildcard absorbed through so far */
+	const char	*star_s = NULL;
 
-	/* iterative two-pointer glob(3)-style match, O(n*m); avoids the exponential blowup of naive backtracking */
+	/* iterative two-pointer glob(3) match, O(n*m); no backtrack blowup */
 	while (*s != '\0') {
 		if (*p == '*' || *p == '%') {
 			while (*p == '*' || *p == '%')
@@ -657,7 +816,13 @@ list_pattern_match(const char *pat, const char *name, 
 	return (*p == '\0');
 }
 
-/* Renders a mailbox name as an RFC 9051 SS4.3 quoted string (with DQUOTEs), escaping DQUOTE and backslash -- without this, a name containing those characters broke the client's parse of the response. out must be MBOX_QUOTED_MAX bytes; returns 0, or -1 (with a shorter well-formed result) if it didn't fit. */
+/*
+ * Renders a mailbox name as an RFC 9051 SS4.3 quoted string (with DQUOTEs),
+ * escaping DQUOTE and backslash -- without this, a name containing those
+ * characters broke the client's parse of the response. out must be
+ * MBOX_QUOTED_MAX bytes; returns 0, or -1 (with a shorter well-formed result)
+ * if it didn't fit.
+ */
 int
 quote_mailbox(char *out, size_t outsize, const char *name)
 {
@@ -674,7 +839,11 @@ quote_mailbox(char *out, size_t outsize, const char *n
 		char	c = name[i];
 		size_t	need;
 
-		/* SS4.3's TEXT-CHAR excludes NUL/CR/LF; substitute rather than drop, matching envbuf_append_nstring() -- a boundary guard since mailbox_name_valid() already refuses bytes below 0x20. */
+		/*
+		 * SS4.3's TEXT-CHAR excludes NUL/CR/LF; substitute rather than
+		 * drop, matching envbuf_append_nstring() -- a boundary guard
+		 * since mailbox_name_valid() already refuses bytes below 0x20.
+		 */
 		if (c == '\r' || c == '\n')
 			c = ' ';
 
@@ -694,7 +863,12 @@ quote_mailbox(char *out, size_t outsize, const char *n
 	return (0);
 }
 
-/* Distinguishes ASTRING-CHAR from list-char: both add resp-specials back, but only list-wildcards ("%","*") differ, mattering for LIST "" * ; deliberately lenient on 8-bit bytes (unquoted UTF-8 allowed) since only atom-specials actually corrupt parsing. */
+/*
+ * Distinguishes ASTRING-CHAR from list-char: both add resp-specials back, but
+ * only list-wildcards ("%","*") differ, mattering for LIST "" * ; deliberately
+ * lenient on 8-bit bytes (unquoted UTF-8 allowed) since only atom-specials
+ * actually corrupt parsing.
+ */
 static int
 atom_char_ok(unsigned char c, int wildcards)
 {
@@ -707,7 +881,13 @@ atom_char_ok(unsigned char c, int wildcards)
 	return (1);
 }
 
-/* The one mailbox-argument parser: pulls a decoded SS9 astring (or list-mailbox when wildcards) off *pp, replacing three divergent hand-written copies that mishandled quote-escaping and atom-specials; literals are refused outright since the listener's literal machinery is terminal-only; does not validate the name itself. Returns 0, or -1 with *errmsg set for a tagged BAD. */
+/*
+ * The one mailbox-argument parser: pulls a decoded SS9 astring (or list-mailbox
+ * when wildcards) off *pp, replacing three divergent hand-written copies that
+ * mishandled quote-escaping and atom-specials; literals are refused outright
+ * since the listener's literal machinery is terminal-only; does not validate
+ * the name itself. Returns 0, or -1 with *errmsg set for a tagged BAD.
+ */
 static int
 parse_mailbox_arg(char **pp, char *out, size_t outsize, int wildcards,
     const char **errmsg)
@@ -742,10 +922,16 @@ parse_mailbox_arg(char **pp, char *out, size_t outsize
 				break;
 			}
 			if (*p == '\\') {
-				/* SS9 QUOTED-CHAR escapes exactly the two quoted-specials, nothing else */
+				/*
+				 * 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 */
+					/*
+					 * a trailing backslash: the string ran
+					 * out, not a bad escape
+					 */
 					*errmsg = "unterminated quoted string";
 					return (-1);
 				}
@@ -784,7 +970,11 @@ parse_mailbox_arg(char **pp, char *out, size_t outsize
 	return (0);
 }
 
-/* Two grammars named explicitly rather than via a boolean: LIST's REFERENCE is a plain mailbox while its PATTERN allows wildcards (RFC 9051 SS9); every other mailbox-taking command uses the strict grammar. */
+/*
+ * Two grammars named explicitly rather than via a boolean: LIST's REFERENCE is
+ * a plain mailbox while its PATTERN allows wildcards (RFC 9051 SS9); every
+ * other mailbox-taking command uses the strict grammar.
+ */
 int
 parse_mailbox_name(char **pp, char *out, size_t outsize, const char **errmsg)
 {
@@ -797,10 +987,11 @@ parse_list_pattern(char **pp, char *out, size_t outsiz
 	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 */
+/* RFC 9051 SS6.3.9 basic syntax only, no list-opts; LSUB just varies output */
 static int
 list_dispatch(struct session *s, const char *tag, char *args, int is_lsub)
 {
+	struct imsg_mbox_list	 req;
 	char		 reference[MBOX_NAME_MAX];
 	char		 pattern[MBOX_NAME_MAX];
 	char		 canon[2 * MBOX_NAME_MAX];
@@ -809,6 +1000,7 @@ list_dispatch(struct session *s, const char *tag, char
 	const char	*cmdname = is_lsub ? "LSUB" : "LIST";
 	const char	*kw = is_lsub ? "LSUB" : "LIST";
 	char		 text[64];
+	int		 subscribed_only = 0;
 
 	if (args == NULL) {
 		snprintf(text, sizeof(text), "%s requires two arguments",
@@ -822,14 +1014,45 @@ list_dispatch(struct session *s, const char *tag, char
 		p++;
 
 	if (*p == '(') {
-		/* SS6.3.9 condition 1: first word after the command starts with "(", list-select-opts */
-		snprintf(text, sizeof(text),
-		    "extended %s selection options not supported", cmdname);
-		session_reply(s, tag, "NO", text);
-		return (1);
+		/*
+		 * SS6.3.9 cond 1: the first word after the command starts
+		 * "(", so it is list-select-opts. SUBSCRIBED (SS6.3.9.1) is
+		 * the only one implemented; REMOTE and RECURSIVEMATCH are
+		 * not, and quietly ignoring either would misreport the set
+		 * of names returned. LSUB has no such form at all (RFC 3501
+		 * SS6.3.9), so "(" there is a syntax error.
+		 */
+		if (is_lsub) {
+			session_reply(s, tag, "BAD",
+			    "LSUB takes no selection options");
+			return (1);
+		}
+		p++;
+		while (*p == ' ')
+			p++;
+		/* "()" is a legal empty option list, and asks for nothing */
+		if (strncasecmp(p, "SUBSCRIBED", 10) == 0 &&
+		    (p[10] == ')' || p[10] == ' ')) {
+			subscribed_only = 1;
+			p += 10;
+			while (*p == ' ')
+				p++;
+		}
+		if (*p != ')') {
+			snprintf(text, sizeof(text),
+			    "unsupported %s selection option", cmdname);
+			session_reply(s, tag, "NO", text);
+			return (1);
+		}
+		p++;
+		while (*p == ' ')
+			p++;
 	}
 
-	/* SS9: list's reference is a plain `mailbox`; only mbox-or-pat below takes wildcards */
+	/*
+	 * SS9: list's reference is a plain `mailbox`; mbox-or-pat takes
+	 * wildcards
+	 */
 	if (parse_mailbox_name(&p, reference, sizeof(reference), &errmsg) ==
 	    -1) {
 		session_reply(s, tag, "BAD", errmsg);
@@ -840,7 +1063,10 @@ list_dispatch(struct session *s, const char *tag, char
 		p++;
 
 	if (*p == '(') {
-		/* SS6.3.9 condition 2: second word starts with "(", parenthesized `patterns` form */
+		/*
+		 * SS6.3.9 cond 2: second word starts "(", parenthesized
+		 * `patterns` form
+		 */
 		snprintf(text, sizeof(text),
 		    "extended %s mailbox-pattern lists not supported",
 		    cmdname);
@@ -856,14 +1082,20 @@ list_dispatch(struct session *s, const char *tag, char
 	while (*p == ' ')
 		p++;
 	if (*p != '\0') {
-		/* SS6.3.9 condition 3: more than 2 parameters, trailing list-return-opts */
+		/*
+		 * SS6.3.9 condition 3: more than 2 parameters, trailing
+		 * list-return-opts
+		 */
 		snprintf(text, sizeof(text),
 		    "extended %s return options not supported", cmdname);
 		session_reply(s, tag, "NO", text);
 		return (1);
 	}
 
-	/* RFC 9051 SS6.3.9: empty pattern requests the delimiter/root; always empty root */
+	/*
+	 * RFC 9051 SS6.3.9: empty pattern requests delimiter/root; root is
+	 * empty
+	 */
 	if (pattern[0] == '\0') {
 		snprintf(text, sizeof(text), "%s (\\Noselect) \"/\" \"\"", kw);
 		session_untagged(s, text);
@@ -872,7 +1104,10 @@ list_dispatch(struct session *s, const char *tag, char
 		return (1);
 	}
 
-	/* canonical LIST pattern: reference concatenated with the mailbox pattern (RFC 9051 SS6.3.9) */
+	/*
+	 * canonical LIST pattern: reference + mailbox pattern (RFC 9051
+	 * SS6.3.9)
+	 */
 	{
 		size_t	n;
 
@@ -886,10 +1121,23 @@ 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 */
+	/*
+	 * SS6.3.9: unaccepted pattern MUST be ignored; INBOX answered
+	 * synchronously
+	 */
 	if (list_pattern_match(canon, "INBOX", 1)) {
-		/* "()" -- SS7.3.1 makes attributes optional, INBOX has none and is selectable; quoted here (though bare INBOX also parses) so this listener-only answer matches how SELECT's LIST line spells INBOX elsewhere, keeping one consistent spelling per session. */
-		snprintf(text, sizeof(text), "%s () \"/\" \"INBOX\"", kw);
+		/*
+		 * SS7.3.1 makes attributes optional and INBOX is selectable,
+		 * so "()" unless the client asked about subscription state:
+		 * INBOX is permanently subscribed here, since SS5.1
+		 * guarantees it exists and nothing can delete it. LSUB keeps
+		 * "()" because RFC 3501 has no \Subscribed. Quoted (though
+		 * bare INBOX also parses) so this listener-only answer
+		 * matches how SELECT's LIST line spells INBOX elsewhere,
+		 * keeping one consistent spelling per session.
+		 */
+		snprintf(text, sizeof(text), "%s (%s) \"/\" \"INBOX\"", kw,
+		    subscribed_only ? "\\Subscribed" : "");
 		session_untagged(s, text);
 	}
 
@@ -900,7 +1148,7 @@ list_dispatch(struct session *s, const char *tag, char
 		return (1);
 	}
 
-	/* real mailbox names live on disk; IMSG_MBOX_LIST has no payload, s->list_pattern is tested per streamed name */
+	/* real names on disk; MBOX_LIST has no payload, pattern per name */
 	if (strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
 	    sizeof(s->pending_tag) ||
 	    strlcpy(s->list_pattern, canon, sizeof(s->list_pattern)) >=
@@ -909,12 +1157,17 @@ list_dispatch(struct session *s, const char *tag, char
 		return (1);
 	}
 	s->list_is_lsub = is_lsub;
+	/* LSUB asks for subscribed names by definition (RFC 3501 SS6.3.9) */
+	s->list_subscribed_only = subscribed_only || is_lsub;
 	s->mbox_op_prev_state = s->state;
 	s->state = SESSION_LISTING;
 
-	/* see cmd_create()'s comment on this failure path; IMSG_MBOX_LIST carries no payload at all, so this is send_mbox_request()'s no-trailing-array form with a NULL request too */
+	memset(&req, 0, sizeof(req));
+	req.subscribed_only = s->list_subscribed_only;
+
+	/* see cmd_create()'s comment on this failure path */
 	if (!send_mbox_request(s, IMSG_MBOX_LIST, cmdname, "IMSG_MBOX_LIST",
-	    NULL, 0, NULL, 0, 0)) {
+	    &req, sizeof(req), NULL, 0, 0)) {
 		s->state = s->mbox_op_prev_state;
 		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
 		return (1);
@@ -937,7 +1190,7 @@ cmd_lsub(struct session *s, const char *tag, char *arg
 	return list_dispatch(s, tag, args, 1);
 }
 
-/* RFC 9051 SS6.3.10 NAMESPACE, answered locally: single Personal Namespace, no prefix, "/" delimiter */
+/* RFC 9051 SS6.3.10 NAMESPACE, answered locally: Personal NS, "/" delimiter */
 int
 cmd_namespace(struct session *s, const char *tag, char *args)
 {
@@ -947,7 +1200,7 @@ cmd_namespace(struct session *s, const char *tag, char
 	return (1);
 }
 
-/* RFC 9051 SS6.3.11 STATUS; doesn't change the selected mailbox, SESSION_STATUSING is a transient async wait */
+/* RFC 9051 SS6.3.11 STATUS; doesn't change mbox, STATUSING is transient wait */
 int
 cmd_status(struct session *s, const char *tag, char *args)
 {
@@ -1004,7 +1257,12 @@ cmd_status(struct session *s, const char *tag, char *a
 		else if (strcasecmp(tok, "HIGHESTMODSEQ") == 0)
 			attrs |= STATUS_ATT_HIGHESTMODSEQ;
 		else if (strcasecmp(tok, "RECENT") == 0)
-			/* IMAP4rev2 dropped RECENT (RFC 9051 SS2.3.2), but real clients (Canary Mail) still ask for it -- accept it and answer 0 rather than BAD-failing the whole command. */
+			/*
+			 * IMAP4rev2 dropped RECENT (RFC 9051 SS2.3.2), but real
+			 * clients (Canary Mail) still ask for it -- accept it
+			 * and answer 0 rather than BAD-failing the whole
+			 * command.
+			 */
 			attrs |= STATUS_ATT_RECENT;
 		else {
 			session_reply(s, tag, "BAD", "unknown status-att");
@@ -1012,15 +1270,19 @@ cmd_status(struct session *s, const char *tag, char *a
 		}
 	}
 
-	/* RFC 9051 SS9: request-side status-att-list requires at least one status-att, unlike the response side */
+	/*
+	 * RFC 9051 SS9: status-att-list needs >=1 status-att, unlike response
+	 * side
+	 */
 	if (attrs == 0) {
 		session_reply(s, tag, "BAD",
 		    "STATUS requires at least one status-att");
 		return (1);
 	}
 
-	if (!mailbox_name_is_inbox(mailbox) && !listener_mailbox_name_valid(mailbox)) {
-		/* RFC 5530 NONEXISTENT: a malformed name can never have existed */
+	if (!mailbox_name_is_inbox(mailbox) &&
+	    !listener_mailbox_name_valid(mailbox)) {
+		/* RFC 5530 NONEXISTENT: a malformed name never existed */
 		session_reply(s, tag, "NO", "[NONEXISTENT] no such mailbox");
 		return (1);
 	}
@@ -1032,7 +1294,10 @@ cmd_status(struct session *s, const char *tag, char *a
 		return (1);
 	}
 
-	/* RFC 7162 SS3.1: STATUS (HIGHESTMODSEQ) is CONDSTORE-enabling; called before s->state is overwritten */
+	/*
+	 * RFC 7162 SS3.1: STATUS HIGHESTMODSEQ is CONDSTORE-enabling;
+	 * pre-overwrite
+	 */
 	if (attrs & STATUS_ATT_HIGHESTMODSEQ)
 		session_condstore_enable(s);
 
@@ -1051,7 +1316,7 @@ 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; STATUS restores its own status_prev_state, not mbox_op_prev_state */
+	/* see cmd_create(); restores status_prev_state, not mbox_op */
 	if (!send_mbox_request(s, IMSG_MBOX_STATUS, "STATUS",
 	    "IMSG_MBOX_STATUS", &req, sizeof(req), NULL, 0, 0)) {
 		s->state = s->status_prev_state;
blob - 3fcd064ddce9640cd84f6e7c6ea2304f73b3760b
blob + ca9384ac7b6f9e505d2c2c19bd7e1ca7ccd0231a
--- src/main.c
+++ src/main.c
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  *
@@ -14,7 +16,7 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* main.c, imapd(8) entry point: parses argv, dispatches to the role named by "-x". */
+/* main.c: imapd(8) entry point, dispatches to the role named by "-x". */
 
 #include <sys/types.h>
 
@@ -46,26 +48,29 @@ log_procname(enum openimap_proc_type type)
 {
 	switch (type) {
 	case PROC_PARENT:
-		return "parent";
+		return ("parent");
 	case PROC_LISTENER:
-		return "listener";
+		return ("listener");
 	case PROC_AUTH:
-		return "auth";
+		return ("auth");
 	case PROC_STORE:
-		return "store";
+		return ("store");
 	case PROC_KEYMGR:
-		return "keymgr";
+		return ("keymgr");
 	case PROC_SEARCH:
-		return "search";
+		return ("search");
 	default:
-		return "?";
+		return ("?");
 	}
 }
 
 __dead static void
 usage(void)
 {
-	/* -x is deliberately absent from getopt, per imapd.8: it names a re-exec'd child's role, not an operator-facing option. */
+	/*
+	 * -x is absent from getopt (imapd.8): names a re-exec'd child's role
+	 * only.
+	 */
 	fprintf(stderr,
 	    "usage: %s [-dVv] [-D macro=value] [-f file]\n",
 	    getprogname());
@@ -85,17 +90,27 @@ main(int argc, char *argv[])
 
 	memset(&conf, 0, sizeof(conf));
 
-	/* Set before any fork(2) so SIG_IGN survives into every role (parent/listener/auth/store) via fork+execve -- this is the one place that reliably reaches all of them. */
+	/*
+	 * Set before any fork(2) so SIG_IGN survives into every role
+	 * (parent/listener/auth/store) via fork+execve -- this is the one place
+	 * that reliably reaches all of them.
+	 */
 	signal(SIGPIPE, SIG_IGN);
 
 	while ((ch = getopt(argc, argv, "D:df:Vvx:")) != -1) {
 		switch (ch) {
 		case 'V':
-			/* exits before log_init()/config_load(); must work with no config or privsep setup */
+			/*
+			 * exits before log_init()/config_load(); works with no
+			 * config/privsep
+			 */
 			printf("%s %s\n", getprogname(), IMAPD_VERSION);
 			return (0);
 		case 'D':
-			/* "-D name=value" macro, applied to parse.y's symbol table before config_load() */
+			/*
+			 * "-D name=value" macro, applied to parse.y's symtab
+			 * pre-config_load()
+			 */
 			if (cmdline_symset(optarg) == -1)
 				fatalx("could not parse macro definition %s",
 				    optarg);
@@ -129,32 +144,34 @@ main(int argc, char *argv[])
 			usage();
 	}
 
-	/* log_procinit() runs before log_init() because openlog(3) stores log_procname's pointer rather than a copy, so the role name must be set first or log.c would later mutate a string syslog(3) still holds. */
+	/* log_procinit() first, so nothing can log without a role */
 	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 */
+	/*
+	 * re-exec'd children get config via fd 3 (IMSG_*_INIT); no conf arg
+	 * needed
+	 */
 	switch (role) {
 	case PROC_PARENT:
+		/* before config_load(): imapd.conf is root-only */
+		/* else a non-root start reads as a config error */
+		if (geteuid() != 0)
+			fatalx("parent must start as root (running as "
+			    "uid %u)", (unsigned int)geteuid());
 		if (config_load(conffile, &conf) == -1)
 			fatalx("config_load: %s", conffile);
 		parent_main(conffile, argc, argv, &conf);
-		/* NOTREACHED */
 	case PROC_LISTENER:
 		listener_main();
-		/* NOTREACHED */
 	case PROC_AUTH:
 		auth_main();
-		/* NOTREACHED */
 	case PROC_STORE:
 		store_main();
-		/* NOTREACHED */
 	case PROC_KEYMGR:
 		keymgr_main();
-		/* NOTREACHED */
 	case PROC_SEARCH:
 		search_oracle_main();
-		/* NOTREACHED */
 	}
 
 	fatalx("unhandled role %d", role);
blob - eb4f077dea5b2e1e6ebfa58f99ab8275bb409e10
blob + 8eed34ba60988e1609e365ec12afb7c28e189a5c
--- src/mbox_copy.c
+++ src/mbox_copy.c
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  *
@@ -14,7 +16,7 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* mbox_copy.c: COPY and MOVE request handling, both same-mailbox and cross-mailbox. */
+/* mbox_copy.c: COPY and MOVE handling, same-mailbox and cross-mailbox. */
 
 #include <sys/types.h>
 #include <sys/file.h>
@@ -51,20 +53,27 @@ resolve_mailbox_target(const char *name, char *target,
 	return (-1);
 }
 
-/* Pass 1 of COPY/MOVE: stages messages read-only; 0 return means partial failure, nothing written yet (RFC 9051 SS6.4.7). */
+/* Pass 1 of COPY/MOVE: stages read-only; 0 means partial failure (SS6.4.7) */
 
 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)
+stage_copy_messages(int dfd, 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 */
+	/* bytes staged so far */
+	uint64_t		 staged_total = 0;
 	struct seq_range	 resolved[SEQSET_MAX_RANGES];
 	uint32_t		 nresolved, max_hi;
 
-	/* "*" and backwards-range swaps (RFC 9051 SS9) are handled by seqset_resolve(): seqno-space hi is clamped to idx->nlines, UID-space hi is left alone since an out-of-range UID just matches nothing extra. */
+	/*
+	 * "*" and backwards-range swaps (RFC 9051 SS9) are handled by
+	 * seqset_resolve(): seqno-space hi is clamped to idx->nlines, UID-space
+	 * hi is left alone since an out-of-range UID just matches nothing
+	 * extra.
+	 */
 	nresolved = seqset_resolve(ranges, nranges, req->by_uid ?
 	    index_max_uid(idx) : (uint32_t)idx->nlines, !req->by_uid,
 	    resolved);
@@ -83,7 +92,10 @@ stage_copy_messages(struct mbox_index *idx, struct ims
 		if (index_parse_line(idx->lines[i], &rec) == -1)
 			continue;
 
-		/* same range check as handle_mbox_fetch()/handle_mbox_store(); this loop is 0-based, so the seqno it passes is i + 1 */
+		/*
+		 * same check as handle_mbox_fetch()/_store(); 0-based loop,
+		 * seqno is i+1
+		 */
 		pos = seqset_position(resolved, nresolved, max_hi, req->by_uid,
 		    rec.uid, (uint32_t)(i + 1));
 		if (pos == SEQSET_PAST_END)
@@ -91,8 +103,8 @@ stage_copy_messages(struct mbox_index *idx, struct ims
 		if (pos == SEQSET_SKIP)
 			continue;
 
-		if (locate_message_file(rec.basename, &size, suffix,
-		    sizeof(suffix)) == -1) {
+		if (locate_message_file(mailbox_dir_fd, rec.basename,
+		    &size, suffix, sizeof(suffix)) == -1) {
 			log_warnx("session %u: COPY: message %s indexed but "
 			    "missing on disk, failing whole COPY (partial "
 			    "copy not permitted, RFC 9051 SS6.4.7)",
@@ -100,7 +112,10 @@ stage_copy_messages(struct mbox_index *idx, struct ims
 			goto fail;
 		}
 
-		/* refuse before allocating if this message or the running total exceeds the staging limits */
+		/*
+		 * refuse before allocating if message/running total exceeds
+		 * staging limits
+		 */
 		if ((uint64_t)size > COPY_STAGE_MSG_MAX) {
 			log_warnx("session %u: COPY: message %s is %lld bytes, "
 			    "over the per-message staging limit, failing COPY",
@@ -108,8 +123,9 @@ stage_copy_messages(struct mbox_index *idx, struct ims
 			goto fail;
 		}
 		if ((uint64_t)size > COPY_STAGE_TOTAL_MAX - staged_total) {
-			log_warnx("session %u: COPY: staged data would exceed the "
-			    "total staging limit, failing COPY", session_id);
+			log_warnx("session %u: COPY: staged data would "
+			    "exceed the total staging limit, failing "
+			    "COPY", session_id);
 			goto fail;
 		}
 		staged_total += (uint64_t)size;
@@ -122,7 +138,14 @@ stage_copy_messages(struct mbox_index *idx, struct ims
 			    "%s, failing COPY", session_id, rec.basename);
 			goto fail;
 		}
-		/* Moved here from commit_copy_messages()'s second loop, which used to fail a bad ':'/newline in keywords only after files were already renamed; checking before anything is written matches every other refusal in this function, and commit_copy_messages() keeps its own copy as defence in depth. */
+		/*
+		 * Moved here from commit_copy_messages()'s second loop, which
+		 * used to fail a bad ':'/newline in keywords only after files
+		 * were already renamed; checking before anything is written
+		 * matches every other refusal in this function, and
+		 * commit_copy_messages() keeps its own copy 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);
@@ -140,7 +163,11 @@ stage_copy_messages(struct mbox_index *idx, struct ims
 			    "long", session_id);
 			goto fail;
 		}
-		/* Same reasoning as the keywords check above; a failure here means a locally generated hostname or timestamp carries a ':' or newline. */
+		/*
+		 * Same reasoning as the keywords check above; a failure here
+		 * means a locally generated hostname or timestamp carries a ':'
+		 * or newline.
+		 */
 		if (!index_basename_valid(cs.basename)) {
 			log_warnx("session %u: COPY: generated basename is "
 			    "unsafe for the index, failing COPY", session_id);
@@ -154,7 +181,7 @@ stage_copy_messages(struct mbox_index *idx, struct ims
 			    "for %s", session_id, rec.basename);
 			goto fail;
 		}
-		if ((srcfd = open(path, O_RDONLY)) == -1) {
+		if ((srcfd = openat(dfd, path, O_RDONLY)) == -1) {
 			log_warn("session %u: COPY: open %s", session_id,
 			    path);
 			goto fail;
@@ -182,7 +209,16 @@ stage_copy_messages(struct mbox_index *idx, struct ims
 					goto fail;
 				}
 				if (n == 0) {
-					/* The file shrank between locate_message_file()'s stat and this read; copying the truncated prefix would answer OK for a partial message, violating RFC 9051 SS6.4.7's all-or-nothing COPY, so fail the whole operation like every other integrity failure here. */
+					/*
+					 * The file shrank between
+					 * locate_message_file()'s stat and this
+					 * read; copying the truncated prefix
+					 * would answer OK for a partial
+					 * message, violating RFC 9051 SS6.4.7's
+					 * all-or-nothing COPY, so fail the
+					 * whole operation like every other
+					 * integrity failure here.
+					 */
 					log_warnx("session %u: COPY: %s shrank "
 					    "during staging (%zu of %zu bytes "
 					    "read), failing COPY", session_id,
@@ -227,11 +263,11 @@ fail:
 	return (0);
 }
 
-/* Pass 2+3: commits staged messages via tmp/ write + rename into cur/, then index_append(); no rollback of already-renamed files on failure. */
+/* Pass 2+3: writes tmp/, renames to cur/, index_append(); no rollback. */
 
 static int
-commit_copy_messages(struct mbox_index *destidx, struct copy_staged *staged,
-    size_t nstaged, struct imsgev *iev)
+commit_copy_messages(int dfd, struct mbox_index *destidx,
+    struct copy_staged *staged, size_t nstaged, struct imsgev *iev)
 {
 	size_t	i;
 	size_t	ncommitted = 0;	/* entries whose file is already in cur/ */
@@ -247,7 +283,7 @@ commit_copy_messages(struct mbox_index *destidx, struc
 			    session_id);
 			goto rollback;
 		}
-		if ((tmpfd = open(tmppath, O_WRONLY | O_CREAT | O_EXCL,
+		if ((tmpfd = openat(dfd, tmppath, O_WRONLY | O_CREAT | O_EXCL,
 		    0600)) == -1) {
 			log_warn("session %u: COPY: open %s", session_id,
 			    tmppath);
@@ -266,7 +302,7 @@ commit_copy_messages(struct mbox_index *destidx, struc
 					log_warn("session %u: COPY: write %s",
 					    session_id, tmppath);
 					close(tmpfd);
-					unlink(tmppath);
+					unlinkat(dfd, tmppath, 0);
 					goto rollback;
 				}
 				written += (size_t)n;
@@ -283,13 +319,13 @@ commit_copy_messages(struct mbox_index *destidx, struc
 		    staged[i].basename, letters) >= (int)sizeof(curpath)) {
 			log_warnx("session %u: COPY: cur path too long",
 			    session_id);
-			unlink(tmppath);
+			unlinkat(dfd, tmppath, 0);
 			goto rollback;
 		}
-		if (rename(tmppath, curpath) == -1) {
+		if (renameat(dfd, tmppath, dfd, curpath) == -1) {
 			log_warn("session %u: COPY: rename %s -> %s",
 			    session_id, tmppath, curpath);
-			unlink(tmppath);
+			unlinkat(dfd, tmppath, 0);
 			goto rollback;
 		}
 		ncommitted = i + 1;
@@ -298,7 +334,10 @@ commit_copy_messages(struct mbox_index *destidx, struc
 	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. */
+		/*
+		 * stage_copy_messages() already refused these; kept as defence
+		 * in depth
+		 */
 		if (!index_basename_valid(staged[i].basename) ||
 		    !index_field_valid(staged[i].keywords)) {
 			log_warnx("session %u: COPY: unsafe field in staged "
@@ -335,7 +374,7 @@ commit_copy_messages(struct mbox_index *destidx, struc
 		}
 	}
 
-	if (index_save(destidx) == -1)
+	if (index_save(dfd, destidx) == -1)
 		goto rollback;
 
 	for (i = 0; i < nstaged; i++) {
@@ -353,7 +392,13 @@ commit_copy_messages(struct mbox_index *destidx, struc
 	return (1);
 
 rollback:
-	/* RFC 9051 SS6.4.7 forbids partial copy: a failure before index_save() leaves observable state untouched but orphans unlinked files (invisible but never reclaimed, leaking on retry), so undo the renames; an unlink failure here is logged and stepped over rather than aborting cleanup. */
+	/*
+	 * RFC 9051 SS6.4.7 forbids partial copy: a failure before index_save()
+	 * leaves observable state untouched but orphans unlinked files
+	 * (invisible but never reclaimed, leaking on retry), so undo the
+	 * renames; an unlink failure here is logged and stepped over rather
+	 * than aborting cleanup.
+	 */
 	while (ncommitted > 0) {
 		char	curpath[320];
 		char	letters[8];
@@ -365,24 +410,29 @@ rollback:
 		    staged[ncommitted].basename, letters) >=
 		    (int)sizeof(curpath))
 			continue;	/* couldn't have been created either */
-		if (unlink(curpath) == -1 && errno != ENOENT)
+		if (unlinkat(dfd, curpath, 0) == -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. */
+/*
+ * 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. Returns 1 locked and loaded; 0 failed, with any
+ * descriptor it opened closed; or 2 when a lock is busy, holding nothing,
+ * since the command is run again from the top (store.c).
+ */
 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,
+    int *destfd_out, 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;
+	int	firstfd, secondfd, first_is_dest, locked;
 
 	if (resolve_mailbox_target(destname, desttarget, desttargetlen) ==
 	    -1) {
@@ -391,10 +441,14 @@ lock_copy_move_mailboxes(const char *what, const char 
 		result->error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
 		return (0);
 	}
-	*cross_mailbox_out = (strcmp(saved, desttarget) != 0);
+	*cross_mailbox_out = (strcmp(selected_mailbox, desttarget) != 0);
 
 	if (!*cross_mailbox_out) {
-		if (index_lock_acquire(il_a, LOCK_EX) == -1)
+		locked = index_lock_acquire(mailbox_dir_fd, il_a,
+		    LOCK_EX | LOCK_NB);
+		if (locked == 1)
+			return (2);
+		if (locked == -1)
 			return (0);
 		if (index_load(il_a->fd, idx_a) == -1)
 			return (0);
@@ -403,79 +457,57 @@ lock_copy_move_mailboxes(const char *what, const char 
 		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);
+	if ((*destfd_out = mailbox_open_dir(desttarget)) == -1) {
+		log_debug("session %u: %s: no such mailbox %s", session_id,
+		    what, desttarget);
+		result->error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
 		return (0);
 	}
-	if (index_lock_acquire(il_a, LOCK_EX) == -1)
-		goto restore;
+
+	/*
+	 * Both indexes are locked at once, so take them in name order:
+	 * two sessions copying in opposite directions would otherwise
+	 * deadlock.
+	 */
+	first_is_dest = strcmp(selected_mailbox, desttarget) > 0;
+	firstfd = first_is_dest ? *destfd_out : mailbox_dir_fd;
+	secondfd = first_is_dest ? mailbox_dir_fd : *destfd_out;
+
+	locked = index_lock_acquire(firstfd, il_a, LOCK_EX | LOCK_NB);
+	if (locked != 0)
+		goto fail;
 	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;
+		goto fail;
+	locked = index_lock_acquire(secondfd, il_b, LOCK_EX | LOCK_NB);
+	if (locked != 0)
+		goto fail;
 	if (index_load(il_b->fd, idx_b) == -1)
-		goto restore;
+		goto fail;
 
 	*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);
+fail:
+	close(*destfd_out);
+	*destfd_out = -1;
+	if (locked == 1) {
+		/* busy: the first lock goes too, so a re-run starts clean */
+		index_lock_release(il_a);
+		index_free(idx_a);
+		memset(idx_a, 0, sizeof(*idx_a));
+		return (2);
+	}
 	return (0);
 }
 
-/* Common COPY/MOVE teardown: unlocks/closes the index fd(s), frees the index(es), and replies with IMSG_MBOX_RESULT. */
+/* Common COPY/MOVE teardown: unlocks/closes index fd(s), frees index(es) */
 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. */
+	/* both idempotent, safe on an INDEX_LOCK_INIT struct never acquired */
 	index_lock_release(il_a);
 	index_lock_release(il_b);
 
@@ -490,9 +522,9 @@ finish_copy_move(struct mbox_index *idx_a, struct mbox
 		    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. */
+/* RFC 9051 SS6.4.7 COPY; if dest != SELECTed, indexes lock in strcmp() order */
 
-void
+int
 handle_mbox_copy(struct imsg_mbox_copy *req, const struct seq_range *ranges,
     uint32_t nranges, struct imsgev *iev)
 {
@@ -504,48 +536,38 @@ handle_mbox_copy(struct imsg_mbox_copy *req, const str
 	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];
 	int				 cross_mailbox;
+	int				 destfd = -1, dfd, got;
 
 	memset(&idx_a, 0, sizeof(idx_a));
 	memset(&idx_b, 0, sizeof(idx_b));
 	memset(&result, 0, sizeof(result));
 
-	if (strlcpy(saved, current_mailbox_dir, sizeof(saved)) >=
-	    sizeof(saved)) {
-		log_warnx("session %u: COPY: current_mailbox_dir truncated, "
-		    "can't happen (same-size buffers)", session_id);
+	got = lock_copy_move_mailboxes("COPY", req->destname, &destfd,
+	    &idx_a, &idx_b, &srcidx, &destidx, desttarget,
+	    sizeof(desttarget), &cross_mailbox, &il_a, &il_b, &result);
+	if (got == 2)
+		return (1);
+	if (got == 0) {
 		ok = 0;
 		goto done;
 	}
+	dfd = cross_mailbox ? destfd : mailbox_dir_fd;
 
-	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)) {
+	if (!stage_copy_messages(mailbox_dir_fd, srcidx, req, ranges,
+	    nranges, &staged, &nstaged)) {
 		ok = 0;
-		goto done;
+		goto close_dest;
 	}
 
-	if (!stage_copy_messages(srcidx, req, ranges, nranges, &staged,
-	    &nstaged)) {
-		ok = 0;
-		goto restore_saved;
-	}
-
 	if (nstaged > 0) {
-		if (cross_mailbox && select_mailbox_dir(desttarget) == -1) {
-			log_warnx("session %u: COPY: couldn't reach "
-			    "destination %s", session_id, desttarget);
+		if (ensure_maildir_dirs(dfd, "") == -1) {
 			ok = 0;
-			goto restore_saved;
+			goto close_dest;
 		}
-		if (ensure_maildir_dirs("") == -1) {
+		if (!commit_copy_messages(dfd, destidx, staged, nstaged, iev))
 			ok = 0;
-			goto restore_saved;
-		}
-		if (!commit_copy_messages(destidx, staged, nstaged, iev))
-			ok = 0;
 	}
 
 	if (ok) {
@@ -554,10 +576,9 @@ handle_mbox_copy(struct imsg_mbox_copy *req, const str
 		result.highestmodseq = destidx->highestmodseq;
 	}
 
-restore_saved:
-	if (cross_mailbox && select_mailbox_dir(saved) == -1)
-		log_warnx("session %u: COPY: couldn't restore previously "
-		    "selected mailbox %s", session_id, saved);
+close_dest:
+	if (destfd != -1)
+		close(destfd);
 
 done:
 	for (i = 0; i < nstaged; i++)
@@ -565,9 +586,16 @@ done:
 	free(staged);
 
 	finish_copy_move(&idx_a, &idx_b, &il_a, &il_b, ok, &result, iev);
+	return (0);
 }
 
-/* Repairs move_same_mailbox()'s in-place compaction when it abandons partway: slides the unvisited tail down over the stale duplicate region left by the partial compaction and returns the new length, so index_free() doesn't double-free; the on-disk index is untouched since the caller never reaches index_save() on this path. */
+/*
+ * Repairs move_same_mailbox()'s in-place compaction when it abandons partway:
+ * slides the unvisited tail down over the stale duplicate region left by the
+ * partial compaction and returns the new length, so index_free() doesn't
+ * double-free; the on-disk index is untouched since the caller never reaches
+ * index_save() on this path.
+ */
 static size_t
 compaction_bail(struct mbox_index *idx, size_t dropped, size_t out)
 {
@@ -579,7 +607,10 @@ compaction_bail(struct mbox_index *idx, size_t dropped
 	return (out);
 }
 
-/* MOVE within same mailbox: range membership below must test "in" (read pos), not "out" (compaction write index), "out" under-consumed the range. */
+/*
+ * 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, const struct seq_range *ranges,
@@ -602,7 +633,10 @@ move_same_mailbox(struct imsg_mbox_copy *req, const st
 
 	*nmoved_out = 0;
 
-	/* "*" and backwards-range swaps (RFC 9051 SS9) are both handled by seqset_resolve() now. */
+	/*
+	 * "*"/backwards-range swaps (RFC 9051 SS9) 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);
@@ -711,7 +745,7 @@ move_same_mailbox(struct imsg_mbox_copy *req, const st
 		}
 	}
 
-	if (index_save(idx) == -1) {
+	if (index_save(mailbox_dir_fd, idx) == -1) {
 		ok = 0;
 		goto done;
 	}
@@ -728,7 +762,7 @@ move_same_mailbox(struct imsg_mbox_copy *req, const st
 			log_warn("session %u: imsg_compose "
 			    "IMSG_MBOX_COPY_MAPPING", session_id);
 	}
-	/* ...then EXPUNGE notices for the old UIDs/seqnos, per SS6.4.8's required ordering. */
+	/* ...then EXPUNGE notices for old UIDs/seqnos (RFC 9051 SS6.4.8) */
 	for (i = 0; i < nmoved; i++) {
 		struct imsg_mbox_expunged	 exp;
 
@@ -747,12 +781,12 @@ done:
 	return (ok);
 }
 
-/* Cross-mailbox MOVE (SS6.4.8): dest commit completes before source removal, so a crash can duplicate a message but never lose one. */
+/* Cross-mailbox MOVE (SS6.4.8): dest commits before removal, no lost mail */
 
 static int
 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,
+    int destfd, uint32_t *nmoved_out,
     struct imsgev *iev)
 {
 	struct copy_staged	*staged = NULL;
@@ -761,38 +795,23 @@ move_cross_mailbox(struct imsg_mbox_copy *req, const s
 
 	*nmoved_out = 0;
 
-	if (!stage_copy_messages(srcidx, req, ranges, nranges, &staged,
-	    &nstaged))
+	if (!stage_copy_messages(mailbox_dir_fd, srcidx, req, ranges,
+	    nranges, &staged, &nstaged))
 		return (0);
 
 	if (nstaged == 0)
 		return (1);
 
-	if (select_mailbox_dir(desttarget) == -1) {
-		log_warnx("session %u: MOVE: couldn't reach destination %s",
-		    session_id, desttarget);
+	if (ensure_maildir_dirs(destfd, "") == -1) {
 		ok = 0;
 		goto cleanup;
 	}
-	if (ensure_maildir_dirs("") == -1) {
+	if (!commit_copy_messages(destfd, destidx, staged, nstaged,
+	    iev)) {
 		ok = 0;
 		goto cleanup;
 	}
-	if (!commit_copy_messages(destidx, staged, nstaged, iev)) {
-		ok = 0;
-		goto cleanup;
-	}
 
-	/* destination commit is durable, now remove originals from source; cwd is at destination, return to saved dir first */
-	if (select_mailbox_dir(saved) == -1) {
-		log_warnx("session %u: MOVE: committed to destination but "
-		    "couldn't return to source %s to remove the originals "
-		    "-- message(s) now duplicated in both mailboxes rather "
-		    "than moved", session_id, saved);
-		ok = 0;
-		goto cleanup;
-	}
-
 	for (i = 0; i < nstaged; i++) {
 		struct index_rec	 rec;
 		size_t			 j, out;
@@ -801,7 +820,10 @@ move_cross_mailbox(struct imsg_mbox_copy *req, const s
 		off_t			 size;
 		char			 suffix[64], path[600];
 
-		/* fresh scan per message so old_seqno reflects state after each earlier removal */
+		/*
+		 * fresh scan per message so old_seqno reflects state after each
+		 * removal
+		 */
 		for (j = 0, out = 0; j < srcidx->nlines; j++) {
 			if (!found && index_parse_line(srcidx->lines[j],
 			    &rec) == 0 && rec.uid == staged[i].src_uid) {
@@ -820,12 +842,12 @@ move_cross_mailbox(struct imsg_mbox_copy *req, const s
 		}
 		any_removed = 1;
 
-		if (locate_message_file(rec.basename, &size, suffix,
-		    sizeof(suffix)) == 0) {
+		if (locate_message_file(mailbox_dir_fd, rec.basename,
+		    &size, suffix, sizeof(suffix)) == 0) {
 			if (snprintf(path, sizeof(path), "%s/%s%s",
 			    suffix[0] == '\0' ? "new" : "cur", rec.basename,
 			    suffix) < (int)sizeof(path))
-				unlink(path);
+				unlinkat(mailbox_dir_fd, path, 0);
 		}
 
 		{
@@ -841,11 +863,11 @@ move_cross_mailbox(struct imsg_mbox_copy *req, const s
 		}
 	}
 
-	/* RFC 7162 SS3.1: single per-operation HIGHESTMODSEQ bump, same as handle_mbox_expunge() */
+	/* RFC 7162 SS3.1: HIGHESTMODSEQ bump per op; see handle_mbox_expunge */
 	if (any_removed)
 		srcidx->highestmodseq++;
 
-	if (index_save(srcidx) == -1) {
+	if (index_save(mailbox_dir_fd, srcidx) == -1) {
 		log_warnx("session %u: MOVE: destination commit succeeded "
 		    "but saving the source's compacted index failed, "
 		    "message(s) may remain duplicated", session_id);
@@ -862,9 +884,9 @@ cleanup:
 	return (ok);
 }
 
-/* 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. */
+/* RFC 9051 SS6.4.8 MOVE dispatcher: locks index(es), hands off to helpers */
 
-void
+int
 handle_mbox_move(struct imsg_mbox_copy *req, const struct seq_range *ranges,
     uint32_t nranges, struct imsgev *iev)
 {
@@ -875,36 +897,31 @@ handle_mbox_move(struct imsg_mbox_copy *req, const str
 	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];
 	int				 cross_mailbox;
+	int				 destfd = -1, got;
 
 	memset(&idx_a, 0, sizeof(idx_a));
 	memset(&idx_b, 0, sizeof(idx_b));
 	memset(&result, 0, sizeof(result));
 
-	if (strlcpy(saved, current_mailbox_dir, sizeof(saved)) >=
-	    sizeof(saved)) {
-		log_warnx("session %u: MOVE: current_mailbox_dir truncated, "
-		    "can't happen (same-size buffers)", session_id);
+	got = lock_copy_move_mailboxes("MOVE", req->destname, &destfd,
+	    &idx_a, &idx_b, &srcidx, &destidx, desttarget,
+	    sizeof(desttarget), &cross_mailbox, &il_a, &il_b, &result);
+	if (got == 2)
+		return (1);
+	if (got == 0) {
 		ok = 0;
 		goto done;
 	}
 
-	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;
-	}
-
 	if (!cross_mailbox) {
 		if (!move_same_mailbox(req, ranges, nranges, srcidx, &nmoved,
 		    iev))
 			ok = 0;
 	} else {
 		if (!move_cross_mailbox(req, ranges, nranges, srcidx, destidx,
-		    desttarget, saved, &nmoved, iev))
+		    destfd, &nmoved, iev))
 			ok = 0;
 	}
 
@@ -914,10 +931,10 @@ handle_mbox_move(struct imsg_mbox_copy *req, const str
 		result.highestmodseq = destidx->highestmodseq;
 	}
 
-	if (cross_mailbox && select_mailbox_dir(saved) == -1)
-		log_warnx("session %u: MOVE: couldn't restore previously "
-		    "selected mailbox %s", session_id, saved);
+	if (destfd != -1)
+		close(destfd);
 
 done:
 	finish_copy_move(&idx_a, &idx_b, &il_a, &il_b, ok, &result, iev);
+	return (0);
 }
blob - 0eef36a8c22b55dc011e10ccafac378d8cf8bd30
blob + e7b9051e620318760b38289f0fe6823b5a4cea7e
--- src/mbox_fetch.c
+++ src/mbox_fetch.c
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  *
@@ -36,7 +38,34 @@
 #include "log.h"
 #include "store_internal.h"
 
-/* Shared compose-and-send helper for the four IMSG_MBOX_FETCH_{HEADER,BODY,ENVELOPE,BODYSTRUCTURE} messages handle_mbox_fetch() sends. */
+/*
+ * A FETCH walk that can stop and be resumed, so the store composes a
+ * bounded amount and lets it drain rather than building the whole reply
+ * first. Only FETCH is paced: it is the one walk carrying message
+ * bodies, where a large mailbox queues hundreds of megabytes, against
+ * single-digit megabytes of fixed-size records for SEARCH, STORE,
+ * EXPUNGE and COPY, whose loops are deliberately left as they are.
+ */
+static struct {
+	int			 active;
+	struct mbox_index	 idx;
+	struct imsg_mbox_fetch	 req;
+	struct seq_range	 resolved[SEQSET_MAX_RANGES];
+	uint32_t		 nresolved;
+	uint32_t		 max_hi;
+	uint32_t		 next;		/* index line to resume at */
+	uint32_t		 sent;
+	struct imsgev		*iev;
+} fetch_walk;
+
+/* Bytes and descriptors composed since the queue was last empty. */
+static size_t	 fetch_composed;
+static int	 fetch_fds;
+
+static void	 fetch_walk_step(void);
+static void	 fetch_walk_finish(int);
+
+/* Shared compose-and-send helper for the four IMSG_MBOX_FETCH_* messages */
 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,
@@ -55,48 +84,100 @@ fetch_send_part(struct imsgev *iev, int imsg_type, con
 	if (imsg_compose(&iev->ibuf, imsg_type, 0, 0, -1, combined,
 	    combined_len) == -1)
 		log_warn("session %u: imsg_compose %s", session_id, what);
+	else
+		fetch_composed += combined_len;
 	free(combined);
 }
 
-void
+/*
+ * Returns 0 having answered or started the walk, or 1 without answering
+ * because another session holds the index lock; store.c runs it again.
+ */
+int
 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;
+	struct mbox_index	*idx = &fetch_walk.idx;
 	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;
+	uint32_t		 i;
+	int			 locked;
 
-	memset(&idx, 0, sizeof(idx));
+	/* Before the reset below, so that a busy return changes nothing. */
+	locked = index_lock_acquire(mailbox_dir_fd, &il, LOCK_SH | LOCK_NB);
+	if (locked == 1)
+		return (1);
 
-	if (index_lock_acquire(&il, LOCK_SH) == -1) {
-		ok = 0;
-		goto done;
+	if (fetch_walk.active) {
+		/*
+		 * Unreachable: the listener holds a command until the one
+		 * in flight finishes (session_is_busy(), listener.c).
+		 * Abandoning the earlier walk rather than leaking its
+		 * snapshot is the defined answer if that ever changes.
+		 */
+		log_warnx("session %u: FETCH arrived during a paused FETCH, "
+		    "abandoning the earlier walk", session_id);
+		index_free(&fetch_walk.idx);
 	}
-	if (index_load(il.fd, &idx) == -1) {
+	memset(&fetch_walk, 0, sizeof(fetch_walk));
+	fetch_walk.iev = iev;
+	fetch_walk.req = *req;
+	fetch_walk.next = 1;
+	fetch_composed = 0;
+	fetch_fds = 0;
+
+	if (locked == -1) {
+		fetch_walk_finish(0);
+		return (0);
+	}
+	if (index_load(il.fd, idx) == -1) {
 		index_lock_release(&il);
-		ok = 0;
-		goto done;
+		fetch_walk_finish(0);
+		return (0);
 	}
 	index_lock_release(&il);
 
-	/* 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);
+	/* RFC 9051 SS6.4.9: UID FETCH set is UIDs; see index_max_uid() */
+	fetch_walk.nresolved = seqset_resolve(ranges, nranges, req->by_uid ?
+	    index_max_uid(idx) : (uint32_t)idx->nlines, !req->by_uid,
+	    fetch_walk.resolved);
+	fetch_walk.max_hi = seqset_max_hi(fetch_walk.resolved,
+	    fetch_walk.nresolved);
 
-	/* RFC 7162 SS3.2.6: VANISHED (EARLIER) MUST precede FETCH responses, guaranteed by send order here */
+	/*
+	 * RFC 7162 SS3.2.6: VANISHED (EARLIER) precedes FETCH, per send order
+	 * here
+	 */
 	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 = 0; i < fetch_walk.nresolved; i++)
+			send_vanished_range(idx, fetch_walk.resolved[i].lo,
+			    fetch_walk.resolved[i].hi, iev);
 	}
 
-	for (i = 1; i <= (uint32_t)idx.nlines; i++) {
+	fetch_walk.active = 1;
+	fetch_walk_step();
+	return (0);
+}
+
+/*
+ * Composes replies from fetch_walk.next and returns with the walk still
+ * active once FETCH_BATCH_MAX bytes are queued. Pausing is safe for the
+ * reason the walk was already safe: it runs on a private snapshot taken
+ * under the lock and released before the walk began, and it already
+ * handles a message that vanishes underneath. Pausing lengthens that
+ * window rather than opening it.
+ */
+static void
+fetch_walk_step(void)
+{
+	struct mbox_index	*idx = &fetch_walk.idx;
+	struct imsg_mbox_fetch	*req = &fetch_walk.req;
+	struct imsgev		*iev = fetch_walk.iev;
+	struct seq_range	*resolved = fetch_walk.resolved;
+	uint32_t		 nresolved = fetch_walk.nresolved;
+	uint32_t		 max_hi = fetch_walk.max_hi;
+	uint32_t		 i;
+
+	for (i = fetch_walk.next; i <= (uint32_t)idx->nlines; i++) {
 		struct imsg_mbox_fetch_meta	 meta;
 		struct index_rec		 rec;
 		enum seqset_pos			 pos;
@@ -104,10 +185,13 @@ handle_mbox_fetch(struct imsg_mbox_fetch *req, const s
 		char				 suffix[64];
 		int				 have_file = 0;
 
-		if (index_parse_line(idx.lines[i - 1], &rec) == -1)
+		if (index_parse_line(idx->lines[i - 1], &rec) == -1)
 			continue;
 
-		/* position-space (i) or UID-space (rec.uid) range check, per req->by_uid; single ascending pass, so PAST_END can stop it */
+		/*
+		 * position/UID-space range check, per by_uid; asc pass,
+		 * PAST_END stops it
+		 */
 		pos = seqset_position(resolved, nresolved, max_hi, req->by_uid,
 		    rec.uid, i);
 		if (pos == SEQSET_PAST_END)
@@ -124,8 +208,8 @@ handle_mbox_fetch(struct imsg_mbox_fetch *req, const s
 		meta.modseq = rec.modseq;
 
 		if (req->attrs & (MBOX_FETCH_RFC822_SIZE | MBOX_FETCH_FLAGS)) {
-			if (locate_message_file(rec.basename, &size, suffix,
-			    sizeof(suffix)) == -1) {
+			if (locate_message_file(mailbox_dir_fd, rec.basename,
+			    &size, suffix, sizeof(suffix)) == -1) {
 				log_warnx("session %u: message %s (uid %u) "
 				    "indexed but missing on disk, skipped",
 				    session_id, rec.basename, meta.uid);
@@ -143,7 +227,10 @@ handle_mbox_fetch(struct imsg_mbox_fetch *req, const s
 			meta.internaldate = parse_maildir_timestamp(
 			    rec.basename);
 
-		/* IMSG_MBOX_FETCH_HEADER precedes this message's own FETCH_META (listener.c depends on the order); always sent, found=0 on failure */
+		/*
+		 * FETCH_HEADER precedes FETCH_META (listener order); always
+		 * sent, found=0
+		 */
 		if (req->attrs & (MBOX_FETCH_BODY_HEADER |
 		    MBOX_FETCH_HEADER_FIELDS)) {
 			struct imsg_mbox_fetch_header	 hdrmeta;
@@ -155,11 +242,11 @@ handle_mbox_fetch(struct imsg_mbox_fetch *req, const s
 			hdrmeta.seqno = i;
 			hdrmeta.uid = rec.uid;
 			if (req->attrs & MBOX_FETCH_BODY_HEADER)
-				rc = read_message_header(rec.basename,
-				    &hdrbuf, &hdrlen);
+				rc = read_message_header(mailbox_dir_fd,
+				    rec.basename, &hdrbuf, &hdrlen);
 			else
-				rc = read_message_header_fields(rec.basename,
-				    req->header_fields,
+				rc = read_message_header_fields(mailbox_dir_fd,
+				    rec.basename, req->header_fields,
 				    req->header_fields_not, &hdrbuf, &hdrlen);
 			if (rc == 0) {
 				hdrmeta.found = 1;
@@ -172,24 +259,28 @@ handle_mbox_fetch(struct imsg_mbox_fetch *req, const s
 			free(hdrbuf);
 		}
 
-		/* IMSG_MBOX_FETCH_BODY, same always-sent/found=0/ordered-before-META contract as HEADER above; WHOLE wins over TEXT/PART */
+		/*
+		 * IMSG_MBOX_FETCH_BODY, same contract as HEADER; WHOLE wins
+		 * over TEXT/PART. It carries a descriptor and a range.
+		 */
 		if (req->attrs & (MBOX_FETCH_BODY_WHOLE | MBOX_FETCH_BODY_TEXT |
 		    MBOX_FETCH_BODY_PART)) {
 			struct imsg_mbox_fetch_body	 bodymeta;
-			char				*bodybuf = NULL;
-			uint32_t			 bodylen = 0;
+			uint64_t			 off = 0, len = 0;
+			int				 fd = -1, fdbusy = 0;
 			int				 want_whole =
 			    (req->attrs & MBOX_FETCH_BODY_WHOLE) != 0;
-			int				 want_part = !want_whole &&
+			int				 want_part =
+			    !want_whole &&
 			    (req->attrs & MBOX_FETCH_BODY_PART) != 0 &&
 			    !(req->attrs & MBOX_FETCH_BODY_TEXT);
-			int				 want_text = !want_whole &&
+			int				 want_text =
+			    !want_whole &&
 			    !want_part;
 
 			memset(&bodymeta, 0, sizeof(bodymeta));
 			bodymeta.seqno = i;
 			bodymeta.uid = rec.uid;
-			bodymeta.is_text = want_text;
 
 			if (want_part) {
 				int	path[MIME_MAX_DEPTH];
@@ -198,52 +289,66 @@ handle_mbox_fetch(struct imsg_mbox_fetch *req, const s
 				pathlen = parse_section_part(req->section_part,
 				    path, MIME_MAX_DEPTH);
 				if (pathlen != -1 &&
-				    extract_mime_part(rec.basename, path,
-				    pathlen, req->has_partial,
+				    extract_mime_part(mailbox_dir_fd,
+				    rec.basename, path, pathlen, &off,
+				    &len) == 0)
+					bodymeta.found = 1;
+				if (bodymeta.found && len > 0 &&
+				    (fd = open_message_file(mailbox_dir_fd,
+				    rec.basename)) == -1) {
+					fdbusy = errno == EMFILE ||
+					    errno == ENFILE;
+					bodymeta.found = 0;
+				}
+			} else {
+				fd = message_body_range(mailbox_dir_fd,
+				    rec.basename, want_text, &off, &len,
+				    &fdbusy);
+				if (fd != -1)
+					bodymeta.found = 1;
+			}
+
+			/*
+			 * Out of descriptors with some of this walk's in
+			 * flight: retry this message once they are sent,
+			 * rather than answering NO for a message that is
+			 * there. A resent HEADER replaces the pending one.
+			 */
+			if (fdbusy && fetch_fds > 0) {
+				fetch_walk.next = i;
+				return;
+			}
+
+			if (bodymeta.found) {
+				partial_range(req->has_partial,
 				    req->partial_start, req->partial_count,
-				    &bodybuf, &bodylen) == 0)
-					bodymeta.found = 1;
-			} else {
-				size_t		 read_cap = req->has_partial ?
-				    bodystructure_read_max : APPEND_LITERAL_MAX;
-				char		*wholebuf = NULL;
-				uint32_t	 wholelen = 0;
-
-				if (read_message_body(rec.basename, want_text,
-				    read_cap, want_text ? "BODY[TEXT]" :
-				    "BODY[]", &wholebuf, &wholelen) == 0) {
-					const char	*out;
-					size_t		 outlen;
-
-					apply_partial_range(wholebuf, wholelen,
-					    req->has_partial,
-					    req->partial_start,
-					    req->partial_count, &out, &outlen);
-					if (outlen == 0) {
-						bodymeta.found = 1;
-					} else if ((bodybuf = malloc(outlen)) ==
-					    NULL) {
-						log_warn("session %u: malloc "
-						    "IMSG_MBOX_FETCH_BODY "
-						    "slice (%s)", session_id,
-						    rec.basename);
-					} else {
-						memcpy(bodybuf, out, outlen);
-						bodylen = (uint32_t)outlen;
-						bodymeta.found = 1;
-					}
+				    &off, &len);
+				if (len == 0 && fd != -1) {
+					close(fd);
+					fd = -1;
 				}
-				free(wholebuf);
+				bodymeta.offset = off;
+				bodymeta.length = len;
 			}
-			bodymeta.bodylen = bodylen;
 
-			fetch_send_part(iev, IMSG_MBOX_FETCH_BODY,
-			    "IMSG_MBOX_FETCH_BODY", &bodymeta, sizeof(bodymeta),
-			    bodymeta.found, bodybuf, bodylen);
-			free(bodybuf);
+			/* imsg_compose() owns fd only once it succeeds */
+			if (imsg_compose(&iev->ibuf, IMSG_MBOX_FETCH_BODY, 0, 0,
+			    fd, &bodymeta, sizeof(bodymeta)) == -1) {
+				log_warn("session %u: imsg_compose "
+				    "IMSG_MBOX_FETCH_BODY", session_id);
+				if (fd != -1)
+					close(fd);
+			} else {
+				fetch_composed += sizeof(bodymeta);
+				if (fd != -1)
+					fetch_fds++;
+			}
 		}
 
-		/* IMSG_MBOX_FETCH_ENVELOPE, same contract as HEADER/BODY; trailing bytes are build_envelope()'s formatted text */
+		/*
+		 * FETCH_ENVELOPE, same contract as HEADER/BODY; bytes are
+		 * formatted text
+		 */
 		if (req->attrs & MBOX_FETCH_ENVELOPE) {
 			struct imsg_mbox_fetch_envelope	 envmeta;
 			char					*envbuf = NULL;
@@ -252,18 +357,23 @@ handle_mbox_fetch(struct imsg_mbox_fetch *req, const s
 			memset(&envmeta, 0, sizeof(envmeta));
 			envmeta.seqno = i;
 			envmeta.uid = rec.uid;
-			if (build_envelope(rec.basename, &envbuf, &envlen) == 0) {
+			if (build_envelope(mailbox_dir_fd, rec.basename,
+			    &envbuf, &envlen) == 0) {
 				envmeta.found = 1;
 				envmeta.envlen = envlen;
 			}
 
 			fetch_send_part(iev, IMSG_MBOX_FETCH_ENVELOPE,
-			    "IMSG_MBOX_FETCH_ENVELOPE", &envmeta, sizeof(envmeta),
+			    "IMSG_MBOX_FETCH_ENVELOPE", &envmeta,
+			    sizeof(envmeta),
 			    envmeta.found, envbuf, envlen);
 			free(envbuf);
 		}
 
-		/* IMSG_MBOX_FETCH_BODYSTRUCTURE, same contract and shape as IMSG_MBOX_FETCH_ENVELOPE above */
+		/*
+		 * IMSG_MBOX_FETCH_BODYSTRUCTURE, same contract/shape as
+		 * ENVELOPE above
+		 */
 		if (req->attrs & MBOX_FETCH_BODYSTRUCTURE) {
 			struct imsg_mbox_fetch_bodystructure	 bsmeta;
 			char					*bsbuf = NULL;
@@ -272,7 +382,8 @@ handle_mbox_fetch(struct imsg_mbox_fetch *req, const s
 			memset(&bsmeta, 0, sizeof(bsmeta));
 			bsmeta.seqno = i;
 			bsmeta.uid = rec.uid;
-			if (build_bodystructure(rec.basename, &bsbuf, &bslen) == 0) {
+			if (build_bodystructure(mailbox_dir_fd,
+			    rec.basename, &bsbuf, &bslen) == 0) {
 				bsmeta.found = 1;
 				bsmeta.bslen = bslen;
 			}
@@ -287,23 +398,85 @@ handle_mbox_fetch(struct imsg_mbox_fetch *req, const s
 		    &meta, sizeof(meta)) == -1)
 			log_warn("session %u: imsg_compose "
 			    "IMSG_MBOX_FETCH_META", session_id);
-		else
-			sent++;
+		else {
+			fetch_walk.sent++;
+			fetch_composed += sizeof(meta);
+		}
+
+		/*
+		 * Enough queued: stop and let it drain. The client reads
+		 * while the rest is still being built, and the store holds
+		 * FETCH_BATCH_MAX of a reply and FETCH_FD_MAX descriptors
+		 * rather than all of them.
+		 */
+		if (fetch_composed >= FETCH_BATCH_MAX ||
+		    fetch_fds >= FETCH_FD_MAX) {
+			fetch_walk.next = i + 1;
+			return;
+		}
 	}
+	fetch_walk_finish(1);
+}
 
-done:
-	index_free(&idx);
+/* Sends the terminal result and drops the walk's snapshots. */
+static void
+fetch_walk_finish(int ok)
+{
+	struct imsg_mbox_result	 result;
+	struct imsgev		*iev = fetch_walk.iev;
 
+	index_free(&fetch_walk.idx);
+	cur_snapshot_discard();
+
 	memset(&result, 0, sizeof(result));
 	result.error = ok ? MBOX_OP_OK : MBOX_OP_ERR_GENERIC;
-	result.count = sent;
+	result.count = fetch_walk.sent;
 	if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
 	    sizeof(result)) == -1)
 		log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
 		    session_id);
+	fetch_walk.active = 0;
+	fetch_walk.iev = NULL;
 }
 
-/* Inverse of build_flags_string()'s letter table: maps flag-suffix letters (e.g. "FS") back to the MBOX_FLAG_* bitmask. */
+/* 1 while a FETCH walk is part way through; store.c keeps its state. */
+int
+fetch_walk_paused(void)
+{
+	return (fetch_walk.active);
+}
+
+/*
+ * Continues a paused walk once everything it composed has gone out.
+ * Called from the store's EV_WRITE handler, the only thing that drains
+ * the queue. A paused walk always has a write pending to wake it, since
+ * imsg_compose() arms EV_WRITE while anything is queued (imsgev.c), so
+ * this cannot leave a client waiting for a reply that never resumes.
+ */
+void
+fetch_walk_resume(struct imsgev *iev)
+{
+	if (!fetch_walk.active || fetch_walk.iev != iev)
+		return;
+	if (imsgbuf_queuelen(&iev->ibuf) > 0)
+		return;
+	fetch_composed = 0;
+	fetch_fds = 0;
+	fetch_walk_step();
+}
+
+/* Drops a paused walk's snapshots when the child is told to exit. */
+void
+fetch_walk_abort(void)
+{
+	if (!fetch_walk.active)
+		return;
+	index_free(&fetch_walk.idx);
+	fetch_walk.active = 0;
+	fetch_walk.iev = NULL;
+}
+
+/* Inverse of build_flags_string()'s table: flag letters back to MBOX_FLAG_* */
 uint32_t
 letters_to_sysflags(const char *letters)
 {
@@ -322,7 +495,7 @@ letters_to_sysflags(const char *letters)
 	return (f);
 }
 
-/* Renders sysflags into maildir(5)'s required ASCII letter order: D, F, R, S, T. */
+/* Renders sysflags into maildir(5)'s ASCII letter order: D, F, R, S, T. */
 void
 sysflags_to_letters(uint32_t sysflags, char *out, size_t outsize)
 {
@@ -341,8 +514,8 @@ sysflags_to_letters(uint32_t sysflags, char *out, size
 	out[i] = '\0';
 }
 
-/* RFC 9051 SS6.3.11 STATUS; reuses SELECT's new/ scan so counts can't under-report. UNSEEN/DELETED/SIZE scan only if requested (SS6.3.11: expensive). */
-void
+/* RFC 9051 SS6.3.11 STATUS; reuses SELECT's scan; extras scan if requested */
+int
 handle_mbox_status(struct imsg_mbox_status *req, struct imsgev *iev)
 {
 	struct mbox_index		 idx;
@@ -350,19 +523,12 @@ handle_mbox_status(struct imsg_mbox_status *req, struc
 	struct index_lock		 il = INDEX_LOCK_INIT;
 	DIR				*dp;
 	struct dirent			*de;
-	char				 saved[MBOX_NAME_MAX];
 	const char			*target;
-	int				 switched = 0;
+	int				 tfd = -1, locked;
 
 	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 (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;
-		goto send;
-	}
+	/* RFC 9051 SS6.3.11: STATUS names a mailbox, not the selection */
 	if (mailbox_name_is_inbox(req->mailbox))
 		target = "";
 	else if (mailbox_name_valid(req->mailbox))
@@ -373,15 +539,19 @@ handle_mbox_status(struct imsg_mbox_status *req, struc
 		reply.error = MBOX_OP_ERR_GENERIC;
 		goto send;
 	}
-	if (select_mailbox_dir(target) == -1) {
+	if ((tfd = mailbox_open_dir(target)) == -1) {
 		log_debug("session %u: STATUS %s: no such mailbox",
 		    session_id, req->mailbox);
 		reply.error = MBOX_OP_ERR_GENERIC;
 		goto send;
 	}
-	switched = 1;
 
-	if (index_lock_acquire(&il, LOCK_EX) == -1) {
+	locked = index_lock_acquire(tfd, &il, LOCK_EX | LOCK_NB);
+	if (locked == 1) {
+		close(tfd);
+		return (1);
+	}
+	if (locked == -1) {
 		reply.error = MBOX_OP_ERR_GENERIC;
 		goto send;
 	}
@@ -392,12 +562,25 @@ handle_mbox_status(struct imsg_mbox_status *req, struc
 		goto send;
 	}
 
-	/* STATUS can be the first command to touch a mailbox without a prior SELECT, so it must persist any header index_load() just invented -- otherwise the UIDVALIDITY reported here won't match what the next caller sees. */
-	if (idx.fresh && index_save(&idx) == -1)
+	/*
+	 * STATUS can be the first command to touch a mailbox without a
+	 * prior SELECT, so it must persist any header index_load() just
+	 * invented -- otherwise the UIDVALIDITY reported here won't
+	 * match what the next caller sees.
+	 */
+	if (idx.fresh && index_save(tfd, &idx) == -1)
 		log_warnx("session %u: STATUS: could not persist the new "
 		    "index header", session_id);
 
-	dp = opendir("new");
+	{
+		int	newfd;
+
+		dp = NULL;
+		newfd = openat(tfd, "new",
+		    O_RDONLY | O_DIRECTORY);
+		if (newfd != -1 && (dp = fdopendir(newfd)) == NULL)
+			close(newfd);
+	}
 	if (dp == NULL) {
 		if (errno != ENOENT)
 			log_warn("session %u: opendir new", session_id);
@@ -420,7 +603,7 @@ handle_mbox_status(struct imsg_mbox_status *req, struc
 		closedir(dp);
 	}
 
-	if (index_save(&idx) == -1) {
+	if (index_save(tfd, &idx) == -1) {
 		index_free(&idx);
 		index_lock_release(&il);
 		reply.error = MBOX_OP_ERR_GENERIC;
@@ -446,8 +629,8 @@ handle_mbox_status(struct imsg_mbox_status *req, struc
 
 			if (index_parse_line(idx.lines[i], &rec) == -1)
 				continue;
-			if (locate_message_file(rec.basename, &size, suffix,
-			    sizeof(suffix)) == -1) {
+			if (locate_message_file(tfd, rec.basename,
+			    &size, suffix, sizeof(suffix)) == -1) {
 				log_warnx("session %u: message %s indexed but "
 				    "missing on disk, skipped for STATUS "
 				    "UNSEEN/DELETED/SIZE", session_id,
@@ -456,7 +639,8 @@ handle_mbox_status(struct imsg_mbox_status *req, struc
 			}
 
 			lp = strstr(suffix, "2,");
-			sysflags = letters_to_sysflags(lp != NULL ? lp + 2 : "");
+			sysflags = letters_to_sysflags(lp != NULL ?
+			    lp + 2 : "");
 			if (!(sysflags & MBOX_FLAG_SEEN))
 				reply.unseen++;
 			if (sysflags & MBOX_FLAG_DELETED)
@@ -469,13 +653,11 @@ handle_mbox_status(struct imsg_mbox_status *req, struc
 	index_lock_release(&il);
 
 send:
-	/* restore whatever this session had selected before STATUS (no-op if already there) */
-	if (switched && select_mailbox_dir(saved) == -1)
-		log_warnx("session %u: STATUS: failed to restore selection "
-		    "to %s, session may be left in an inconsistent state",
-		    session_id, saved[0] != '\0' ? saved : "INBOX");
+	if (tfd != -1)
+		close(tfd);
 	if (imsg_compose(&iev->ibuf, IMSG_MBOX_STATUS_RESULT, 0, 0, -1, &reply,
 	    sizeof(reply)) == -1)
 		log_warn("session %u: imsg_compose IMSG_MBOX_STATUS_RESULT",
 		    session_id);
+	return (0);
 }
blob - ae8fbfb76152385535d2ef3b6ad95205d9297596
blob + b29930cfcfcfffe8151e681d4f505d879b046a93
--- src/mbox_manage.c
+++ src/mbox_manage.c
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  *
@@ -36,7 +38,7 @@
 #include "log.h"
 #include "store_internal.h"
 
-void
+int
 handle_mbox_select(struct imsg_mbox_select *req,
     const struct seq_range *ranges, uint32_t nranges, struct imsgev *iev)
 {
@@ -44,9 +46,20 @@ handle_mbox_select(struct imsg_mbox_select *req,
 	struct imsg_mbox_selected reply;
 	struct index_lock	 il = INDEX_LOCK_INIT;
 	const char		*target;
+	int			 dfd, locked;
 
 	memset(&reply, 0, sizeof(reply));
 
+	/*
+	 * Invalidate index.c's IDLE state up front: a later poll would
+	 * otherwise report "unchanged" for a directory it never looked at,
+	 * and diff a new mailbox against the old one's UID list. The SS6.2
+	 * gate is cleared by the caller, which dispatches nothing else that
+	 * could depend on it in between (store.c).
+	 */
+	idle_probe_reset();
+	idle_baseline_reset();
+
 	if (mailbox_name_is_inbox(req->mailbox))
 		target = "";
 	else if (mailbox_name_valid(req->mailbox))
@@ -58,27 +71,58 @@ handle_mbox_select(struct imsg_mbox_select *req,
 		goto send;
 	}
 
-	if (select_mailbox_dir(target) == -1) {
+	if ((dfd = mailbox_open_dir(target)) == -1) {
 		log_debug("session %u: SELECT %s: no such mailbox",
 		    session_id, req->mailbox);
 		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 (index_lock_acquire(&il, LOCK_EX) == -1) {
+	/*
+	 * multiple store children/user; r-m-w needs cross-process mutual
+	 * exclusion
+	 */
+	locked = index_lock_acquire(dfd, &il, LOCK_EX | LOCK_NB);
+	if (locked == 1) {
+		/* only the IDLE resets have run, and a re-run repeats them */
+		close(dfd);
+		return (1);
+	}
+	if (locked == -1) {
+		close(dfd);
 		reply.error = MBOX_OP_ERR_GENERIC;
 		goto send;
 	}
 
-	if (refresh_index(&idx, il.fd) == -1) {
+	if (refresh_index(dfd, &idx, il.fd) == -1) {
 		index_lock_release(&il);
+		close(dfd);
 		reply.error = MBOX_OP_ERR_GENERIC;
 		goto send;
 	}
 
+	/*
+	 * Commit point: everything above leaves this session's own state
+	 * alone, so only a selection that has already succeeded moves the
+	 * descriptor, the name and the gate.
+	 */
+	if (mailbox_dir_fd != -1)
+		close(mailbox_dir_fd);
+	mailbox_dir_fd = dfd;
+
+	if (strlcpy(selected_mailbox, target, sizeof(selected_mailbox)) >=
+	    sizeof(selected_mailbox)) {
+		log_warnx("session %u: SELECT %s: name too long to "
+		    "record, can't happen (validated above, same-size "
+		    "buffers)", session_id, req->mailbox);
+		reply.error = MBOX_OP_ERR_GENERIC;
+		index_free(&idx);
+		index_lock_release(&il);
+		goto send;
+	}
+
 	reply.error = MBOX_OP_OK;
-	mailbox_selected = 1;	/* SS6.2's gate; see store_internal.h */
+	mailbox_selected = 1;	/* the store's gate; see store_internal.h */
 	reply.exists = (uint32_t)idx.nlines;
 	reply.uidvalidity = idx.uidvalidity;
 	reply.uidnext = idx.uidnext;
@@ -95,10 +139,11 @@ send:
 	    sizeof(reply)) == -1)
 		log_warn("session %u: imsg_compose IMSG_MBOX_SELECTED",
 		    session_id);
+	return (0);
 }
 
 int
-ensure_maildir_dirs(const char *prefix)
+ensure_maildir_dirs(int dfd, const char *prefix)
 {
 	static const char *dirs[] = { "tmp", "new", "cur" };
 	char	 path[MBOX_NAME_MAX + 8];
@@ -111,7 +156,7 @@ ensure_maildir_dirs(const char *prefix)
 			    session_id);
 			return (-1);
 		}
-		if (mkdir(path, 0700) == -1 && errno != EEXIST) {
+		if (mkdirat(dfd, path, 0700) == -1 && errno != EEXIST) {
 			log_warn("session %u: mkdir %s", session_id, path);
 			return (-1);
 		}
@@ -119,45 +164,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);
-}
 
-/* Saves the current selection and moves to the maildir root, the opening move of CREATE/DELETE/RENAME/LIST -- all of which operate on mailbox names relative to the root rather than on whatever a SELECT left the cwd at. Returns 0 with *saved holding the previous directory (restore it with select_mailbox_dir(saved), which each caller's own send: epilogue does differently), or -1 having logged. `subject` names the mailbox in the log and may be NULL where the command has none (LIST). handle_mbox_append() and handle_mbox_status() deliberately don't use this: both visit a specific target rather than the root, and both report a failure to get there as "no such mailbox" rather than as an internal error. */
-static int
-mbox_root_enter(char *saved, size_t savedsize, const char *what,
-    const char *subject)
-{
-	const char	*sep = subject != NULL ? " " : "";
-	const char	*who = subject != NULL ? subject : "";
+/* IMSG_MBOX_CREATE (RFC 9051 SS6.3.4); mkdir EEXIST means already exists */
 
-	if (save_current_mailbox_dir(saved, savedsize) == -1) {
-		log_warnx("session %u: %s%s%s: current_mailbox_dir truncated, "
-		    "can't happen (same-size buffers)", session_id, what, sep,
-		    who);
-		return (-1);
-	}
-	if (select_mailbox_dir("") == -1) {
-		log_warnx("session %u: %s%s%s: couldn't reach maildir root",
-		    session_id, what, sep, who);
-		return (-1);
-	}
-	return (0);
-}
-
-/* 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
 handle_mbox_create(struct imsg_mbox_create *req, struct imsgev *iev)
 {
 	struct imsg_mbox_result	 result;
 	char				 prefix[MBOX_NAME_MAX + 1];
-	char				 saved[MBOX_NAME_MAX];
-	int				 switched = 0;
 
 	memset(&result, 0, sizeof(result));
 
@@ -169,15 +183,8 @@ handle_mbox_create(struct imsg_mbox_create *req, struc
 		goto send;
 	}
 
-	/* mkdir/ensure_maildir_dirs() below need cwd at maildir root, not wherever a SELECTed mailbox left it, visit root first */
-	if (mbox_root_enter(saved, sizeof(saved), "CREATE",
-	    req->mailbox) == -1) {
-		result.error = MBOX_OP_ERR_GENERIC;
-		goto send;
-	}
-	switched = 1;
 
-	if (mkdir(req->mailbox, 0700) == -1) {
+	if (mkdirat(maildir_root_fd, req->mailbox, 0700) == -1) {
 		if (errno != EEXIST) {
 			log_warn("session %u: CREATE: mkdir %s", session_id,
 			    req->mailbox);
@@ -185,16 +192,23 @@ handle_mbox_create(struct imsg_mbox_create *req, struc
 		} else {
 			log_debug("session %u: CREATE %s: already exists",
 			    session_id, req->mailbox);
-			/* RFC 5530 SS3 ALREADYEXISTS: this is its own worked example */
+			/*
+			 * RFC 5530 SS3 ALREADYEXISTS: this is its own worked
+			 * example
+			 */
 			result.error = MBOX_OP_ERR_ALREADY_EXISTS;
 		}
 		goto send;
 	}
 
 	if (snprintf(prefix, sizeof(prefix), "%s/", req->mailbox) >=
-	    (int)sizeof(prefix) || ensure_maildir_dirs(prefix) == -1) {
-		/* best-effort cleanup so a half-initialized mailbox doesn't get stuck (mkdir would now see EEXIST) */
-		rmdir(req->mailbox);
+	    (int)sizeof(prefix) ||
+	    ensure_maildir_dirs(maildir_root_fd, prefix) == -1) {
+		/*
+		 * best-effort cleanup so a half-init mailbox isn't stuck
+		 * (EEXIST later)
+		 */
+		unlinkat(maildir_root_fd, req->mailbox, AT_REMOVEDIR);
 		result.error = MBOX_OP_ERR_GENERIC;
 		goto send;
 	}
@@ -202,22 +216,21 @@ handle_mbox_create(struct imsg_mbox_create *req, struc
 	result.error = MBOX_OP_OK;
 
 send:
-	if (switched && select_mailbox_dir(saved) == -1)
-		log_warnx("session %u: CREATE %s: couldn't restore "
-		    "previously selected mailbox %s", session_id,
-		    req->mailbox, saved);
 	if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
 	    sizeof(result)) == -1)
 		log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
 		    session_id);
 }
 
-/* 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 */
+/* bounded removal of a mailbox's tmp/new/cur + index; refuses non-regulars */
 static int
-remove_maildir_subtree(const char *prefix)
+remove_maildir_subtree(int dfd, const char *prefix)
 {
 	static const char *dirs[] = { "tmp", "new", "cur" };
-	char	path[MBOX_NAME_MAX + 32];	/* +32 must cover STORE_INDEX_NAME on a near-maximal mailbox name */
+	/*
+	 * +32 must cover STORE_INDEX_NAME on a near-maximal mailbox name.
+	 */
+	char	path[MBOX_NAME_MAX + 32];
 	size_t	i;
 
 	for (i = 0; i < sizeof(dirs) / sizeof(dirs[0]); i++) {
@@ -231,9 +244,18 @@ remove_maildir_subtree(const char *prefix)
 			    session_id);
 			return (-1);
 		}
-		if ((dp = opendir(path)) == NULL) {
+		{
+			int	subfd;
+
+			dp = NULL;
+			subfd = openat(dfd, path, O_RDONLY | O_DIRECTORY);
+			if (subfd != -1 && (dp = fdopendir(subfd)) == NULL)
+				close(subfd);
+		}
+		if (dp == NULL) {
 			if (errno == ENOENT)
-				continue;	/* tmp/ may never have been created */
+				/* tmp/ may never have been created */
+				continue;
 			log_warn("session %u: DELETE: opendir %s",
 			    session_id, path);
 			return (-1);
@@ -252,7 +274,8 @@ remove_maildir_subtree(const char *prefix)
 				ok = 0;
 				continue;
 			}
-			if (lstat(entpath, &st) == -1) {
+			if (fstatat(dfd, entpath, &st,
+			    AT_SYMLINK_NOFOLLOW) == -1) {
 				log_warn("session %u: DELETE: lstat %s",
 				    session_id, entpath);
 				ok = 0;
@@ -265,7 +288,7 @@ remove_maildir_subtree(const char *prefix)
 				ok = 0;
 				continue;
 			}
-			if (unlink(entpath) == -1) {
+			if (unlinkat(dfd, entpath, 0) == -1) {
 				log_warn("session %u: DELETE: unlink %s",
 				    session_id, entpath);
 				ok = 0;
@@ -274,14 +297,19 @@ remove_maildir_subtree(const char *prefix)
 		closedir(dp);
 		if (!ok)
 			return (-1);
-		if (rmdir(path) == -1 && errno != ENOENT) {
+		if (unlinkat(dfd, path, AT_REMOVEDIR) == -1 &&
+		    errno != ENOENT) {
 			log_warn("session %u: DELETE: rmdir %s", session_id,
 			    path);
 			return (-1);
 		}
 	}
 
-	/* Removes the index, its lock, and any leftover index_save() temp -- rmdir(2) follows, so every file this daemon creates here must be named or a crash mid-save leaves DELETE failing with ENOTEMPTY. */
+	/*
+	 * Removes the index, its lock, and any leftover index_save() temp --
+	 * rmdir(2) follows, so every file this daemon creates here must be
+	 * named or a crash mid-save leaves DELETE failing with ENOTEMPTY.
+	 */
 	{
 		static const char *files[] = {
 			STORE_INDEX_NAME,
@@ -297,7 +325,7 @@ remove_maildir_subtree(const char *prefix)
 				    "long", session_id);
 				return (-1);
 			}
-			if (unlink(path) == -1 && errno != ENOENT) {
+			if (unlinkat(dfd, path, 0) == -1 && errno != ENOENT) {
 				log_warn("session %u: DELETE: unlink %s",
 				    session_id, path);
 				return (-1);
@@ -308,7 +336,10 @@ remove_maildir_subtree(const char *prefix)
 	return (0);
 }
 
-/* IMSG_MBOX_DELETE (RFC 9051 SS6.3.5); no check for "is this session's own SELECTed mailbox", leaves store child at root until next SELECT resolves it */
+/*
+ * IMSG_MBOX_DELETE (RFC 9051 SS6.3.5); no check for "is this session's own
+ * SELECTed mailbox", leaves store child at root until next SELECT resolves it.
+ */
 
 void
 handle_mbox_delete(struct imsg_mbox_delete *req, struct imsgev *iev)
@@ -316,8 +347,6 @@ handle_mbox_delete(struct imsg_mbox_delete *req, struc
 	struct imsg_mbox_result	 result;
 	struct stat			 st;
 	char				 prefix[MBOX_NAME_MAX + 1];
-	char				 saved[MBOX_NAME_MAX];
-	int				 switched = 0;
 
 	memset(&result, 0, sizeof(result));
 
@@ -329,18 +358,15 @@ handle_mbox_delete(struct imsg_mbox_delete *req, struc
 		goto send;
 	}
 
-	/* visit root first, same as handle_mbox_create() */
-	if (mbox_root_enter(saved, sizeof(saved), "DELETE",
-	    req->mailbox) == -1) {
-		result.error = MBOX_OP_ERR_GENERIC;
-		goto send;
-	}
-	switched = 1;
 
-	if (lstat(req->mailbox, &st) == -1 || !S_ISDIR(st.st_mode)) {
+	if (fstatat(maildir_root_fd, req->mailbox, &st,
+	    AT_SYMLINK_NOFOLLOW) == -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 */
+		/*
+		 * RFC 5530 SS3 NONEXISTENT: its own worked example is exactly
+		 * this
+		 */
 		result.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
 		goto send;
 	}
@@ -351,11 +377,12 @@ handle_mbox_delete(struct imsg_mbox_delete *req, struc
 		goto send;
 	}
 
-	if (remove_maildir_subtree(prefix) == -1) {
+	if (remove_maildir_subtree(maildir_root_fd, prefix) == -1) {
 		result.error = MBOX_OP_ERR_GENERIC;
 		goto send;
 	}
-	if (rmdir(req->mailbox) == -1 && errno != ENOENT) {
+	if (unlinkat(maildir_root_fd, req->mailbox, AT_REMOVEDIR) == -1 &&
+	    errno != ENOENT) {
 		log_warn("session %u: DELETE: rmdir %s", session_id,
 		    req->mailbox);
 		result.error = MBOX_OP_ERR_GENERIC;
@@ -365,31 +392,30 @@ handle_mbox_delete(struct imsg_mbox_delete *req, struc
 	result.error = MBOX_OP_OK;
 
 send:
-	if (switched) {
-		/* Deleting this session's own selection: cwd/current_mailbox_dir are already reset, but mailbox_selected must also clear -- else a later FETCH/STORE/EXPUNGE could still operate on INBOX under the deleted name. */
-		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);
-	}
+	/*
+	 * Deleting this session's own selection clears the store's gate:
+	 * a later FETCH/STORE/EXPUNGE would otherwise still work on the
+	 * descriptor of a directory that no longer has a name.
+	 */
+	if (result.error == MBOX_OP_OK &&
+	    strcmp(selected_mailbox, req->mailbox) == 0)
+		mailbox_selected = 0;
 	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);
 }
 
-/* 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) */
+/*
+ * 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).
+ */
 
 void
 handle_mbox_rename(struct imsg_mbox_rename *req, struct imsgev *iev)
 {
 	struct imsg_mbox_result	 result;
 	struct stat			 st;
-	char				 saved[MBOX_NAME_MAX];
-	int				 switched = 0;
 
 	memset(&result, 0, sizeof(result));
 
@@ -403,32 +429,34 @@ handle_mbox_rename(struct imsg_mbox_rename *req, struc
 		goto send;
 	}
 
-	/* the log names the source only; the destination is not yet involved in getting to the root */
-	if (mbox_root_enter(saved, sizeof(saved), "RENAME",
-	    req->oldname) == -1) {
-		result.error = MBOX_OP_ERR_GENERIC;
-		goto send;
-	}
-	switched = 1;
 
-	if (lstat(req->oldname, &st) == -1 || !S_ISDIR(st.st_mode)) {
+	if (fstatat(maildir_root_fd, req->oldname, &st,
+	    AT_SYMLINK_NOFOLLOW) == -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 */
+		/*
+		 * RFC 5530 SS3 NONEXISTENT: example is RENAME failing on
+		 * missing source
+		 */
 		result.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
 		goto send;
 	}
 
-	/* SS6.3.6: error to rename to an existing name; checked explicitly rather than relying on rename(2)'s semantics */
-	if (lstat(req->newname, &st) == 0 || errno != ENOENT) {
+	/* RFC 9051 SS6.3.6: error to rename onto an existing name */
+	if (fstatat(maildir_root_fd, req->newname, &st,
+	    AT_SYMLINK_NOFOLLOW) == 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 */
+		/*
+		 * RFC 5530 SS3 ALREADYEXISTS: its worked example is this exact
+		 * RENAME case
+		 */
 		result.error = MBOX_OP_ERR_ALREADY_EXISTS;
 		goto send;
 	}
 
-	if (rename(req->oldname, req->newname) == -1) {
+	if (renameat(maildir_root_fd, req->oldname, maildir_root_fd,
+	    req->newname) == -1) {
 		log_warn("session %u: RENAME: rename %s -> %s", session_id,
 		    req->oldname, req->newname);
 		result.error = MBOX_OP_ERR_GENERIC;
@@ -438,16 +466,18 @@ handle_mbox_rename(struct imsg_mbox_rename *req, struc
 	result.error = MBOX_OP_OK;
 
 send:
-	if (switched) {
-		const char	*restore = saved;
-
-		/* if renamed mailbox was this session's own selection, follow it to its new name */
-		if (result.error == MBOX_OP_OK && strcmp(saved, req->oldname) == 0)
-			restore = req->newname;
-		if (select_mailbox_dir(restore) == -1)
-			log_warnx("session %u: RENAME %s -> %s: couldn't "
-			    "restore previously selected mailbox (as %s)",
-			    session_id, req->oldname, req->newname, restore);
+	/*
+	 * A rename of this session's selection follows the mailbox: the
+	 * descriptor still names the same directory, so only the recorded
+	 * name changes.
+	 */
+	if (result.error == MBOX_OP_OK &&
+	    strcmp(selected_mailbox, req->oldname) == 0) {
+		if (strlcpy(selected_mailbox, req->newname,
+		    sizeof(selected_mailbox)) >= sizeof(selected_mailbox))
+			log_warnx("session %u: RENAME %s -> %s: new name too "
+			    "long to record, can't happen (validated)",
+			    session_id, req->oldname, req->newname);
 	}
 	if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
 	    sizeof(result)) == -1)
@@ -455,55 +485,435 @@ send:
 		    session_id);
 }
 
-/* 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 */
+/*
+ * 1 if this maildir-root directory entry names a mailbox. Shared by
+ * handle_mbox_list() and subs_materialise(), which have to agree exactly: a
+ * name the two judged differently would be listed but never subscribable, or
+ * the reverse. *badname is set when a real directory was refused by the name
+ * rule alone, which the caller may report; every other refusal is silent.
+ */
+static int
+mbox_root_entry_is_mailbox(const char *name, int *badname)
+{
+	struct stat	st;
 
+	*badname = 0;
+
+	if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0)
+		return (0);
+
+	/*
+	 * Check directory-ness first so the name rule below only judges real
+	 * mailbox candidates. lstat(2) is safe pre-validation since a
+	 * readdir(2) d_name can't contain "/".
+	 */
+	if (fstatat(maildir_root_fd, name, &st, AT_SYMLINK_NOFOLLOW) ==
+	    -1 || !S_ISDIR(st.st_mode))
+		return (0);
+
+	/*
+	 * Directories that aren't mailboxes: tmp/new/cur are INBOX's own
+	 * maildir subdirs, and a directory named after a reserved file is
+	 * skipped quietly so such a name is never reported as lost mail.
+	 */
+	if (strcmp(name, "tmp") == 0 || strcmp(name, "new") == 0 ||
+	    strcmp(name, "cur") == 0 || mailbox_name_reserved(name))
+		return (0);
+
+	if (!mailbox_name_valid(name)) {
+		*badname = 1;
+		return (0);
+	}
+	return (1);
+}
+
+/*
+ * The subscription list in memory. `present` is 0 when the file does not
+ * exist, which means every mailbox is subscribed; see store_internal.h.
+ */
+struct sublist {
+	char	**names;
+	size_t	  n;
+	size_t	  cap;
+	int	  present;
+};
+
+#define SUBLIST_INIT	{ NULL, 0, 0, 0 }
+
+static void
+sublist_free(struct sublist *sl)
+{
+	size_t	i;
+
+	for (i = 0; i < sl->n; i++)
+		free(sl->names[i]);
+	free(sl->names);
+	sl->names = NULL;
+	sl->n = sl->cap = 0;
+}
+
+static int
+sublist_has(const struct sublist *sl, const char *name)
+{
+	size_t	i;
+
+	for (i = 0; i < sl->n; i++)
+		if (strcmp(sl->names[i], name) == 0)
+			return (1);
+	return (0);
+}
+
+/* takes its own copy; the caller keeps `name` */
+static int
+sublist_add(struct sublist *sl, const char *name)
+{
+	char	*copy;
+
+	if (sl->n == sl->cap) {
+		size_t	  newcap = (sl->cap == 0) ? 16 : sl->cap * 2;
+		char	**newnames;
+
+		if ((newnames = reallocarray(sl->names, newcap,
+		    sizeof(*sl->names))) == NULL) {
+			log_warn("session %u: reallocarray subscriptions",
+			    session_id);
+			return (-1);
+		}
+		sl->names = newnames;
+		sl->cap = newcap;
+	}
+	if ((copy = strdup(name)) == NULL) {
+		log_warn("session %u: strdup subscription", session_id);
+		return (-1);
+	}
+	sl->names[sl->n++] = copy;
+	return (0);
+}
+
+/* order is preserved so the file stays readable across rewrites */
+static void
+sublist_remove(struct sublist *sl, const char *name)
+{
+	size_t	i;
+
+	for (i = 0; i < sl->n; i++) {
+		if (strcmp(sl->names[i], name) != 0)
+			continue;
+		free(sl->names[i]);
+		memmove(&sl->names[i], &sl->names[i + 1],
+		    (sl->n - i - 1) * sizeof(*sl->names));
+		sl->n--;
+		return;
+	}
+}
+
+/*
+ * Reads the subscription file into *sl; cwd must be the maildir root. No
+ * file is not an error: it leaves sl->present 0, which every caller reads as
+ * "everything is subscribed". Takes no lock -- sublist_save() commits by
+ * rename(2), so a reader gets a whole file either way.
+ */
+static int
+sublist_load(struct sublist *sl)
+{
+	FILE	*fp;
+	char	 line[MBOX_NAME_MAX + 1];
+
+	sl->present = 0;
+	{
+		int	fd;
+
+		fp = NULL;
+		fd = openat(maildir_root_fd, STORE_SUBSCRIPTIONS_NAME,
+		    O_RDONLY);
+		if (fd != -1 && (fp = fdopen(fd, "r")) == NULL)
+			close(fd);
+	}
+	if (fp == NULL) {
+		if (errno == ENOENT)
+			return (0);
+		log_warn("session %u: open %s", session_id,
+		    STORE_SUBSCRIPTIONS_NAME);
+		return (-1);
+	}
+	sl->present = 1;
+
+	while (fgets(line, sizeof(line), fp) != NULL) {
+		/*
+		 * fgets(3) silently splits an over-long line; peek at the next
+		 * byte to tell a legal max-length name (next byte is '\n' or
+		 * EOF) from an actual split record.
+		 */
+		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 it", session_id,
+				    STORE_SUBSCRIPTIONS_NAME);
+				goto fail;
+			}
+		}
+		line[strcspn(line, "\n")] = '\0';
+		if (line[0] == '\0')
+			continue;
+
+		/*
+		 * SUBSCRIBE can't have written a name this server refuses, so
+		 * the file has been edited by hand. Refusing the whole file
+		 * rather than the line keeps the two halves of an edit from
+		 * being applied separately.
+		 */
+		if (!mailbox_name_syntax_ok(line)) {
+			log_warnx("session %u: unusable name in %s, refusing "
+			    "to parse it", session_id,
+			    STORE_SUBSCRIPTIONS_NAME);
+			goto fail;
+		}
+		if (sublist_has(sl, line))
+			continue;
+		if (sublist_add(sl, line) == -1)
+			goto fail;
+	}
+	if (ferror(fp)) {
+		log_warn("session %u: read %s", session_id,
+		    STORE_SUBSCRIPTIONS_NAME);
+		goto fail;
+	}
+	fclose(fp);
+	return (0);
+
+fail:
+	fclose(fp);
+	sublist_free(sl);
+	sl->present = 0;
+	return (-1);
+}
+
+/*
+ * Writes *sl over the subscription file, following index_save()'s discipline:
+ * O_EXCL on the temp so a pre-planted symlink can't be followed, fsync(2)
+ * before the rename(2) that commits it, and an fsync of the directory entry
+ * the rename repointed. The caller holds the lock.
+ */
+static int
+sublist_save(const struct sublist *sl)
+{
+	FILE	*fp;
+	size_t	 i;
+	int	 fd;
+
+	if (unlinkat(maildir_root_fd, STORE_SUBSCRIPTIONS_TMP_NAME, 0) ==
+	    -1 && errno != ENOENT) {
+		log_warn("session %u: unlink %s", session_id,
+		    STORE_SUBSCRIPTIONS_TMP_NAME);
+		return (-1);
+	}
+	if ((fd = openat(maildir_root_fd, STORE_SUBSCRIPTIONS_TMP_NAME,
+	    O_WRONLY | O_CREAT | O_EXCL, 0600)) == -1) {
+		log_warn("session %u: open %s", session_id,
+		    STORE_SUBSCRIPTIONS_TMP_NAME);
+		return (-1);
+	}
+	if ((fp = fdopen(fd, "w")) == NULL) {
+		log_warn("session %u: fdopen %s", session_id,
+		    STORE_SUBSCRIPTIONS_TMP_NAME);
+		close(fd);
+		return (-1);
+	}
+
+	for (i = 0; i < sl->n; i++) {
+		if (fprintf(fp, "%s\n", sl->names[i]) < 0) {
+			log_warnx("session %u: write %s failed", session_id,
+			    STORE_SUBSCRIPTIONS_TMP_NAME);
+			fclose(fp);
+			return (-1);
+		}
+	}
+	if (fflush(fp) != 0) {
+		log_warn("session %u: fflush %s", session_id,
+		    STORE_SUBSCRIPTIONS_TMP_NAME);
+		fclose(fp);
+		return (-1);
+	}
+	if (fsync(fileno(fp)) == -1) {
+		log_warn("session %u: fsync %s", session_id,
+		    STORE_SUBSCRIPTIONS_TMP_NAME);
+		fclose(fp);
+		return (-1);
+	}
+	if (fclose(fp) != 0) {
+		log_warn("session %u: fclose %s", session_id,
+		    STORE_SUBSCRIPTIONS_TMP_NAME);
+		return (-1);
+	}
+
+	if (renameat(maildir_root_fd, STORE_SUBSCRIPTIONS_TMP_NAME,
+	    maildir_root_fd, STORE_SUBSCRIPTIONS_NAME) == -1) {
+		log_warn("session %u: rename %s -> %s", session_id,
+		    STORE_SUBSCRIPTIONS_TMP_NAME, STORE_SUBSCRIPTIONS_NAME);
+		return (-1);
+	}
+
+	/* and the directory entry the rename(2) just repointed */
+	if (fsync(maildir_root_fd) == -1)
+		log_warn("session %u: fsync maildir root (continuing)",
+		    session_id);
+	return (0);
+}
+
+/*
+ * The subscription lock, taken on a file of its own rather than on the list,
+ * for the reason index_lock_acquire() takes the index's lock on one: flock(2)
+ * locks an inode, and sublist_save() replaces the list's inode every time.
+ */
+static int
+subs_lock_acquire(int *lockfd)
+{
+	if ((*lockfd = openat(maildir_root_fd,
+	    STORE_SUBSCRIPTIONS_LOCK_NAME,
+	    O_RDWR | O_CREAT, 0600)) == -1) {
+		log_warn("session %u: open %s", session_id,
+		    STORE_SUBSCRIPTIONS_LOCK_NAME);
+		return (-1);
+	}
+	if (flock(*lockfd, LOCK_EX) == -1) {
+		log_warn("session %u: flock %s", session_id,
+		    STORE_SUBSCRIPTIONS_LOCK_NAME);
+		close(*lockfd);
+		*lockfd = -1;
+		return (-1);
+	}
+	return (0);
+}
+
+static void
+subs_lock_release(int lockfd)
+{
+	if (lockfd == -1)
+		return;
+	flock(lockfd, LOCK_UN);
+	close(lockfd);
+}
+
+/*
+ * Fills *sl with every mailbox now on disk -- the list an absent file stands
+ * for. UNSUBSCRIBE calls this before removing its name, because the first
+ * UNSUBSCRIBE is what has to write that meaning down. INBOX is not among them
+ * and never is: it is the maildir root, not an entry in it. cwd must be the
+ * maildir root.
+ */
+static int
+subs_materialise(struct sublist *sl)
+{
+	DIR		*dp;
+	struct dirent	*de;
+	int		 badname;
+
+	{
+		int	rootfd;
+
+		dp = NULL;
+		rootfd = openat(maildir_root_fd, ".",
+		    O_RDONLY | O_DIRECTORY);
+		if (rootfd != -1 && (dp = fdopendir(rootfd)) == NULL)
+			close(rootfd);
+	}
+	if (dp == NULL) {
+		log_warn("session %u: UNSUBSCRIBE: opendir .", session_id);
+		return (-1);
+	}
+	while ((de = readdir(dp)) != NULL) {
+		if (!mbox_root_entry_is_mailbox(de->d_name, &badname))
+			continue;
+		if (sublist_add(sl, de->d_name) == -1) {
+			closedir(dp);
+			return (-1);
+		}
+	}
+	closedir(dp);
+	sl->present = 1;
+	return (0);
+}
+
+/*
+ * Emits one IMSG_MBOX_LIST_ITEM; returns 1 if it went out, 0 if it did not,
+ * so a caller can add the result straight to its count.
+ */
+static int
+list_item_send(struct imsgev *iev, const char *name, int exists)
+{
+	struct imsg_mbox_list_item	item;
+
+	memset(&item, 0, sizeof(item));
+	if (strlcpy(item.mailbox, name, sizeof(item.mailbox)) >=
+	    sizeof(item.mailbox)) {
+		log_warnx("session %u: LIST: %s truncated, can't happen (the "
+		    "name rule already bounds it under MBOX_NAME_MAX)",
+		    session_id, name);
+		return (0);
+	}
+	item.exists = exists;
+	if (imsg_compose(&iev->ibuf, IMSG_MBOX_LIST_ITEM, 0, 0, -1, &item,
+	    sizeof(item)) == -1) {
+		log_warn("session %u: imsg_compose IMSG_MBOX_LIST_ITEM",
+		    session_id);
+		return (0);
+	}
+	return (1);
+}
+
+/* IMSG_MBOX_LIST (RFC 9051 SS6.3.9); streams LIST_ITEM/mailbox, no INBOX */
+
 void
-handle_mbox_list(struct imsgev *iev)
+handle_mbox_list(struct imsg_mbox_list *req, struct imsgev *iev)
 {
 	struct imsg_mbox_result	 result;
-	struct imsg_mbox_list_item item;
+	struct sublist			 sl = SUBLIST_INIT;
 	DIR				*dp;
 	struct dirent			*de;
-	char				 saved[MBOX_NAME_MAX];
-	int				 switched = 0;
+	size_t				 i;
+	int				 badname;
 	static int			 skip_warned;	/* see the loop below */
 
 	memset(&result, 0, sizeof(result));
 
-	/* LIST names no mailbox, so it has no subject for the log */
-	if (mbox_root_enter(saved, sizeof(saved), "LIST", NULL) == -1) {
-		result.error = MBOX_OP_ERR_GENERIC;
-		goto send;
+
+	/*
+	 * Only a subscribed-only request reads the file, and a read that
+	 * fails is not fatal to the LIST: sl.present 0 means every mailbox is
+	 * subscribed, so the answer becomes every mailbox rather than none.
+	 */
+	if (req->subscribed_only && sublist_load(&sl) == -1)
+		sl.present = 0;
+
+	{
+		int	rootfd;
+
+		dp = NULL;
+		rootfd = openat(maildir_root_fd, ".",
+		    O_RDONLY | O_DIRECTORY);
+		if (rootfd != -1 && (dp = fdopendir(rootfd)) == NULL)
+			close(rootfd);
 	}
-	switched = 1;
-
-	if ((dp = opendir(".")) == NULL) {
+	if (dp == NULL) {
 		log_warn("session %u: LIST: opendir .", session_id);
 		result.error = MBOX_OP_ERR_GENERIC;
 		goto send;
 	}
 
 	while ((de = readdir(dp)) != NULL) {
-		struct stat	st;
-
-		if (strcmp(de->d_name, ".") == 0 ||
-		    strcmp(de->d_name, "..") == 0)
-			continue;
-
-		/* Check directory-ness first so the refusal below only fires on real mailbox candidates; the index/lock/uidvalidity files must stay in the silent skip list or a correct-but-alarming warning fires on every LIST. lstat(2) is safe pre-validation since readdir(2) d_name can't contain "/". */
-		if (lstat(de->d_name, &st) == -1 || !S_ISDIR(st.st_mode))
-			continue;
-
-		/* Directories that aren't mailboxes: tmp/new/cur (INBOX's own maildir subdirs) and a directory named like STORE_INDEX_NAME, skipped quietly so a reserved name is never 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)) {
-			/* Logged (not silent): anything reaching here is real mail made invisible over IMAP, likely by the later SS5.1 UTF-8 rule, and the operator's log is the only place to see why -- logged once per store child, not once per LIST, so frequent Apple Mail LISTs don't bury it. */
-			if (!skip_warned) {
+		if (!mbox_root_entry_is_mailbox(de->d_name, &badname)) {
+			/*
+			 * Logged (not silent): anything rejected by the name
+			 * rule is real mail made invisible over IMAP, likely
+			 * by the later SS5.1 UTF-8 rule, and the operator's
+			 * log is the only place to see why -- logged once per
+			 * store child, not once per LIST, so frequent Apple
+			 * Mail LISTs don't bury it.
+			 */
+			if (badname && !skip_warned) {
 				skip_warned = 1;
 				log_warnx("session %u: LIST: skipping %s: "
 				    "not a valid mailbox name (RFC 9051 "
@@ -514,39 +924,173 @@ handle_mbox_list(struct imsgev *iev)
 			}
 			continue;
 		}
-
-		memset(&item, 0, sizeof(item));
-		if (strlcpy(item.mailbox, de->d_name, sizeof(item.mailbox)) >=
-		    sizeof(item.mailbox)) {
-			log_warnx("session %u: LIST: %s truncated, can't "
-			    "happen (mailbox_name_valid() already bounds it "
-			    "under MBOX_NAME_MAX)", session_id, de->d_name);
+		if (req->subscribed_only && sl.present &&
+		    !sublist_has(&sl, de->d_name))
 			continue;
-		}
-		if (imsg_compose(&iev->ibuf, IMSG_MBOX_LIST_ITEM, 0, 0, -1,
-		    &item, sizeof(item)) == -1) {
-			log_warn("session %u: imsg_compose "
-			    "IMSG_MBOX_LIST_ITEM", session_id);
-			continue;
-		}
-		result.count++;
+		result.count += list_item_send(iev, de->d_name, 1);
 	}
 	closedir(dp);
+
+	/*
+	 * Subscribed names with no mailbox left on disk. sl.n is 0 unless
+	 * this was a subscribed-only request, so the loop is skipped
+	 * entirely on a plain LIST.
+	 */
+	for (i = 0; i < sl.n; i++) {
+		struct stat	st;
+
+		/* the walk above already sent the ones that are there */
+		if (fstatat(maildir_root_fd, sl.names[i], &st,
+		    AT_SYMLINK_NOFOLLOW) != -1 && S_ISDIR(st.st_mode))
+			continue;
+		result.count += list_item_send(iev, sl.names[i], 0);
+	}
 	result.error = MBOX_OP_OK;
 
 send:
-	if (switched && select_mailbox_dir(saved) == -1)
-		log_warnx("session %u: LIST: couldn't restore previously "
-		    "selected mailbox %s", session_id, saved);
+	sublist_free(&sl);
 	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);
 }
 
-/* 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 */
+/* IMSG_MBOX_SUBSCRIBE (RFC 9051 SS6.3.7); idempotent, name must exist */
 
-/* host name for the maildir basename uniquer; cached after first gethostname(2), falls back to "imapd" (not fatal, uniqueness doesn't depend on it) */
+void
+handle_mbox_subscribe(struct imsg_mbox_subscribe *req, struct imsgev *iev)
+{
+	struct imsg_mbox_result	 result;
+	struct sublist		 sl = SUBLIST_INIT;
+	struct stat		 st;
+	int			 lockfd = -1;
+
+	memset(&result, 0, sizeof(result));
+
+	/*
+	 * INBOX is permanently subscribed and is never named in the file, so
+	 * this succeeds having written nothing (SS6.3.7: subscribing what is
+	 * already subscribed returns OK).
+	 */
+	if (mailbox_name_is_inbox(req->mailbox)) {
+		result.error = MBOX_OP_OK;
+		goto send;
+	}
+	if (!mailbox_name_valid(req->mailbox)) {
+		log_debug("session %u: SUBSCRIBE %s: invalid name", session_id,
+		    req->mailbox);
+		result.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
+		goto send;
+	}
+
+
+	/*
+	 * SS6.3.7 leaves validating the name a MAY. Taking that MAY: this
+	 * namespace is flat and single-user, so a name that isn't there is a
+	 * typo rather than a mailbox that comes and goes, and saying so now
+	 * beats a permanent entry only an exact UNSUBSCRIBE can remove.
+	 */
+	if (fstatat(maildir_root_fd, req->mailbox, &st,
+	    AT_SYMLINK_NOFOLLOW) == -1 || !S_ISDIR(st.st_mode)) {
+		log_debug("session %u: SUBSCRIBE %s: no such mailbox",
+		    session_id, req->mailbox);
+		result.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
+		goto send;
+	}
+
+	if (subs_lock_acquire(&lockfd) == -1 || sublist_load(&sl) == -1) {
+		result.error = MBOX_OP_ERR_GENERIC;
+		goto send;
+	}
+
+	/* absent list means already subscribed, so there is nothing to write */
+	if (!sl.present || sublist_has(&sl, req->mailbox)) {
+		result.error = MBOX_OP_OK;
+		goto send;
+	}
+	if (sublist_add(&sl, req->mailbox) == -1 || sublist_save(&sl) == -1) {
+		result.error = MBOX_OP_ERR_GENERIC;
+		goto send;
+	}
+	result.error = MBOX_OP_OK;
+
+send:
+	sublist_free(&sl);
+	subs_lock_release(lockfd);
+	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);
+}
+
+/* IMSG_MBOX_UNSUBSCRIBE (RFC 9051 SS6.3.8); idempotent, no existence test */
+
+void
+handle_mbox_unsubscribe(struct imsg_mbox_subscribe *req, struct imsgev *iev)
+{
+	struct imsg_mbox_result	 result;
+	struct sublist		 sl = SUBLIST_INIT;
+	int			 lockfd = -1;
+
+	memset(&result, 0, sizeof(result));
+
+	/* the one name SS6.3.8's result table lets a server refuse outright */
+	if (mailbox_name_is_inbox(req->mailbox)) {
+		log_debug("session %u: UNSUBSCRIBE INBOX: refused",
+		    session_id);
+		result.error = MBOX_OP_ERR_GENERIC;
+		goto send;
+	}
+	/*
+	 * SS6.3.8 says a name may stay subscribed after its mailbox is gone,
+	 * so this does not ask whether the mailbox exists -- only whether the
+	 * name is one this server could ever have written down.
+	 */
+	if (!mailbox_name_valid(req->mailbox)) {
+		log_debug("session %u: UNSUBSCRIBE %s: invalid name",
+		    session_id, req->mailbox);
+		result.error = MBOX_OP_ERR_GENERIC;
+		goto send;
+	}
+
+
+	if (subs_lock_acquire(&lockfd) == -1 || sublist_load(&sl) == -1) {
+		result.error = MBOX_OP_ERR_GENERIC;
+		goto send;
+	}
+
+	/*
+	 * First UNSUBSCRIBE on this account: write out what an absent file
+	 * stood for, minus this name, or the file would arrive claiming
+	 * nothing else is subscribed either.
+	 */
+	if (!sl.present && subs_materialise(&sl) == -1) {
+		result.error = MBOX_OP_ERR_GENERIC;
+		goto send;
+	}
+	if (!sublist_has(&sl, req->mailbox)) {
+		/* SS6.3.8: unsubscribing what isn't subscribed returns OK */
+		result.error = MBOX_OP_OK;
+		goto send;
+	}
+
+	sublist_remove(&sl, req->mailbox);
+	if (sublist_save(&sl) == -1) {
+		result.error = MBOX_OP_ERR_GENERIC;
+		goto send;
+	}
+	result.error = MBOX_OP_OK;
+
+send:
+	sublist_free(&sl);
+	subs_lock_release(lockfd);
+	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);
+}
+
+/* host for maildir basename uniquer; cached, falls back to "imapd" */
 const char *
 append_hostname(void)
 {
@@ -566,186 +1110,279 @@ append_hostname(void)
 	return (hostbuf);
 }
 
+/*
+ * The one APPEND in flight. IMSG_MBOX_APPEND opens a tmp/ file,
+ * IMSG_MBOX_APPEND_DATA fills it, and IMSG_MBOX_APPEND_END commits it
+ * once the listener has seen the command's closing CRLF (RFC 9051
+ * SS6.3.12). A failure part way through is held until END, since the
+ * rest of the literal is still arriving.
+ */
+static struct {
+	int			  active;
+	int			  failed;
+	enum mbox_op_error	  error;
+	struct imsg_mbox_append	  req;
+	uint64_t		  remaining;
+	int			  tfd;
+	int			  tmpfd;
+	char			  basename[256];
+	char			  tmppath[300];
+} ap;
 
+/* Closes what the in-flight APPEND holds and removes its tmp/ file. */
 void
-handle_mbox_append(struct imsg_mbox_append *req, const char *msgbody,
-    size_t msglen, struct imsgev *iev)
+append_abort(void)
 {
-	struct mbox_index		 idx;
-	struct imsg_mbox_appended	 reply;
-	int				 tmpfd = -1;
-	struct index_lock		 il = INDEX_LOCK_INIT;
-	char				 basename[256];
-	char				 tmppath[300], curpath[320];
-	char				 target[MBOX_NAME_MAX];
-	char				 letters[8], line[STORE_INDEX_LINE_MAX];
-	int64_t				 delivery_ts;
-	char				 saved[MBOX_NAME_MAX];
-	int				 switched = 0;
+	if (!ap.active)
+		return;
+	if (ap.tmpfd != -1)
+		close(ap.tmpfd);
+	if (ap.tmppath[0] != '\0' && ap.tfd != -1)
+		unlinkat(ap.tfd, ap.tmppath, 0);
+	if (ap.tfd != -1)
+		close(ap.tfd);
+	memset(&ap, 0, sizeof(ap));
+}
 
-	memset(&idx, 0, sizeof(idx));
-	memset(&reply, 0, sizeof(reply));
+static void
+append_reply(struct imsgev *iev, const struct imsg_mbox_appended *reply)
+{
+	if (imsg_compose(&iev->ibuf, IMSG_MBOX_APPENDED, 0, 0, -1, reply,
+	    sizeof(*reply)) == -1)
+		log_warn("session %u: imsg_compose IMSG_MBOX_APPENDED",
+		    session_id);
+}
 
-	/* 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);
-		reply.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
-		goto done_reply;
+/* Checks the target and opens its tmp/ file; the reply waits for END. */
+void
+handle_mbox_append_begin(const struct imsg_mbox_append *req)
+{
+	char		target[MBOX_NAME_MAX];
+	int64_t		delivery_ts;
+
+	if (ap.active) {
+		log_warnx("session %u: APPEND begun with another in flight, "
+		    "abandoning the first", session_id);
+		append_abort();
 	}
+	memset(&ap, 0, sizeof(ap));
+	ap.active = 1;
+	ap.tfd = -1;
+	ap.tmpfd = -1;
+	ap.req = *req;
+	ap.remaining = req->msglen;
 
+	/* failed until the tmp/ file is open */
+	ap.failed = 1;
+	ap.error = MBOX_OP_ERR_GENERIC;
+
+	if (req->msglen > append_max) {
+		log_warnx("session %u: APPEND of %llu octets is over the %llu "
+		    "octet limit", session_id, (unsigned long long)req->msglen,
+		    (unsigned long long)append_max);
+		return;
+	}
 	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;
+		return;
 	}
 
-	if (mailbox_name_is_inbox(req->mailbox)) {
+	if (mailbox_name_is_inbox(req->mailbox))
 		target[0] = '\0';
-	} else if (mailbox_name_valid(req->mailbox)) {
-		if (strlcpy(target, req->mailbox, sizeof(target)) >=
-		    sizeof(target)) {
-			log_warnx("session %u: APPEND %s: target truncated "
-			    "-- can't happen (mailbox_name_valid() already "
-			    "bounds it under MBOX_NAME_MAX)", session_id,
-			    req->mailbox);
-			reply.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
-			goto done_reply;
-		}
-	} else {
-		log_debug("session %u: APPEND %s: invalid mailbox name",
-		    session_id, req->mailbox);
-		reply.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
-		goto done_reply;
+	else if (!mailbox_name_valid(req->mailbox) ||
+	    strlcpy(target, req->mailbox, sizeof(target)) >= sizeof(target)) {
+		log_debug("session %u: APPEND: invalid mailbox name",
+		    session_id);
+		ap.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
+		return;
 	}
 
-	if (select_mailbox_dir(target) == -1) {
+	if ((ap.tfd = mailbox_open_dir(target)) == -1) {
 		log_debug("session %u: APPEND %s: no such mailbox",
 		    session_id, req->mailbox);
-		reply.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
-		goto done_reply;
+		ap.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
+		return;
 	}
-	switched = 1;
+	if (ensure_maildir_dirs(ap.tfd, "") == -1)
+		return;
 
-	if (ensure_maildir_dirs("") == -1)
-		goto done_reply;
-
 	delivery_ts = req->has_date ? req->date : (int64_t)time(NULL);
-
-	if (snprintf(basename, sizeof(basename), "%lld.%d_%u.%s",
+	if (snprintf(ap.basename, sizeof(ap.basename), "%lld.%d_%u.%s",
 	    (long long)delivery_ts, (int)getpid(), append_counter++,
-	    append_hostname()) >= (int)sizeof(basename)) {
+	    append_hostname()) >= (int)sizeof(ap.basename)) {
 		log_warnx("session %u: generated basename too long",
 		    session_id);
-		goto done_reply;
+		return;
 	}
-	if (snprintf(tmppath, sizeof(tmppath), "tmp/%s", basename) >=
-	    (int)sizeof(tmppath)) {
+	if (snprintf(ap.tmppath, sizeof(ap.tmppath), "tmp/%s",
+	    ap.basename) >= (int)sizeof(ap.tmppath)) {
 		log_warnx("session %u: tmp path too long", session_id);
-		goto done_reply;
+		ap.tmppath[0] = '\0';
+		return;
 	}
+	if ((ap.tmpfd = openat(ap.tfd, ap.tmppath,
+	    O_WRONLY | O_CREAT | O_EXCL, 0600)) == -1) {
+		log_warn("session %u: open %s", session_id, ap.tmppath);
+		/* not ours to remove, O_EXCL may have found another file */
+		ap.tmppath[0] = '\0';
+		return;
+	}
+	ap.failed = 0;
+}
 
-	if ((tmpfd = open(tmppath, O_WRONLY | O_CREAT | O_EXCL, 0600)) == -1) {
-		log_warn("session %u: open %s", session_id, tmppath);
-		goto done_reply;
+/* Writes one piece of the literal to the tmp/ file. */
+void
+handle_mbox_append_data(const char *buf, size_t len)
+{
+	size_t	written = 0;
+
+	if (!ap.active) {
+		log_warnx("session %u: APPEND data with no APPEND in flight, "
+		    "ignoring", session_id);
+		return;
 	}
-	{
-		size_t	 written = 0;
+	if (len > ap.remaining) {
+		log_warnx("session %u: APPEND data past the announced length",
+		    session_id);
+		ap.failed = 1;
+		ap.error = MBOX_OP_ERR_GENERIC;
+		ap.remaining = 0;
+		return;
+	}
+	ap.remaining -= len;
+	if (ap.failed)
+		return;
 
-		while (written < msglen) {
-			ssize_t	 n;
+	while (written < len) {
+		ssize_t	n;
 
-			n = write(tmpfd, msgbody + written, msglen - written);
-			if (n == -1) {
-				if (errno == EINTR)
-					continue;
-				log_warn("session %u: write %s", session_id,
-				    tmppath);
-				close(tmpfd);
-				tmpfd = -1;
-				unlink(tmppath);
-				goto done_reply;
-			}
-			written += (size_t)n;
+		n = write(ap.tmpfd, buf + written, len - written);
+		if (n == -1) {
+			if (errno == EINTR)
+				continue;
+			log_warn("session %u: write %s", session_id,
+			    ap.tmppath);
+			ap.failed = 1;
+			ap.error = MBOX_OP_ERR_GENERIC;
+			return;
 		}
+		written += (size_t)n;
 	}
-	if (fsync(tmpfd) == -1)
-		log_warn("session %u: fsync %s (continuing)", session_id,
-		    tmppath);
-	close(tmpfd);
-	tmpfd = -1;
+}
 
-	if (index_lock_acquire(&il, LOCK_EX) == -1)
-		goto done_unlink;
+/*
+ * Commits the in-flight APPEND and replies. The index is updated before
+ * the tmp/ to cur/ rename, so a rename failure leaves an indexed entry
+ * missing on disk rather than a file nothing indexes.
+ *
+ * Returns 1 without replying when another session holds the index lock,
+ * leaving ap as it was so that store.c can run this again.
+ */
+int
+handle_mbox_append_end(struct imsgev *iev)
+{
+	struct imsg_mbox_appended	 reply;
+	struct mbox_index		 idx;
+	struct index_lock		 il = INDEX_LOCK_INIT;
+	char				 curpath[320];
+	char				 letters[8];
+	char				 line[STORE_INDEX_LINE_MAX];
+	int				 locked;
+
+	memset(&idx, 0, sizeof(idx));
+	memset(&reply, 0, sizeof(reply));
+	reply.error = MBOX_OP_ERR_GENERIC;
+
+	if (!ap.active) {
+		log_warnx("session %u: APPEND end with no APPEND in flight",
+		    session_id);
+		append_reply(iev, &reply);
+		return (0);
+	}
+	if (!ap.failed && ap.remaining != 0) {
+		log_warnx("session %u: APPEND ended %llu octets short",
+		    session_id, (unsigned long long)ap.remaining);
+		ap.failed = 1;
+		ap.error = MBOX_OP_ERR_GENERIC;
+	}
+	if (ap.failed) {
+		reply.error = ap.error;
+		goto done;
+	}
+
+	/* A re-run after a busy lock finds this already done. */
+	if (ap.tmpfd != -1) {
+		if (fsync(ap.tmpfd) == -1)
+			log_warn("session %u: fsync %s (continuing)",
+			    session_id, ap.tmppath);
+		close(ap.tmpfd);
+		ap.tmpfd = -1;
+	}
+
+	locked = index_lock_acquire(ap.tfd, &il, LOCK_EX | LOCK_NB);
+	if (locked == 1)
+		return (1);
+	if (locked == -1)
+		goto done_index;
 	if (index_load(il.fd, &idx) == -1)
-		goto done_unlink;
+		goto done_index;
 
 	reply.uid = idx.uidnext;
-	if (index_append(&idx, reply.uid, basename) == -1)
-		goto done_unlink;
+	if (index_append(&idx, reply.uid, ap.basename) == -1)
+		goto done_index;
 	idx.uidnext++;
 
-	if (req->keywords[0] != '\0') {
-		int	 n;
+	if (ap.req.keywords[0] != '\0') {
+		int	n;
 
-		/* carry forward the modseq index_append() just assigned, or an initial flag-list loses its mod-sequence */
+		/* carry forward the modseq index_append() assigned */
 		n = snprintf(line, sizeof(line), "%u:%s:%s:%llu", reply.uid,
-		    basename, req->keywords,
+		    ap.basename, ap.req.keywords,
 		    (unsigned long long)idx.highestmodseq);
 		if (n < 0 || (size_t)n >= sizeof(line)) {
 			log_warnx("session %u: index line too long for %s",
-			    session_id, basename);
-			goto done_unlink;
+			    session_id, ap.basename);
+			goto done_index;
 		}
 		free(idx.lines[idx.nlines - 1]);
 		if ((idx.lines[idx.nlines - 1] = strdup(line)) == NULL) {
 			log_warn("session %u: strdup index line", session_id);
-			goto done_unlink;
+			goto done_index;
 		}
 	}
 
-	if (index_save(&idx) == -1)
-		goto done_unlink;
+	if (index_save(ap.tfd, &idx) == -1)
+		goto done_index;
 
 	reply.uidvalidity = idx.uidvalidity;
 	reply.exists = (uint32_t)idx.nlines;
-
 	index_lock_release(&il);
 	index_free(&idx);
 
-	/* index committed, now the rename; index-then-rename is the safer order to fail partway through */
-	sysflags_to_letters(req->sysflags, letters, sizeof(letters));
-	if (snprintf(curpath, sizeof(curpath), "cur/%s:2,%s", basename,
+	/* index committed, now rename; index-then-rename fails safer partway */
+	sysflags_to_letters(ap.req.sysflags, letters, sizeof(letters));
+	if (snprintf(curpath, sizeof(curpath), "cur/%s:2,%s", ap.basename,
 	    letters) >= (int)sizeof(curpath)) {
 		log_warnx("session %u: cur path too long for %s", session_id,
-		    basename);
-		unlink(tmppath);
-		goto done_reply;
+		    ap.basename);
+		goto done;
 	}
-	if (rename(tmppath, curpath) == -1) {
-		log_warn("session %u: rename %s -> %s", session_id, tmppath,
+	if (renameat(ap.tfd, ap.tmppath, ap.tfd, curpath) == -1) {
+		log_warn("session %u: rename %s -> %s", session_id, ap.tmppath,
 		    curpath);
-		unlink(tmppath);
-		goto done_reply;
+		goto done;
 	}
-
+	ap.tmppath[0] = '\0';	/* now in cur/, append_abort() must keep it */
 	reply.error = MBOX_OP_OK;
-	goto done_reply;
+	goto done;
 
-done_unlink:
-	unlink(tmppath);
-	index_lock_release(&il);	/* idempotent; already released on the success path above */
+done_index:
+	/* both idempotent */
+	index_lock_release(&il);
 	index_free(&idx);
-
-done_reply:
-	if (switched && select_mailbox_dir(saved) == -1)
-		log_warnx("session %u: APPEND %s: couldn't restore "
-		    "previously selected mailbox %s", session_id,
-		    req->mailbox, saved);
-	if (imsg_compose(&iev->ibuf, IMSG_MBOX_APPENDED, 0, 0, -1, &reply,
-	    sizeof(reply)) == -1)
-		log_warn("session %u: imsg_compose IMSG_MBOX_APPENDED",
-		    session_id);
+done:
+	append_abort();
+	append_reply(iev, &reply);
+	return (0);
 }
blob - d0484b76bce282d8bf32bb8b6d5e97b61f295c21
blob + 1cc1977f0e44ec4adf3ef66db1f58f3b6faed340
--- src/mbox_search.c
+++ src/mbox_search.c
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  *
@@ -36,7 +38,7 @@
 #include "log.h"
 #include "store_internal.h"
 
-/* True if comma-separated list contains kw as a whole token (not substring); used by merge_keywords() to dedupe/REMOVE. */
+/* True if comma-list has kw as a whole token; used by merge_keywords(). */
 static int
 kw_list_contains(const char *list, const char *kw)
 {
@@ -60,11 +62,19 @@ kw_list_contains(const char *list, const char *kw)
 	return (0);
 }
 
-/* Appends tok to out (comma-joined, tracking *firstp); shared overflow-check body for merge_keywords()'s three tokenize loops below. */
+/*
+ * 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)
 {
-	/* Can happen on the ADD path: out and both inputs are MBOX_FLAGS_MAX but ADD concatenates them, and strlcat(3) has already written a truncated prefix by the time it reports failure, so the caller must discard *out rather than store it. */
+	/*
+	 * Can happen on the ADD path: out and both inputs are
+	 * MBOX_FLAGS_MAX but ADD concatenates them, and strlcat(3) has
+	 * already written a truncated prefix by the time it reports
+	 * failure, so the caller must discard *out 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);
@@ -79,7 +89,12 @@ append_kw_token(char *out, size_t outsize, int *firstp
 	return (1);
 }
 
-/* Applies mode/new_kws to old_kws into *out (SET ignores old_kws per RFC 9051 SS6.4.6, dedup always); returns -1 if ADD's concatenation overflows MBOX_FLAGS_MAX, since silently truncating used to cut a keyword mid-token and drop the others under a tagged OK. */
+/*
+ * Applies mode/new_kws to old_kws into *out (SET ignores old_kws per RFC
+ * 9051 SS6.4.6, dedup always); returns -1 if ADD's concatenation overflows
+ * MBOX_FLAGS_MAX, since silently truncating used to cut a keyword
+ * mid-token and drop the others under a tagged OK.
+ */
 int
 merge_keywords(int mode, const char *old_kws, const char *new_kws,
     char *out, size_t outsize)
@@ -124,7 +139,10 @@ merge_keywords(int mode, const char *old_kws, const ch
 		}
 	}
 
-	/* SET starts from nothing; ADD continues from old_kws already built above; either way append new_kws not already present */
+	/*
+	 * SET starts from nothing; ADD continues from old_kws already built
+	 * above; either way append new_kws not already present.
+	 */
 	if (strlcpy(tmp, new_kws, sizeof(tmp)) >= sizeof(tmp)) {
 		log_warnx("session %u: merge_keywords: new_kws truncated, "
 		    "can't happen (same-size MBOX_FLAGS_MAX buffers); "
@@ -141,18 +159,22 @@ merge_keywords(int mode, const char *old_kws, const ch
 	return (0);
 }
 
-/* Per-message context handed to search_eval()/search_eval_leaf() during handle_mbox_search()'s scan below. */
+/* Per-message context for search_eval()/search_eval_leaf() in this file. */
 struct search_msg_ctx {
 	uint32_t	seqno;
 	uint32_t	uid;
 	uint32_t	sysflags;
-	const char	*keywords;	/* comma-separated, same convention as everywhere else in this file */
+	/* comma-separated, as elsewhere in file */
+	const char	*keywords;
 	int64_t		internaldate;
 	uint64_t	size;		/* 64-bit for SEARCH LARGER/SMALLER */
 	uint64_t	modseq;		/* RFC 7162 SS3.1.5 MODSEQ search key */
 };
 
-/* One postfix node's evaluation against a message; SEARCH_OP_AND/OR/NOT never reach here, handled by search_eval()'s stack machine. */
+/*
+ * One postfix node's evaluation against a message; SEARCH_OP_AND/OR/NOT
+ * never reach here, handled by search_eval()'s stack machine.
+ */
 static int
 search_eval_leaf(const struct search_node *n, const struct search_msg_ctx *m)
 {
@@ -205,7 +227,7 @@ search_eval_leaf(const struct search_node *n, const st
 	}
 }
 
-/* Evaluates nodes[0..nnodes), a postfix boolean expression from listener.c's parse_search_key(), against one message. */
+/* Evaluates nodes[0..nnodes), postfix from listener.c's parse_search_key(). */
 static int
 search_eval(const struct search_node *nodes, uint32_t nnodes,
     const struct search_msg_ctx *m)
@@ -245,24 +267,34 @@ search_eval(const struct search_node *nodes, uint32_t 
 	return (sp == 1 ? stack[0] : 0);
 }
 
-/* IMSG_MBOX_SEARCH: LOCK_SH, iterates every message (no shortcut range), evaluating search_eval() for each; streams matches, then IMSG_MBOX_RESULT. */
-void
+/*
+ * IMSG_MBOX_SEARCH: LOCK_SH, iterates every message (no shortcut range),
+ * evaluating search_eval() for each; streams matches, then
+ * IMSG_MBOX_RESULT. Returns 0 having answered, or 1 without answering
+ * because another session holds the index lock; store.c runs it again.
+ */
+int
 handle_mbox_search(struct imsg_mbox_search *req, struct search_node *nodes,
     uint32_t nnodes, struct imsgev *iev)
 {
 	struct mbox_index	 idx;
 	struct imsg_mbox_result	 result;
 	struct index_lock	 il = INDEX_LOCK_INIT;
-	int			 ok = 1;
+	int			 ok = 1, locked;
 	uint32_t		 sent = 0;
 	uint32_t		 max_uid = 0;
 	uint32_t		 i;
 
-	(void)req;	/* nnodes, passed separately, is currently its only field */
+	/* nnodes, passed separately, is currently its only field */
+	(void)req;
 
 	memset(&idx, 0, sizeof(idx));
 
-	if (index_lock_acquire(&il, LOCK_SH) == -1) {
+	/* Nodes are only rewritten after the load; busy leaves them intact. */
+	locked = index_lock_acquire(mailbox_dir_fd, &il, LOCK_SH | LOCK_NB);
+	if (locked == 1)
+		return (1);
+	if (locked == -1) {
 		ok = 0;
 		goto done;
 	}
@@ -273,9 +305,16 @@ handle_mbox_search(struct imsg_mbox_search *req, struc
 	}
 	index_lock_release(&il);
 
-	max_uid = index_max_uid(&idx);	/* shared with UID FETCH/STORE/EXPUNGE's own "*" resolution */
+	/* shared with UID FETCH/STORE/EXPUNGE's own "*" resolution */
+	max_uid = index_max_uid(&idx);
 
-	/* Resolves each SEQSET/UIDSET node's "*" and normalizes backwards ranges one at a time via the shared seqset_resolve() (SEARCH's nodes sit scattered in a postfix AND/OR/NOT tree, so they can't be batched like a single sequence-set); SEQSET clamps hi to idx.nlines like FETCH/STORE, UIDSET doesn't. */
+	/*
+	 * Resolves each SEQSET/UIDSET node's "*" and normalizes backwards
+	 * ranges one at a time via the shared seqset_resolve() (SEARCH's
+	 * nodes sit scattered in a postfix AND/OR/NOT tree, so they can't
+	 * be batched like a single sequence-set); SEQSET clamps hi to
+	 * idx.nlines like FETCH/STORE, UIDSET doesn't.
+	 */
 	for (i = 0; i < nnodes; i++) {
 		struct search_node	*n = &nodes[i];
 		struct seq_range	 in, out;
@@ -313,8 +352,8 @@ handle_mbox_search(struct imsg_mbox_search *req, struc
 		m.uid = rec.uid;
 		m.modseq = rec.modseq;
 
-		if (locate_message_file(rec.basename, &size, suffix,
-		    sizeof(suffix)) == -1) {
+		if (locate_message_file(mailbox_dir_fd, rec.basename,
+		    &size, suffix, sizeof(suffix)) == -1) {
 			log_warnx("session %u: message %s (uid %u) indexed "
 			    "but missing on disk, skipped", session_id,
 			    rec.basename, m.uid);
@@ -354,5 +393,6 @@ done:
 	    sizeof(result)) == -1)
 		log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
 		    session_id);
+	return (0);
 }
 
blob - bd678679600d47a3a57b7ab7ec7b60a7bd061791
blob + 60e14a41865a3ce1ab8609c51f88be1aaff823e0
--- src/mbox_store.c
+++ src/mbox_store.c
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  *
@@ -36,19 +38,164 @@
 #include "log.h"
 #include "store_internal.h"
 
-/* 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
+/*
+ * What STORE does to one matched message, worked out before anything is
+ * changed and kept so the renames can be undone and the responses sent
+ * once the index is saved.
+ */
+struct store_step {
+	uint32_t	 seqno;
+	uint64_t	 modseq;
+	int		 modified;	/* RFC 7162 UNCHANGEDSINCE miss */
+	int		 changed;	/* flags or keywords differ */
+	int		 rename;	/* the file's name changes */
+	int		 in_new;
+	char		 oldsuffix[64];
+	char		 letters[8];
+};
+
+/*
+ * Plans one message: its new letters, keywords and index line. Returns
+ * 1 to store it, 0 to skip it (missing on disk, as before), -1 to refuse
+ * the whole STORE.
+ */
+static int
+store_plan(const struct imsg_mbox_store *req, const struct index_rec *rec,
+    uint64_t new_modseq, struct store_step *st, char *newline,
+    size_t linesize)
+{
+	char		 suffix[64], newkeywords[MBOX_FLAGS_MAX];
+	const char	*lp;
+	off_t		 size;
+	uint32_t	 old_sysflags, new_sysflags;
+	int		 len;
+
+	if (locate_message_file(mailbox_dir_fd, rec->basename, &size, suffix,
+	    sizeof(suffix)) == -1) {
+		log_warnx("session %u: message %s (uid %u) indexed but missing "
+		    "on disk, skipped", session_id, rec->basename, rec->uid);
+		return (0);
+	}
+	st->in_new = (suffix[0] == '\0');
+	(void)strlcpy(st->oldsuffix, suffix, sizeof(st->oldsuffix));
+
+	lp = strstr(suffix, "2,");
+	old_sysflags = letters_to_sysflags(lp != NULL ? lp + 2 : "");
+	switch (req->mode) {
+	case MBOX_STORE_SET:
+		new_sysflags = req->sysflags;
+		break;
+	case MBOX_STORE_ADD:
+		new_sysflags = old_sysflags | req->sysflags;
+		break;
+	case MBOX_STORE_REMOVE:
+	default:
+		new_sysflags = old_sysflags & ~req->sysflags;
+		break;
+	}
+
+	/*
+	 * A merged keyword set that does not fit refuses the STORE rather
+	 * than storing a truncation under a tagged OK (RFC 9051 SS6.4.6).
+	 */
+	if (merge_keywords(req->mode, rec->keywords, req->keywords,
+	    newkeywords, sizeof(newkeywords)) == -1) {
+		log_warnx("session %u: STORE: merged keyword set for uid %u "
+		    "exceeds %zu bytes, failing STORE", session_id, rec->uid,
+		    sizeof(newkeywords));
+		return (-1);
+	}
+	sysflags_to_letters(new_sysflags, st->letters, sizeof(st->letters));
+
+	st->changed = new_sysflags != old_sysflags ||
+	    strcmp(newkeywords, rec->keywords) != 0;
+	st->modseq = st->changed ? new_modseq : rec->modseq;
+
+	/* once STORE touches a message it lives in cur/ with ":2,<letters>" */
+	st->rename = st->in_new ||
+	    strcmp(lp != NULL ? lp + 2 : "", st->letters) != 0;
+
+	len = snprintf(newline, linesize, "%u:%s:%s:%llu", rec->uid,
+	    rec->basename, newkeywords, (unsigned long long)st->modseq);
+	if (len < 0 || (size_t)len >= linesize) {
+		log_warnx("session %u: new index line too long for %s",
+		    session_id, rec->basename);
+		return (-1);
+	}
+	return (1);
+}
+
+/* The file's name before and after; -1 if either does not fit. */
+static int
+store_paths(const char *basename, const struct store_step *st,
+    char *oldpath, size_t oldsize, char *newpath, size_t newsize)
+{
+	if (snprintf(oldpath, oldsize, "%s/%s%s", st->in_new ? "new" : "cur",
+	    basename, st->oldsuffix) >= (int)oldsize ||
+	    snprintf(newpath, newsize, "cur/%s:2,%s", basename,
+	    st->letters) >= (int)newsize) {
+		log_warnx("session %u: path too long for %s", session_id,
+		    basename);
+		return (-1);
+	}
+	return (0);
+}
+
+/*
+ * Puts back the renames a failed STORE made, newest first. A rename
+ * that cannot be undone is logged and stepped over, as COPY's rollback
+ * does, since nothing better is available.
+ */
+static void
+store_undo(const struct mbox_index *idx, const struct store_step *steps,
+    size_t nsteps)
+{
+	struct index_rec	 rec;
+	char			 oldpath[600], newpath[600];
+	size_t			 k;
+
+	for (k = nsteps; k > 0; k--) {
+		const struct store_step	*st = &steps[k - 1];
+
+		if (st->modified || !st->rename)
+			continue;
+		if (index_parse_line(idx->lines[st->seqno - 1], &rec) == -1 ||
+		    store_paths(rec.basename, st, oldpath, sizeof(oldpath),
+		    newpath, sizeof(newpath)) == -1)
+			continue;
+		if (renameat(mailbox_dir_fd, newpath, mailbox_dir_fd,
+		    oldpath) == -1)
+			log_warn("session %u: STORE rollback: rename %s -> %s, "
+			    "left with the new flags", session_id, newpath,
+			    oldpath);
+	}
+}
+
+/*
+ * IMSG_MBOX_STORE (RFC 9051 SS6.4.6) under LOCK_EX. A first pass plans
+ * every matched message and changes nothing, so a STORE refused for its
+ * input touches no file. The second renames and edits the in-memory
+ * index; if a rename or the save then fails, the renames are undone. The
+ * FETCH echoes and RFC 7162 MODIFIED reports go out only once the index
+ * is saved, so a NO means nothing changed.
+ *
+ * Returns 0 having answered the listener, or 1 without answering because
+ * another session holds the index lock; store.c runs it again for that.
+ */
+int
 handle_mbox_store(struct imsg_mbox_store *req, const struct seq_range *ranges,
     uint32_t nranges, struct imsgev *iev)
 {
+	int			 locked;
 	struct mbox_index	 idx;
 	struct imsg_mbox_result	 result;
 	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;
+	struct store_step	*steps = NULL, *grown;
+	size_t			 nsteps = 0, maxsteps = 0, k;
+	uint32_t		 i, nresolved, max_hi, sent = 0;
+	uint64_t		 new_modseq, reported = 0;
+	int			 ok = 1, changed = 0, pass;
 
 	memset(&idx, 0, sizeof(idx));
 
@@ -58,56 +205,133 @@ handle_mbox_store(struct imsg_mbox_store *req, const s
 		ok = 0;
 		goto done;
 	}
-
-	if (index_lock_acquire(&il, LOCK_EX) == -1) {
+	/*
+	 * Nothing above this point has touched the mailbox or this session,
+	 * so returning here is free and the command can simply be run again.
+	 */
+	locked = index_lock_acquire(mailbox_dir_fd, &il, LOCK_EX | LOCK_NB);
+	if (locked == 1)
+		return (1);
+	if (locked == -1) {
 		ok = 0;
 		goto done;
 	}
-
 	if (index_load(il.fd, &idx) == -1) {
 		ok = 0;
 		goto done;
 	}
 
+	reported = idx.highestmodseq;
 	new_modseq = idx.highestmodseq + 1;
 
-	/* RFC 9051 SS6.4.9: UID STORE's sequence-set is UID-space, same seqset_resolve() switch as handle_mbox_fetch() */
+	/* RFC 9051 SS6.4.9: UID STORE's set is UIDs, 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);
 
-	for (i = 1; i <= (uint32_t)idx.nlines; i++) {
-		struct imsg_mbox_fetch_meta	 meta;
-		struct index_rec		 rec;
-		const char			*lp;
-		uint32_t			 old_sysflags, new_sysflags;
-		char				 suffix[64], newletters[8];
-		char				 newkeywords[MBOX_FLAGS_MAX];
-		char				 newline[STORE_INDEX_LINE_MAX];
-		char				 oldpath[600], newpath[600];
-		off_t				 size;
-		uint64_t			 this_modseq;
-		enum seqset_pos			 pos;
-		int				 in_new, len, real_change, send_fetch;
+	for (pass = 0; pass < 2 && ok; pass++) {
+		for (i = 1; i <= (uint32_t)idx.nlines; i++) {
+			struct index_rec	 rec;
+			struct store_step	 st;
+			enum seqset_pos		 pos;
+			char			 newline[STORE_INDEX_LINE_MAX];
+			char			 oldpath[600], newpath[600];
+			char			*dup;
+			int			 rc;
 
-		if (index_parse_line(idx.lines[i - 1], &rec) == -1)
-			continue;
+			if (index_parse_line(idx.lines[i - 1], &rec) == -1)
+				continue;
+			pos = seqset_position(resolved, nresolved, max_hi,
+			    req->by_uid, rec.uid, i);
+			if (pos == SEQSET_PAST_END)
+				break;
+			if (pos == SEQSET_SKIP)
+				continue;
 
-		/* same range check as handle_mbox_fetch(), and now literally the same function */
-		pos = seqset_position(resolved, nresolved, max_hi, req->by_uid,
-		    rec.uid, i);
-		if (pos == SEQSET_PAST_END)
-			break;
-		if (pos == SEQSET_SKIP)
-			continue;
+			memset(&st, 0, sizeof(st));
+			st.seqno = i;
+			if (req->has_unchangedsince &&
+			    rec.modseq > req->unchangedsince) {
+				st.modified = 1;
+				st.modseq = rec.modseq;
+			} else {
+				rc = store_plan(req, &rec, new_modseq, &st,
+				    newline, sizeof(newline));
+				if (rc == 0)
+					continue;
+				if (rc == -1 || (st.rename &&
+				    store_paths(rec.basename, &st, oldpath,
+				    sizeof(oldpath), newpath,
+				    sizeof(newpath)) == -1)) {
+					ok = 0;
+					break;
+				}
+			}
+			if (pass == 0)
+				continue;
 
-		if (req->has_unchangedsince &&
-		    rec.modseq > req->unchangedsince) {
-			struct imsg_mbox_store_modified	mod;
+			if (nsteps == maxsteps) {
+				size_t	newmax = maxsteps ? maxsteps * 2 : 64;
 
+				if ((grown = reallocarray(steps, newmax,
+				    sizeof(*steps))) == NULL) {
+					log_warn("session %u: STORE: "
+					    "reallocarray", session_id);
+					ok = 0;
+					break;
+				}
+				steps = grown;
+				maxsteps = newmax;
+			}
+			if (st.modified) {
+				steps[nsteps++] = st;
+				continue;
+			}
+			if ((dup = strdup(newline)) == NULL) {
+				log_warn("session %u: strdup index line",
+				    session_id);
+				ok = 0;
+				break;
+			}
+			if (st.rename && renameat(mailbox_dir_fd, oldpath,
+			    mailbox_dir_fd, newpath) == -1) {
+				log_warn("session %u: rename %s -> %s",
+				    session_id, oldpath, newpath);
+				free(dup);
+				ok = 0;
+				break;
+			}
+			steps[nsteps++] = st;
+			free(idx.lines[i - 1]);
+			idx.lines[i - 1] = dup;
+			if (st.changed)
+				changed = 1;
+		}
+	}
+
+	if (ok && changed) {
+		idx.highestmodseq = new_modseq;
+		if (index_save(mailbox_dir_fd, &idx) == -1)
+			ok = 0;
+	}
+	if (!ok) {
+		store_undo(&idx, steps, nsteps);
+		goto done;
+	}
+	reported = idx.highestmodseq;
+
+	for (k = 0; k < nsteps; k++) {
+		const struct store_step		*st = &steps[k];
+		struct index_rec			 rec;
+
+		if (index_parse_line(idx.lines[st->seqno - 1], &rec) == -1)
+			continue;
+		if (st->modified) {
+			struct imsg_mbox_store_modified	 mod;
+
 			memset(&mod, 0, sizeof(mod));
-			mod.seqno = i;
+			mod.seqno = st->seqno;
 			mod.uid = rec.uid;
 			if (imsg_compose(&iev->ibuf, IMSG_MBOX_STORE_MODIFIED,
 			    0, 0, -1, &mod, sizeof(mod)) == -1)
@@ -116,99 +340,21 @@ handle_mbox_store(struct imsg_mbox_store *req, const s
 			continue;
 		}
 
-		if (locate_message_file(rec.basename, &size, suffix,
-		    sizeof(suffix)) == -1) {
-			log_warnx("session %u: message %s (uid %u) indexed "
-			    "but missing on disk, skipped", session_id,
-			    rec.basename, rec.uid);
-			continue;
-		}
-		in_new = (suffix[0] == '\0');
+		/* RFC 7162 SS3.1.3: UNCHANGEDSINCE echoes even under .SILENT */
+		if (!req->silent || req->has_unchangedsince) {
+			struct imsg_mbox_fetch_meta	 meta;
+			char				 newsuffix[16];
 
-		lp = strstr(suffix, "2,");
-		old_sysflags = letters_to_sysflags(lp != NULL ? lp + 2 : "");
-
-		switch (req->mode) {
-		case MBOX_STORE_SET:
-			new_sysflags = req->sysflags;
-			break;
-		case MBOX_STORE_ADD:
-			new_sysflags = old_sysflags | req->sysflags;
-			break;
-		case MBOX_STORE_REMOVE:
-		default:
-			new_sysflags = old_sysflags & ~req->sysflags;
-			break;
-		}
-		if (merge_keywords(req->mode, rec.keywords, req->keywords,
-		    newkeywords, sizeof(newkeywords)) == -1) {
-			/* Merged keyword set doesn't fit -- fail the STORE rather than store a mid-keyword truncation under a tagged OK; RFC 9051 SS6.4.6 permits this, and a NO is the only answer that doesn't 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));
-
-		real_change = (new_sysflags != old_sysflags) ||
-		    (strcmp(newkeywords, rec.keywords) != 0);
-		this_modseq = real_change ? new_modseq : rec.modseq;
-
-		/* once targeted by STORE, message moves new/ -> cur/ with explicit ":2,<letters>" suffix; never cur/ -> new/ */
-		if (in_new || strcmp(lp != NULL ? lp + 2 : "",
-		    newletters) != 0) {
-			if (snprintf(oldpath, sizeof(oldpath), "%s/%s%s",
-			    in_new ? "new" : "cur", rec.basename, suffix) >=
-			    (int)sizeof(oldpath) ||
-			    snprintf(newpath, sizeof(newpath), "cur/%s:2,%s",
-			    rec.basename, newletters) >= (int)sizeof(newpath)) {
-				log_warnx("session %u: path too long for %s",
-				    session_id, rec.basename);
-				continue;
-			}
-			if (rename(oldpath, newpath) == -1) {
-				log_warn("session %u: rename %s -> %s",
-				    session_id, oldpath, newpath);
-				continue;
-			}
-		}
-
-		len = snprintf(newline, sizeof(newline), "%u:%s:%s:%llu",
-		    rec.uid, rec.basename, newkeywords,
-		    (unsigned long long)this_modseq);
-		if (len < 0 || (size_t)len >= sizeof(newline)) {
-			log_warnx("session %u: new index line too long for "
-			    "%s", session_id, rec.basename);
-			continue;
-		}
-		free(idx.lines[i - 1]);
-		if ((idx.lines[i - 1] = strdup(newline)) == NULL) {
-			log_warn("session %u: strdup index line", session_id);
-			ok = 0;
-			goto done;
-		}
-		if (real_change)
-			changed = 1;
-
-		/* SS3.1.3: UNCHANGEDSINCE forces the FETCH echo even under .SILENT, for every message that passed */
-		send_fetch = !req->silent || req->has_unchangedsince;
-
-		if (send_fetch) {
-			char	newsuffix[16];
-
 			memset(&meta, 0, sizeof(meta));
-			meta.seqno = i;
+			meta.seqno = st->seqno;
 			meta.uid = rec.uid;
-			meta.modseq = this_modseq;
-			snprintf(newsuffix, sizeof(newsuffix), "2,%s",
-			    newletters);
-			build_flags_string(newsuffix, newkeywords, meta.flags,
+			meta.modseq = st->modseq;
+			(void)snprintf(newsuffix, sizeof(newsuffix), "2,%s",
+			    st->letters);
+			build_flags_string(newsuffix, rec.keywords, meta.flags,
 			    sizeof(meta.flags));
-
-			if (imsg_compose(&iev->ibuf, IMSG_MBOX_FETCH_META, 0,
-			    0, -1, &meta, sizeof(meta)) == -1)
+			if (imsg_compose(&iev->ibuf, IMSG_MBOX_FETCH_META, 0, 0,
+			    -1, &meta, sizeof(meta)) == -1)
 				log_warn("session %u: imsg_compose "
 				    "IMSG_MBOX_FETCH_META", session_id);
 			else
@@ -216,53 +362,76 @@ handle_mbox_store(struct imsg_mbox_store *req, const s
 		}
 	}
 
-	if (changed) {
-		idx.highestmodseq = new_modseq;
-		if (index_save(&idx) == -1)
-			ok = 0;
-	}
-
 done:
 	index_lock_release(&il);
+	free(steps);
 
 	memset(&result, 0, sizeof(result));
 	result.error = ok ? MBOX_OP_OK : MBOX_OP_ERR_GENERIC;
 	result.count = sent;
-	result.highestmodseq = idx.highestmodseq;
+	/* never a value the index does not hold */
+	result.highestmodseq = reported;
 	index_free(&idx);
 	if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
 	    sizeof(result)) == -1)
 		log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
 		    session_id);
+	return (0);
 }
 
-/* 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
+/*
+ * IMSG_MBOX_EXPUNGE (also used, silent=1, by CLOSE) under LOCK_EX. The
+ * compacted index is saved before any file is removed and before any
+ * EXPUNGE response is composed. A failed save removes nothing and
+ * reports nothing; a file that cannot be removed after the save is left
+ * in cur/ with no index entry, which nothing reads, rather than an index
+ * entry with no file, which every later command would trip over. The
+ * decrement rule of RFC 9051 SS7.5.1 makes this order matter: a client
+ * renumbers on each response it is sent.
+ *
+ * Returns 0 having answered the listener, or 1 without answering because
+ * another session holds the index lock; store.c runs it again for that.
+ */
+int
 handle_mbox_expunge(struct imsg_mbox_expunge *req,
     const struct seq_range *ranges, uint32_t nranges, struct imsgev *iev)
 {
+	struct expunged {
+		char		*line;
+		char		 suffix[64];
+		uint32_t	 seqno;
+	};
 	struct mbox_index	 idx;
 	struct imsg_mbox_result	 result;
 	struct index_lock	 il = INDEX_LOCK_INIT;
-	size_t			 in, out;
-	uint32_t		 sent = 0;
 	struct seq_range	 resolved[SEQSET_MAX_RANGES];
-	uint32_t		 nresolved = 0;
-	int			 ok = 1, changed = 0;
+	struct expunged		*gone = NULL, *grown;
+	size_t			 in, out, ngone = 0, maxgone = 0, k;
+	uint32_t		 sent = 0, nresolved = 0;
+	uint64_t		 reported = 0;
+	int			 ok = 1, locked;
 
 	memset(&idx, 0, sizeof(idx));
 
-	if (index_lock_acquire(&il, LOCK_EX) == -1) {
+	/* Nothing above has touched the mailbox, so a busy return is free. */
+	locked = index_lock_acquire(mailbox_dir_fd, &il, LOCK_EX | LOCK_NB);
+	if (locked == 1)
+		return (1);
+	if (locked == -1) {
 		ok = 0;
 		goto done;
 	}
-
 	if (index_load(il.fd, &idx) == -1) {
 		ok = 0;
 		goto done;
 	}
+	reported = idx.highestmodseq;
 
-	/* 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 */
+	/*
+	 * UID EXPUNGE: resolve the UID ranges once, same seqset_resolve() "*"
+	 * resolution as UID FETCH/STORE; plain EXPUNGE and CLOSE send no
+	 * ranges and never consult them
+	 */
 	if (req->by_uid)
 		nresolved = seqset_resolve(ranges, nranges,
 		    index_max_uid(&idx), 0, resolved);
@@ -270,58 +439,91 @@ handle_mbox_expunge(struct imsg_mbox_expunge *req,
 	out = 0;
 	for (in = 0; in < idx.nlines; in++) {
 		struct index_rec	 rec;
-		const char		*lp;
-		uint32_t		 sysflags;
-		char			 suffix[64], path[600];
-		off_t			 size;
+		const char	*lp;
+		uint32_t	 sysflags;
+		char		 suffix[64];
+		off_t		 size;
 
 		if (index_parse_line(idx.lines[in], &rec) == -1) {
 			idx.lines[out++] = idx.lines[in];
 			continue;
 		}
-
-		if (locate_message_file(rec.basename, &size, suffix,
-		    sizeof(suffix)) == -1) {
+		if (locate_message_file(mailbox_dir_fd, rec.basename,
+		    &size, suffix, sizeof(suffix)) == -1) {
 			log_warnx("session %u: message %s indexed but missing "
 			    "on disk, kept in index, not counted as "
 			    "expunged", session_id, rec.basename);
 			idx.lines[out++] = idx.lines[in];
 			continue;
 		}
-
 		lp = strstr(suffix, "2,");
 		sysflags = letters_to_sysflags(lp != NULL ? lp + 2 : "");
 		if (!(sysflags & MBOX_FLAG_DELETED)) {
 			idx.lines[out++] = idx.lines[in];
 			continue;
 		}
-
-		/* SS6.4.9: a \Deleted message outside the UID EXPUNGE ranges is kept, same as "not \Deleted" above */
+		/* SS6.4.9: \Deleted outside UID EXPUNGE's ranges is kept */
 		if (req->by_uid &&
 		    !seqset_contains(resolved, nresolved, rec.uid)) {
 			idx.lines[out++] = idx.lines[in];
 			continue;
 		}
 
+		/* a failed grow keeps the message and fails the command */
+		if (ngone == maxgone) {
+			size_t	newmax = maxgone ? maxgone * 2 : 64;
+
+			if ((grown = reallocarray(gone, newmax,
+			    sizeof(*gone))) == NULL) {
+				log_warn("session %u: EXPUNGE: reallocarray",
+				    session_id);
+				ok = 0;
+				idx.lines[out++] = idx.lines[in];
+				continue;
+			}
+			gone = grown;
+			maxgone = newmax;
+		}
+		gone[ngone].line = idx.lines[in];
+		(void)strlcpy(gone[ngone].suffix, suffix,
+		    sizeof(gone[ngone].suffix));
+		gone[ngone].seqno = (uint32_t)(out + 1);
+		ngone++;
+	}
+	idx.nlines = out;
+
+	if (ok && ngone > 0) {
+		idx.highestmodseq++;
+		if (index_save(mailbox_dir_fd, &idx) == -1)
+			ok = 0;
+	}
+	if (!ok) {
+		/* nothing was removed; both line sets are freed below */
+		goto done;
+	}
+	reported = idx.highestmodseq;
+
+	for (k = 0; k < ngone; k++) {
+		struct index_rec	 rec;
+		char		 path[600];
+
+		if (index_parse_line(gone[k].line, &rec) == -1)
+			continue;
 		if (snprintf(path, sizeof(path), "%s/%s%s",
-		    suffix[0] == '\0' ? "new" : "cur", rec.basename, suffix) >=
-		    (int)sizeof(path)) {
-			log_warnx("session %u: path too long for %s, kept "
-			    "in index", session_id, rec.basename);
-			idx.lines[out++] = idx.lines[in];
-			continue;
-		}
-		if (unlink(path) == -1 && errno != ENOENT) {
-			log_warn("session %u: unlink %s", session_id, path);
-			idx.lines[out++] = idx.lines[in];
-			continue;
-		}
+		    gone[k].suffix[0] == '\0' ? "new" : "cur", rec.basename,
+		    gone[k].suffix) >= (int)sizeof(path))
+			log_warnx("session %u: path too long for %s, left on "
+			    "disk", session_id, rec.basename);
+		else if (unlinkat(mailbox_dir_fd, path, 0) == -1 &&
+		    errno != ENOENT)
+			log_warn("session %u: unlink %s, left on disk with no "
+			    "index entry", session_id, path);
 
 		if (!req->silent) {
 			struct imsg_mbox_expunged	 exp;
 
 			memset(&exp, 0, sizeof(exp));
-			exp.seqno = (uint32_t)(out + 1);
+			exp.seqno = gone[k].seqno;
 			exp.uid = rec.uid;
 			if (imsg_compose(&iev->ibuf, IMSG_MBOX_EXPUNGED, 0, 0,
 			    -1, &exp, sizeof(exp)) == -1)
@@ -330,21 +532,11 @@ handle_mbox_expunge(struct imsg_mbox_expunge *req,
 			else
 				sent++;
 		}
-
-		free(idx.lines[in]);
-		changed = 1;
 	}
-	idx.nlines = out;
 
-	if (changed) {
-		idx.highestmodseq++;
-		if (index_save(&idx) == -1)
-			ok = 0;
-	}
-
 done:
-	/* CLOSE deselects regardless of expunge success, mirroring store_ipc.c's unconditional post-CLOSE state transition (RFC 9051 SS6.2). */
-	if (req->silent)
+	/* RFC 9051 SS6.4.1: CLOSE deselects only once its expunge is saved. */
+	if (req->silent && ok)
 		mailbox_selected = 0;
 
 	index_lock_release(&il);
@@ -352,10 +544,15 @@ done:
 	memset(&result, 0, sizeof(result));
 	result.error = ok ? MBOX_OP_OK : MBOX_OP_ERR_GENERIC;
 	result.count = sent;
-	result.highestmodseq = idx.highestmodseq;
+	/* never a value the index does not hold */
+	result.highestmodseq = reported;
 	index_free(&idx);
+	for (k = 0; k < ngone; k++)
+		free(gone[k].line);
+	free(gone);
 	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);
+	return (0);
 }
blob - 0fcbab43e49074d99b374784b8306b7ec06004aa
blob + 1816ffbc3688578bbecb0cdbe54a822939add354
--- src/mboxname.c
+++ src/mboxname.c
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  *
@@ -20,14 +22,28 @@
 #include "mboxname.h"
 #include "utf8.h"
 
-/* RFC 9051 SS5.1: "INBOX" names this user's primary mailbox, case-insensitively, in every command that takes a mailbox name. Callers peel it off before mailbox_name_syntax_ok(), which would accept it as an ordinary name and let a CREATE or DELETE through. */
+/*
+ * RFC 9051 SS5.1: "INBOX" names this user's primary mailbox,
+ * case-insensitively, in every command that takes a mailbox name. Callers peel
+ * it off before mailbox_name_syntax_ok(), which would accept it as an ordinary
+ * name and let a CREATE or DELETE through.
+ */
 int
 mailbox_name_is_inbox(const char *name)
 {
 	return (strcasecmp(name, "INBOX") == 0);
 }
 
-/* Returns 1 if `name` is acceptable as a mailbox name on this server, 0 otherwise. RFC 9051 SS5.1/SS5.1.1 plus this implementation's own storage rules; the caller decides what a refusal means on the wire, and INBOX is not handled here (it is a name this predicate accepts, and mailbox_name_is_inbox() peels it off before anyone asks). The store additionally checks that the name arrived NUL-terminated before calling this, since its copy comes off the imsg wire rather than out of its own parser -- see mailbox_name_valid() in store.c. */
+/*
+ * Returns 1 if `name` is acceptable as a mailbox name on this server, 0
+ * otherwise. RFC 9051 SS5.1/SS5.1.1 plus this implementation's own storage
+ * rules; the caller decides what a refusal means on the wire, and INBOX is not
+ * handled here (it is a name this predicate accepts, and
+ * mailbox_name_is_inbox() peels it off before anyone asks). The store
+ * additionally checks that the name arrived NUL-terminated before calling this,
+ * since its copy comes off the imsg wire rather than out of its own parser --
+ * see mailbox_name_valid() in store.c.
+ */
 int
 mailbox_name_syntax_ok(const char *name)
 {
@@ -37,7 +53,10 @@ mailbox_name_syntax_ok(const char *name)
 	if (len == 0 || len >= MBOX_NAME_MAX)
 		return (0);
 
-	/* RFC 9051 SS5.1: 8-bit mailbox names must comply with Net-Unicode; the encoding test is its own shared predicate again (see utf8.c). */
+	/*
+	 * RFC 9051 SS5.1: 8-bit mailbox names must comply with Net-Unicode; the
+	 * encoding test is its own shared predicate again (see utf8.c).
+	 */
 	if (!utf8_mailbox_ok(name))
 		return (0);
 
@@ -47,24 +66,36 @@ mailbox_name_syntax_ok(const char *name)
 		/* RFC 9051 SS5.1.1: "/" is the hierarchy delimiter */
 		if (c == '/')
 			return (0);
-		/* SS5.1 point 2: MAY refuse CTL/non-graphic names; taking that MAY for ASCII C0/DEL */
+		/*
+		 * SS5.1 pt 2: MAY refuse CTL/non-graphic names; taking that MAY
+		 * for C0/DEL
+		 */
 		if (c < 0x20 || c == 0x7f)
 			return (0);
 	}
 
-	/* "tmp"/"new"/"cur" are INBOX's own maildir internals, refusing them here prevents cross-mailbox corruption */
+	/* "tmp"/"new"/"cur" are INBOX internals; blocks cross-mailbox damage */
 	if (strcmp(name, "tmp") == 0 || strcmp(name, "new") == 0 ||
 	    strcmp(name, "cur") == 0)
 		return (0);
 
-	/* reject "." and ".." (DELETE "." would destroy INBOX) and the on-disk index filenames */
+	/* reject "." and ".." (destroys INBOX) and reserved on-disk names */
 	if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0)
 		return (0);
-	if (strcmp(name, STORE_INDEX_NAME) == 0 ||
+	if (mailbox_name_reserved(name))
+		return (0);
+
+	return (1);
+}
+
+int
+mailbox_name_reserved(const char *name)
+{
+	return (strcmp(name, STORE_INDEX_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);
+	    strcmp(name, STORE_UIDVALIDITY_NAME) == 0 ||
+	    strcmp(name, STORE_SUBSCRIPTIONS_NAME) == 0 ||
+	    strcmp(name, STORE_SUBSCRIPTIONS_TMP_NAME) == 0 ||
+	    strcmp(name, STORE_SUBSCRIPTIONS_LOCK_NAME) == 0);
 }
blob - 76c1a8120b66228859ccae6b480c878bb0264f72
blob + 307e23dad0ae85a3d8ded4a0742f9850fa89960c
--- src/mboxname.h
+++ src/mboxname.h
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  *
@@ -17,39 +19,36 @@
 #ifndef IMAPD_MBOXNAME_H
 #define IMAPD_MBOXNAME_H
 
-/*
- * The one mailbox-name syntax rule, shared by the listener's
- * listener_mailbox_name_valid() and the store's mailbox_name_valid().
- *
- * Those two validators stay separate on purpose, for the reason utf8.h gives:
- * the store does not trust the listener, and re-checking on the far side of
- * the imsg boundary is the point. What they must not do is disagree about the
- * RULE, and they have. mailbox_cmd.c's copy had drifted once already, missing
- * store.c's rejection of "." / ".." and of the on-disk index filenames; utf8.c
- * exists because the UTF-8 half of the same rule drifted before that. Twice is
- * enough: the predicate lives here now, and each side calls it.
- *
- * The reserved filenames below are part of the rule, not incidental to it. A
- * mailbox may not be named after one, because creating it would collide with
- * the real file in that maildir root. They moved here from store_internal.h,
- * which the listener deliberately does not include -- so the listener used to
- * open-code them as string literals, which is the drift that already happened
- * spelled out in advance. store_internal.h includes this header, so the store
- * side sees exactly the same four strings; what each file is FOR is documented
- * there, where the locking and UIDVALIDITY design that needs it lives.
- *
- * Like utf8.h, this header deliberately depends on nothing, so both sides can
- * include it without dragging in listener.h or store_internal.h.
- */
+/* The one mailbox-name syntax rule, shared by the listener's */
+/* listener_mailbox_name_valid() and the store's mailbox_name_valid(). The */
+/* two validators stay separate because the store does not trust the */
+/* listener, and re-checking across the imsg boundary is the point. What */
+/* they must not do is disagree about the rule, which they twice did. */
+/* The reserved filenames below are part of the rule: a mailbox may not be */
+/* named after one. What each file is FOR is documented in */
+/* store_internal.h, which includes this header. */
+/* Like utf8.h, this header depends on nothing, so either side can include */
+/* it without dragging in listener.h or store_internal.h. */
 
-#define STORE_INDEX_NAME	"imapd.index"
-#define STORE_INDEX_TMP_NAME	"imapd.index.tmp"
-#define STORE_INDEX_LOCK_NAME	"imapd.index.lock"
-#define STORE_UIDVALIDITY_NAME	"imapd.uidvalidity"
+#define STORE_INDEX_NAME		"imapd.index"
+#define STORE_INDEX_TMP_NAME		"imapd.index.tmp"
+#define STORE_INDEX_LOCK_NAME		"imapd.index.lock"
+#define STORE_UIDVALIDITY_NAME		"imapd.uidvalidity"
+#define STORE_SUBSCRIPTIONS_NAME	"imapd.subscriptions"
+#define STORE_SUBSCRIPTIONS_TMP_NAME	"imapd.subscriptions.tmp"
+#define STORE_SUBSCRIPTIONS_LOCK_NAME	"imapd.subscriptions.lock"
 
 int	 mailbox_name_syntax_ok(const char *);
 
 /*
+ * 1 if `name` is one of the reserved filenames above. mailbox_name_syntax_ok()
+ * refuses such a name; the store also asks directly, to skip a directory named
+ * after one quietly while walking the maildir root rather than reporting it as
+ * a mailbox it could not list.
+ */
+int	 mailbox_name_reserved(const char *);
+
+/*
  * RFC 9051 SS5.1: INBOX is case-insensitive and always exists, so it is not a
  * name either side validates -- it is peeled off first and mapped to the
  * maildir root. One definition rather than two: the store and the listener
blob - 99ef172756d73563a0f92eec8aaa755466147e42
blob + a3167586e86a8c6202f5d55df4ea81a7e7614998
--- src/mime.c
+++ src/mime.c
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  *
@@ -14,7 +16,10 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* mime.c: header and MIME parsing shared across FETCH, ENVELOPE, and BODYSTRUCTURE -- content-type, multipart splitting, and per-part location. */
+/*
+ * mime.c: header and MIME parsing shared across FETCH, ENVELOPE, and
+ * BODYSTRUCTURE -- content-type, multipart splitting, and per-part location.
+ */
 
 #include <sys/types.h>
 #include <sys/file.h>
@@ -36,17 +41,175 @@
 #include "log.h"
 #include "store_internal.h"
 
-/* Shared cur/ fallback scan for locate_message_file()/open_message_file(): finds the first "<basename>:*" entry, writes its "cur/<name>" path to path[] and optionally its suffix to suffix_out; returns 0 on match, -1 on opendir failure or no match. */
+/*
+ * One command's view of cur/. A walk over N messages used to read the
+ * directory once per message, which is N reads of N entries; this reads it
+ * once. Measured on premio before the change: 88% of a FETCH over 10,000
+ * messages, and 99% over 100,000.
+ *
+ * A name absent from the snapshot falls through to a real scan below, so a
+ * file created after the snapshot was taken is still found and the snapshot
+ * can only ever cost a lookup, never change its answer.
+ * cur_snapshot_discard() drops it at the end of every request (store.c),
+ * the same lifetime as the index snapshot a walk already works from.
+ */
+static struct {
+	int	  dfd;		/* directory described, -1 when empty */
+	int	  failed;	/* a build that failed is not retried */
+	char	**names;	/* entry names, sorted by strcmp(3) */
+	size_t	  n;
+} cur_snap = { -1, 0, NULL, 0 };
+
 static int
-scan_cur_for_basename(const char *basename, char *path, size_t pathsize,
+cur_snap_cmp(const void *a, const void *b)
+{
+	return (strcmp(*(const char * const *)a, *(const char * const *)b));
+}
+
+void
+cur_snapshot_discard(void)
+{
+	size_t	i;
+
+	for (i = 0; i < cur_snap.n; i++)
+		free(cur_snap.names[i]);
+	free(cur_snap.names);
+	cur_snap.names = NULL;
+	cur_snap.n = 0;
+	cur_snap.dfd = -1;
+	cur_snap.failed = 0;
+}
+
+/* Reads cur/ into the snapshot; -1 having left it empty and not retryable. */
+static int
+cur_snapshot_build(int dfd)
+{
+	DIR			*dp = NULL;
+	const struct dirent	*de;
+	char			**names = NULL, **tmp;
+	size_t			  n = 0, cap = 0;
+	int			  curfd;
+
+	cur_snapshot_discard();
+
+	curfd = openat(dfd, "cur", O_RDONLY | O_DIRECTORY);
+	if (curfd != -1 && (dp = fdopendir(curfd)) == NULL)
+		close(curfd);
+	if (dp == NULL) {
+		cur_snap.failed = 1;
+		return (-1);
+	}
+	while ((de = readdir(dp)) != NULL) {
+		/* "." and ".."; maildir delivery creates no dotfiles */
+		if (de->d_name[0] == '.')
+			continue;
+		if (n == cap) {
+			cap = cap != 0 ? cap * 2 : 64;
+			tmp = reallocarray(names, cap, sizeof(*names));
+			if (tmp == NULL)
+				goto fail;
+			names = tmp;
+		}
+		if ((names[n] = strdup(de->d_name)) == NULL)
+			goto fail;
+		n++;
+	}
+	closedir(dp);
+	if (n > 1)
+		qsort(names, n, sizeof(*names), cur_snap_cmp);
+	cur_snap.names = names;
+	cur_snap.n = n;
+	cur_snap.dfd = dfd;
+	return (0);
+
+fail:
+	log_warn("session %u: reading cur/ into a snapshot", session_id);
+	while (n > 0)
+		free(names[--n]);
+	free(names);
+	closedir(dp);
+	cur_snap.failed = 1;
+	return (-1);
+}
+
+/*
+ * The "<basename>:*" entry in the snapshot, or NULL. Lower bound by binary
+ * search then one prefix test: a maildir basename is unique, so at most one
+ * entry can match.
+ */
+static const char *
+cur_snapshot_find(int dfd, const char *basename, size_t baselen)
+{
+	size_t	lo = 0, hi, mid;
+
+	if (cur_snap.dfd != dfd) {
+		if (cur_snap.failed)
+			return (NULL);
+		if (cur_snapshot_build(dfd) == -1)
+			return (NULL);
+	}
+	hi = cur_snap.n;
+	while (lo < hi) {
+		mid = lo + (hi - lo) / 2;
+		if (strncmp(cur_snap.names[mid], basename, baselen) < 0)
+			lo = mid + 1;
+		else
+			hi = mid;
+	}
+	if (lo < cur_snap.n &&
+	    strncmp(cur_snap.names[lo], basename, baselen) == 0 &&
+	    cur_snap.names[lo][baselen] == ':')
+		return (cur_snap.names[lo]);
+	return (NULL);
+}
+
+/* Writes one matched cur/ entry out as a path and a flag suffix. */
+static int
+cur_entry_take(const char *name, size_t baselen, char *path, size_t pathsize,
     char *suffix_out, size_t suffix_out_size)
 {
+	if (snprintf(path, pathsize, "cur/%s", name) >= (int)pathsize)
+		return (-1);
+	if (suffix_out != NULL && strlcpy(suffix_out, 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, name);
+		return (-1);
+	}
+	return (0);
+}
+
+/*
+ * Shared cur/ fallback lookup for locate_message_file()/open_message_file():
+ * finds the "<basename>:*" entry, writes its "cur/<name>" path to path[] and
+ * optionally its suffix to suffix_out; returns 0 on match, -1 on failure or
+ * no match.
+ */
+static int
+scan_cur_for_basename(int dfd, const char *basename, char *path,
+    size_t pathsize,
+    char *suffix_out, size_t suffix_out_size)
+{
 	DIR			*dp;
 	const struct dirent	*de;
+	const char		*hit;
 	size_t			 baselen = strlen(basename);
 	int			 rv = -1;
 
-	if ((dp = opendir("cur")) == NULL) {
+	if ((hit = cur_snapshot_find(dfd, basename, baselen)) != NULL)
+		return (cur_entry_take(hit, baselen, path, pathsize,
+		    suffix_out, suffix_out_size));
+
+	/* absent from the snapshot, so it may have arrived since: look */
+	{
+		int	curfd;
+
+		dp = NULL;
+		curfd = openat(dfd, "cur", O_RDONLY | O_DIRECTORY);
+		if (curfd != -1 && (dp = fdopendir(curfd)) == NULL)
+			close(curfd);
+	}
+	if (dp == NULL) {
 		if (errno != ENOENT)
 			log_warn("session %u: opendir cur", session_id);
 		return (-1);
@@ -55,18 +218,8 @@ scan_cur_for_basename(const char *basename, char *path
 		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;
+		rv = cur_entry_take(de->d_name, baselen, path, pathsize,
+		    suffix_out, suffix_out_size);
 		break;
 	}
 	closedir(dp);
@@ -74,8 +227,8 @@ scan_cur_for_basename(const char *basename, char *path
 }
 
 int
-locate_message_file(const char *basename, off_t *size_out, char *suffix_out,
-    size_t suffix_out_size)
+locate_message_file(int dfd, const char *basename, off_t *size_out,
+    char *suffix_out, size_t suffix_out_size)
 {
 	struct stat	 st;
 	char		 path[600];
@@ -88,7 +241,7 @@ locate_message_file(const char *basename, off_t *size_
 		    basename);
 		return (-1);
 	}
-	if (stat(path, &st) == 0) {
+	if (fstatat(dfd, path, &st, 0) == 0) {
 		*size_out = st.st_size;
 		return (0);
 	}
@@ -97,18 +250,21 @@ locate_message_file(const char *basename, off_t *size_
 		return (-1);
 	}
 
-	if (scan_cur_for_basename(basename, path, sizeof(path), suffix_out,
-	    suffix_out_size) == -1)
+	if (scan_cur_for_basename(dfd, basename, path, sizeof(path),
+	    suffix_out, suffix_out_size) == -1)
 		return (-1);
-	if (stat(path, &st) == -1)
+	if (fstatat(dfd, path, &st, 0) == -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)
+open_message_file(int dfd, const char *basename)
 {
 	char	 path[600];
 	int	 fd;
@@ -119,21 +275,26 @@ open_message_file(const char *basename)
 		    basename);
 		return (-1);
 	}
-	if ((fd = open(path, O_RDONLY)) != -1)
+	if ((fd = openat(dfd, path, O_RDONLY)) != -1)
 		return (fd);
 	if (errno != ENOENT) {
 		log_warn("session %u: open %s", session_id, path);
 		return (-1);
 	}
 
-	if (scan_cur_for_basename(basename, path, sizeof(path), NULL, 0) == -1)
+	if (scan_cur_for_basename(dfd, basename, path, sizeof(path), NULL,
+	    0) == -1)
 		return (-1);
-	return (open(path, O_RDONLY));
+	return (openat(dfd, 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)
+read_message_header(int dfd, const char *basename, char **buf_out,
+    uint32_t *len_out)
 {
 	char	 readbuf[FETCH_HEADER_MAX + 1];
 	int	 fd;
@@ -141,14 +302,15 @@ read_message_header(const char *basename, char **buf_o
 	size_t	 i, hdrend = 0, limit, sepindex = 0;
 	int	 found_sep = 0;
 
-	if ((fd = open_message_file(basename)) == -1)
+	if ((fd = open_message_file(dfd, basename)) == -1)
 		return (-1);
 
 	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 */
+				/* as mbox_copy.c's staging read does */
+				continue;
 			log_warn("session %u: read message header (%s)",
 			    session_id, basename);
 			close(fd);
@@ -165,7 +327,12 @@ read_message_header(const char *basename, char **buf_o
 		found_sep = 1;
 	}
 
-	/* NUL check is bounded to where the separator was found; the "i + 1 < total" term looks like an off-by-one but isn't -- when limit == total the unexamined final byte is always the separator's trailing '\n', never a NUL. */
+	/*
+	 * NUL check is bounded to where the separator was found; the "i + 1 <
+	 * total" term looks like an off-by-one but isn't -- when limit == total
+	 * the unexamined final byte is always the separator's 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') {
@@ -193,26 +360,42 @@ read_message_header(const char *basename, char **buf_o
 	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)
+read_message_body(int dfd, 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;
 	struct stat	 st;
 	int		 fd;
 	ssize_t		 n, total = 0;
-	/* sepindex is size_t, not int: readbuf's size comes from the configurable maxlen (bodystructure_read_max), and narrowing to int was only safe because parse.y caps it at 1GB -- past 2GB it would go negative, pointing before the allocation and wrapping the memcpy length. */
+	/*
+	 * sepindex is size_t, not int: readbuf's size comes from the
+	 * configurable maxlen (bodystructure_read_max), and narrowing to int
+	 * was only safe because parse.y caps it at 1GB -- past 2GB it would go
+	 * negative, pointing before the allocation and wrapping the memcpy
+	 * length.
+	 */
 	size_t		 i, hdrend, sepindex = 0;
 
 	*buf_out = NULL;
 	*len_out = 0;
 
-	if ((fd = open_message_file(basename)) == -1)
+	if ((fd = open_message_file(dfd, basename)) == -1)
 		return (-1);
 
-	/* Size the buffer to the message, not the configured ceiling -- allocating the full cap (up to 1GB) per message made "FETCH 1:* BODYSTRUCTURE" do one huge malloc(3)+read(2) per message; maxlen+1 slack is kept at/over the cap so the "exceeds maxlen" check still fires. */
+	/*
+	 * Size the buffer to the message, not the configured ceiling --
+	 * allocating the full cap (up to 1GB) per message made "FETCH 1:*
+	 * BODYSTRUCTURE" do one huge malloc(3)+read(2) per message; maxlen+1
+	 * slack is kept at/over the cap so the "exceeds maxlen" check 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)
@@ -229,7 +412,8 @@ read_message_body(const char *basename, int text_only,
 		n = read(fd, readbuf + total, readbuf_size - total);
 		if (n == -1) {
 			if (errno == EINTR)
-				continue;	/* as mbox_copy.c's staging read does */
+				/* as mbox_copy.c's staging read does */
+				continue;
 			log_warn("session %u: read message body (%s)",
 			    session_id, basename);
 			close(fd);
@@ -286,7 +470,10 @@ 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)
 {
@@ -312,7 +499,12 @@ header_field_name_matches(const char *name, size_t nam
 	return (0);
 }
 
-/* One logical header field: the byte range [start, end) covering its first line and every RFC 5322 SS2.2.3 obs-fold continuation that belongs to it, plus line_end (the first line's end, before CR/LF) and colon (the ':' index, or line_end when the line has none). */
+/*
+ * One logical header field: the byte range [start, end) covering its first line
+ * and every RFC 5322 SS2.2.3 obs-fold continuation that belongs to it, plus
+ * line_end (the first line's end, before CR/LF) and colon (the ':' index, or
+ * line_end when the line has none).
+ */
 struct hdr_field {
 	size_t	start;
 	size_t	colon;
@@ -320,7 +512,14 @@ struct hdr_field {
 	size_t	end;
 };
 
-/* Walks one field forward from *off. Returns 1 for a field, 0 for the blank line that ends the header (with [start, end) covering that line, since a HEADER.FIELDS response has to include it), and -1 for a header that ran out without one. Both callers used to write this out themselves; the -1/0 split in particular was expressed at each site as two different breaks setting two different values, which is exactly the distinction that is easy to get wrong. */
+/*
+ * Walks one field forward from *off. Returns 1 for a field, 0 for the blank
+ * line that ends the header (with [start, end) covering that line, since a
+ * HEADER.FIELDS response has to include it), and -1 for a header that ran out
+ * without one. Both callers used to write this out themselves; the -1/0 split
+ * in particular was expressed at each site as two different breaks setting two
+ * different values, which is exactly the distinction that is easy to get wrong.
+ */
 static int
 hdr_next_field(const char *hdr, size_t hdrlen, size_t *off,
     struct hdr_field *f)
@@ -352,7 +551,11 @@ hdr_next_field(const char *hdr, size_t hdrlen, size_t 
 			break;
 	}
 
-	/* obs-fold continuation lines start with SP/HTAB and belong to this same field, so *off must land past them either way -- a caller that ignored them would resume mid-field */
+	/*
+	 * obs-fold continuation lines start with SP/HTAB and belong to this
+	 * same field, so *off must land past them either way -- a caller that
+	 * ignored them would resume mid-field
+	 */
 	while (*off < hdrlen && (hdr[*off] == ' ' || hdr[*off] == '\t')) {
 		j = *off;
 		while (j < hdrlen && hdr[j] != '\n')
@@ -368,10 +571,15 @@ hdr_next_field(const char *hdr, size_t hdrlen, size_t 
 	return (1);
 }
 
-/* Implements BODY.PEEK[HEADER.FIELDS[.NOT] (fields_spec)] (RFC 9051 SS6.4.5.1): splits the raw header on RFC 5322 obs-fold lines and copies matching fields verbatim. */
+/*
+ * Implements BODY.PEEK[HEADER.FIELDS[.NOT] (fields_spec)] (RFC 9051 SS6.4.5.1):
+ * splits the raw header on RFC 5322 obs-fold lines and 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)
+read_message_header_fields(int dfd, const char *basename,
+    const char *fields_spec, int want_not, char **buf_out,
+    uint32_t *len_out)
 {
 	char		*hdrbuf = NULL;
 	uint32_t	 hdrlen = 0;
@@ -383,10 +591,13 @@ read_message_header_fields(const char *basename, const
 	*buf_out = NULL;
 	*len_out = 0;
 
-	if (read_message_header(basename, &hdrbuf, &hdrlen) == -1)
+	if (read_message_header(dfd, basename, &hdrbuf, &hdrlen) == -1)
 		return (-1);
 
-	/* filtered output can never exceed the unfiltered header's size, every byte copied below comes verbatim from hdrbuf */
+	/*
+	 * filtered output can never exceed the unfiltered header's size, every
+	 * byte copied below comes verbatim from hdrbuf
+	 */
 	if ((out = malloc(hdrlen)) == NULL) {
 		log_warn("session %u: malloc HEADER.FIELDS buffer (%s)",
 		    session_id, basename);
@@ -400,9 +611,14 @@ read_message_header_fields(const char *basename, const
 
 		r = hdr_next_field(hdrbuf, hdrlen, &off, &f);
 		if (r == -1)
-			break;		/* rc stays -1: no terminating blank line */
+			/* rc stays -1: no terminating blank line */
+			break;
 		if (r == 0) {
-			/* the blank line is copied too: SS6.4.5's HEADER.FIELDS data is a header block, and a header block ends with one */
+			/*
+			 * the blank line is copied too: SS6.4.5's HEADER.FIELDS
+			 * data is a header block, and a header block ends with
+			 * one
+			 */
 			memcpy(out + outlen, hdrbuf + f.start,
 			    f.end - f.start);
 			outlen += f.end - f.start;
@@ -433,7 +649,10 @@ 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)
@@ -450,7 +669,11 @@ extract_header_field(const char *hdr, size_t hdrlen, c
 		char			*out;
 		size_t			 outlen = 0;
 
-		/* a blank line (header ended, name never seen) and a header that ran out both mean "no value", which is what this returned for either before the walk was shared */
+		/*
+		 * a blank line (header ended, name never seen) and a header
+		 * that ran out both mean "no value", which is what this
+		 * returned for either before the walk was shared
+		 */
 		if (hdr_next_field(hdr, hdrlen, &off, &f) != 1)
 			return (-1);
 
@@ -458,7 +681,11 @@ extract_header_field(const char *hdr, size_t hdrlen, c
 		    strncasecmp(hdr + f.start, name, namelen) != 0)
 			continue;
 
-		/* RFC 5322 SS2.2.3: the value runs from the colon to the end of the last fold line, with CRLF/LF dropped and leading WSP trimmed -- f.end already covers the folds */
+		/*
+		 * RFC 5322 SS2.2.3: the value runs from the colon to the end of
+		 * the last fold line, with CRLF/LF dropped and leading WSP
+		 * trimmed -- f.end already covers the folds
+		 */
 		vstart = f.colon + 1;
 		vend = f.end;
 		if (vend > vstart && hdr[vend - 1] == '\n')
@@ -511,14 +738,17 @@ find_header_body_split(const char *buf, size_t len, si
 	return (-1);
 }
 
-/* RFC 2045 SS5.1 tspecials; a `token` is any US-ASCII CHAR except SPACE, CTLs, or one of these */
+/* RFC 2045 SS5.1 tspecials; a `token` excludes SPACE, CTLs, and these */
 int
 mime_is_tspecial(char c)
 {
 	return (strchr("()<>@,;:\\\"/[]?=", c) != NULL);
 }
 
-/* reads one RFC 2045 token/quoted-string at s[*pos], advancing *pos; undoes RFC 822 quoted-pair escaping ("\" + one CHAR) in quotes */
+/*
+ * reads one RFC 2045 token/quoted-string at s[*pos], advancing *pos; undoes RFC
+ * 822 quoted-pair escaping ("\" + one CHAR) in quotes
+ */
 int
 mime_read_token_or_qstring(const char *s, size_t len, size_t *pos,
     char *out, size_t outsize)
@@ -537,7 +767,13 @@ mime_read_token_or_qstring(const char *s, size_t len, 
 				(*pos)++;
 				c = s[*pos];
 			}
-			/* Unlike the unquoted-token branch below, this branch applied no character class, letting a lone CR that extract_header_field() didn't unfold ride into the BODYSTRUCTURE; parse_content_type() degrades to its RFC 2045 SS5.2 default on -1. */
+			/*
+			 * Unlike the unquoted-token branch below, this branch
+			 * applied no character class, letting a lone CR that
+			 * extract_header_field() didn't unfold ride into the
+			 * BODYSTRUCTURE; 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)
@@ -566,7 +802,10 @@ mime_read_token_or_qstring(const char *s, size_t len, 
 	return (0);
 }
 
-/* in-place ASCII-range uppercase, to canonicalize MIME type/subtype/attribute names (RFC 9051 SS7.5.2 examples); values left as-is */
+/*
+ * in-place ASCII-range uppercase, to canonicalize MIME type/subtype/attribute
+ * names (RFC 9051 SS7.5.2 examples); values left as-is
+ */
 void
 mime_str_upper(char *s)
 {
@@ -576,7 +815,10 @@ mime_str_upper(char *s)
 	}
 }
 
-/* RFC 2045 SS5.1 Content-Type parse into type/subtype (uppercased) + params_fmt_out (RFC 9051 body-fld-param); SS5.2 default on failure */
+/*
+ * RFC 2045 SS5.1 Content-Type parse into type/subtype (uppercased) +
+ * params_fmt_out (RFC 9051 body-fld-param); SS5.2 default on failure
+ */
 int
 parse_content_type(const char *hdr, size_t hdrlen, char *type_out,
     size_t typesize, char *subtype_out, size_t subtypesize,
@@ -589,7 +831,8 @@ parse_content_type(const char *hdr, size_t hdrlen, cha
 	size_t	 plen = 0;
 	int	 nparams = 0;
 	int	 use_default = 0;
-	size_t	 pfsize = (params_fmt_outsize > 0) ? params_fmt_outsize - 1 : 0;	/* -1: envbuf_append*() never NUL-terminates, reserve a byte */
+	/* -1: envbuf_append*() never NUL-terminates, reserve a byte */
+	size_t	 pfsize = (params_fmt_outsize > 0) ? params_fmt_outsize - 1 : 0;
 
 	*has_boundary_out = 0;
 	boundary_out[0] = '\0';
@@ -671,7 +914,10 @@ parse_content_type(const char *hdr, size_t hdrlen, cha
 		nparams++;
 
 		if (strcasecmp(attr, "BOUNDARY") == 0) {
-			/* boundary_out is RFC 2046 SS5.1.1-sized (70+1); an oversized value is "no BOUNDARY found", not truncated */
+			/*
+			 * boundary_out is RFC 2046 SS5.1.1-sized (70+1); an
+			 * oversized value is "no BOUNDARY found", not truncated
+			 */
 			if (strlcpy(boundary_out, value, boundary_outsize) <
 			    boundary_outsize)
 				*has_boundary_out = 1;
@@ -692,12 +938,16 @@ parse_content_type(const char *hdr, size_t hdrlen, cha
 	return (0);
 }
 
-/* RFC 2046 SS5.1.1: splits multipart body into body-part spans (not yet header/body split, caller uses find_header_body_split()) */
+/*
+ * RFC 2046 SS5.1.1: splits multipart body into body-part spans (not yet
+ * header/body split, caller uses find_header_body_split())
+ */
 int
 split_multipart(const char *body, size_t bodylen, const char *boundary,
     size_t *part_starts, size_t *part_ends, int *nparts_out, int maxparts)
 {
-	char	 needle[2 + 70 + 1];	/* "--" + boundary; RFC 2046 SS5.1.1 caps boundary at 70 characters */
+	/* "--" + boundary; RFC 2046 SS5.1.1 caps boundary at 70 characters */
+	char	 needle[2 + 70 + 1];
 	size_t	 needlelen;
 	size_t	 pos = 0;
 	int	 found_first = 0;
@@ -741,7 +991,11 @@ split_multipart(const char *body, size_t bodylen, cons
 			after++;
 		if (after < bodylen && body[after] != '\r' &&
 		    body[after] != '\n') {
-			pos++;	/* not followed by CRLF/LF/EOF, coincidental match inside content, not a delimiter */
+			/*
+			 * not CRLF/LF/EOF: coincidental match, not a real
+			 * delimiter
+			 */
+			pos++;
 			continue;
 		}
 
@@ -749,7 +1003,11 @@ split_multipart(const char *body, size_t bodylen, cons
 			size_t	 content_end = pos;
 			size_t	 part_start = part_starts[n - 1];
 
-			/* bound strip at part_start: else an empty part underflows the unsigned length into a huge memcpy/scan bound */
+			/*
+			 * bound strip at part_start: else an empty part
+			 * underflows the unsigned length into a huge
+			 * memcpy/scan bound
+			 */
 			if (content_end >= part_start + 2 &&
 			    body[content_end - 2] == '\r' &&
 			    body[content_end - 1] == '\n')
@@ -771,7 +1029,11 @@ split_multipart(const char *body, size_t bodylen, cons
 		else if (after < bodylen && body[after] == '\n')
 			after += 1;
 		else
-			break;	/* delimiter runs to end of body with no CRLF, no body-part can follow */
+			/*
+			 * delimiter runs to EOF with no CRLF, no body-part can
+			 * follow
+			 */
+			break;
 
 		if (n >= maxparts)
 			return (-1);
@@ -788,7 +1050,10 @@ split_multipart(const char *body, size_t bodylen, cons
 	return (0);
 }
 
-/* parses a dotted section-part string (e.g. "1.2.3") into path[]; RFC 9051 SS6.4.5 section-part := nz-number *("." nz-number) */
+/*
+ * parses a dotted section-part string (e.g. "1.2.3") into path[]; RFC 9051
+ * SS6.4.5 section-part := nz-number *("." nz-number)
+ */
 int
 parse_section_part(const char *s, int *path, int maxpath)
 {
@@ -824,7 +1089,10 @@ parse_section_part(const char *s, int *path, int maxpa
 	return (n);
 }
 
-/* recursive descent once path[0] establishes MULTIPART; walks exactly one child per level (the one path[0] names), mirrors build_body_structure() */
+/*
+ * recursive descent once path[0] establishes MULTIPART; walks exactly one child
+ * per level (the one path[0] names), mirrors build_body_structure()
+ */
 int
 find_mime_part(int depth, const char *hdr, size_t hdrlen, const char *body,
     size_t bodylen, const int *path, int pathlen, const char **part_out,
@@ -866,7 +1134,11 @@ find_mime_part(int depth, const char *hdr, size_t hdrl
 		return (-1);
 
 	if (pathlen == 1) {
-		/* path consumed; must be a genuine leaf (not MULTIPART/MESSAGE-RFC822|GLOBAL), no "combined children" concept exists */
+		/*
+		 * path consumed; must be a genuine leaf (not
+		 * MULTIPART/MESSAGE-RFC822|GLOBAL), no "combined children"
+		 * concept exists
+		 */
 		char	 ctype[64], csub[64], cparams[600];
 		char	 cboundary[70 + 1];
 		int	 chb;
@@ -891,7 +1163,10 @@ find_mime_part(int depth, const char *hdr, size_t hdrl
 	    plen - phdrend, path + 1, pathlen - 1, part_out, partlen_out));
 }
 
-/* locates a leaf MIME part by dotted path; handles RFC 9051 SS6.4.5.1's non-multipart top-level case (own body = section-part "1") */
+/*
+ * locates a leaf MIME part by dotted path; handles RFC 9051 SS6.4.5.1's
+ * non-multipart top-level case (own body = section-part "1")
+ */
 int
 locate_mime_part(const char *hdr, size_t hdrlen, const char *body,
     size_t bodylen, const int *path, int pathlen, const char **part_out,
@@ -922,52 +1197,42 @@ locate_mime_part(const char *hdr, size_t hdrlen, const
 	return (0);
 }
 
-/* applies RFC 9051 SS6.4.5 "<start.count>" to content, computing *out and *outlen (a subrange, no copy); clamped to FETCH_PART_MAX */
+/* RFC 9051 SS6.4.5 "<start.count>" applied to a range; past its end is empty */
 void
-apply_partial_range(const char *content, size_t contentlen, int has_partial,
-    uint32_t partial_start, uint32_t partial_count, const char **out,
-    size_t *outlen)
+partial_range(int has_partial, uint32_t start, uint32_t count,
+    uint64_t *off, uint64_t *len)
 {
-	if (!has_partial) {
-		*out = content;
-		*outlen = contentlen;
-		if (*outlen > FETCH_PART_MAX)
-			*outlen = FETCH_PART_MAX;
+	if (!has_partial)
 		return;
-	}
-
-	if (partial_start >= contentlen) {
-		*out = content;
-		*outlen = 0;
+	if (start >= *len) {
+		*off += *len;
+		*len = 0;
 		return;
 	}
-
-	*out = content + partial_start;
-	*outlen = contentlen - partial_start;
-	if (*outlen > partial_count)
-		*outlen = partial_count;
-	if (*outlen > FETCH_PART_MAX)
-		*outlen = FETCH_PART_MAX;
+	*off += start;
+	*len -= start;
+	if (*len > count)
+		*len = count;
 }
 
-/* BODY.PEEK[<section-part>] entry point: reads the whole message (bodystructure_read_max cap), locates the part, applies partial range */
+/*
+ * BODY.PEEK[<section-part>]: reads the whole message (bodystructure_read_max
+ * cap) to find the part, and returns where the part lies in the file.
+ */
 int
-extract_mime_part(const char *basename, const int *path, int pathlen,
-    int has_partial, uint32_t partial_start, uint32_t partial_count,
-    char **buf_out, uint32_t *len_out)
+extract_mime_part(int dfd, const char *basename, const int *path,
+    int pathlen, uint64_t *off_out, uint64_t *len_out)
 {
 	char		*wholebuf = NULL;
 	uint32_t	 wholelen = 0;
 	size_t		 hdrend;
 	const char	*part = NULL;
 	size_t		 partlen = 0;
-	const char	*out;
-	size_t		 outlen;
 
-	*buf_out = NULL;
+	*off_out = 0;
 	*len_out = 0;
 
-	if (read_message_body(basename, 0, bodystructure_read_max,
+	if (read_message_body(dfd, basename, 0, bodystructure_read_max,
 	    "BODY[<part>]", &wholebuf, &wholelen) == -1)
 		return (-1);
 	if (wholelen == 0 ||
@@ -976,30 +1241,97 @@ extract_mime_part(const char *basename, const int *pat
 		return (-1);
 	}
 
+	/*
+	 * RFC 9051 SS6.4.5: a section-part the message does not have is an
+	 * empty item, not a failure. found=0 means the server could not
+	 * produce what was asked for, which this is not; SS6.4.5.1 leaves
+	 * the nonexistent case unspecified. The out parameters keep the
+	 * empty values set at entry.
+	 */
 	if (locate_mime_part(wholebuf, hdrend, wholebuf + hdrend,
-	    wholelen - hdrend, path, pathlen, &part, &partlen) == -1) {
-		free(wholebuf);
-		return (-1);
+	    wholelen - hdrend, path, pathlen, &part, &partlen) == 0) {
+		/* wholebuf was read from the file's first octet */
+		*off_out = (uint64_t)(part - wholebuf);
+		*len_out = (uint64_t)partlen;
 	}
-
-	apply_partial_range(part, partlen, has_partial, partial_start,
-	    partial_count, &out, &outlen);
-
-	if (outlen > 0) {
-		if ((*buf_out = malloc(outlen)) == NULL) {
-			log_warn("session %u: malloc BODY[<part>] buffer (%s)",
-			    session_id, basename);
-			free(wholebuf);
-			return (-1);
-		}
-		memcpy(*buf_out, out, outlen);
-	}
-	*len_out = (uint32_t)outlen;
 	free(wholebuf);
 	return (0);
 }
 
-/* builds the space-separated IMAP flag-atom list from maildir flag-suffix letters + index keywords; letters per Courier's maildir(5) */
+/*
+ * Finds BODY[] (text_only 0) or BODY[TEXT] (text_only 1) in basename by
+ * reading it in blocks, and returns the open descriptor with the range. A
+ * NUL is refused, since a literal carries CHAR8, %x01-ff (RFC 9051 SS9).
+ * *fdbusy_out is set when the open failed for want of a descriptor.
+ */
+int
+message_body_range(int dfd, const char *basename, int text_only,
+    uint64_t *off_out, uint64_t *len_out, int *fdbusy_out)
+{
+	char			 blk[65536];
+	unsigned char		 c, b1 = 0, b2 = 0, b3 = 0;
+	uint64_t		 total = 0, hdrend = 0;
+	ssize_t			 n, i;
+	int			 fd, found = !text_only;
+
+	*off_out = 0;
+	*len_out = 0;
+	*fdbusy_out = 0;
+
+	if ((fd = open_message_file(dfd, basename)) == -1) {
+		if (errno == EMFILE || errno == ENFILE)
+			*fdbusy_out = 1;
+		return (-1);
+	}
+
+	for (;;) {
+		if ((n = read(fd, blk, sizeof(blk))) == -1) {
+			if (errno == EINTR)
+				continue;
+			log_warn("session %u: read message body (%s)",
+			    session_id, basename);
+			close(fd);
+			return (-1);
+		}
+		if (n == 0)
+			break;
+		for (i = 0; i < n; i++) {
+			c = (unsigned char)blk[i];
+			if (c == '\0') {
+				log_warnx("session %u: message %s has a NUL "
+				    "byte, BODY[] skipped", session_id,
+				    basename);
+				close(fd);
+				return (-1);
+			}
+			/* LF LF or CRLF CRLF, as find_header_body_split() */
+			if (!found && c == '\n' && (b1 == '\n' ||
+			    (b1 == '\r' && b2 == '\n' && b3 == '\r'))) {
+				found = 1;
+				hdrend = total + (uint64_t)i + 1;
+			}
+			b3 = b2;
+			b2 = b1;
+			b1 = c;
+		}
+		total += (uint64_t)n;
+	}
+
+	if (!found) {
+		log_warnx("session %u: message %s: no header/body separator "
+		    "found, BODY[TEXT] skipped", session_id, basename);
+		close(fd);
+		return (-1);
+	}
+	*off_out = hdrend;
+	*len_out = total - hdrend;
+	return (fd);
+}
+
+/*
+ * builds the space-separated IMAP flag-atom list from maildir flag-suffix
+ * letters + index keywords; letters per Courier's maildir(5)
+ */
 void
 build_flags_string(const char *maildir_suffix, const char *keywords,
     char *out, size_t outsize)
@@ -1073,7 +1405,10 @@ build_flags_string(const char *maildir_suffix, const c
 	}
 }
 
-/* RFC 9051 SS2.3.1.1 INTERNALDATE: uses the maildir basename's leading timestamp field, not mtime; falls back to now if non-conforming */
+/*
+ * RFC 9051 SS2.3.1.1 INTERNALDATE: uses the maildir basename's leading
+ * timestamp field, not mtime; falls back to now if non-conforming
+ */
 int64_t
 parse_maildir_timestamp(const char *basename)
 {
blob - 8420f4c90a170f5f92aa85c97a5a17b3a7d43848
blob + a1ad40f9dd931b15cc09fee8a43afe409432650c
--- src/parent.c
+++ src/parent.c
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  * Copyright (c) 2009 Jacek Masiulaniec <jacekm@dobremiasto.net>
@@ -28,7 +30,14 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* parent.c: privileged supervisor -- owns listen sockets and forks a paired listener-worker/auth-worker per accepted connection (spawn_connection()) so a compromise's blast radius is one connection; keymgr remains the sole boot-time child; retires the old single-listener IMSG_SESSION_OPEN/CLOSE and listener-side MaxStartups machinery now that parent tracks sessions and throttling itself. */
+/*
+ * parent.c: privileged supervisor -- owns listen sockets, forks a paired
+ * listener-worker/auth-worker per accepted connection (spawn_connection()) so a
+ * compromise's blast radius is one connection; keymgr remains the sole
+ * boot-time child. Retires the old single-listener IMSG_SESSION_OPEN/CLOSE and
+ * listener-side MaxStartups machinery now that parent tracks sessions and
+ * throttling itself.
+ */
 
 #include <sys/types.h>
 #include <sys/queue.h>
@@ -54,11 +63,20 @@
 #include "imapd.h"
 #include "log.h"
 
-#define STORE_SETUP_TIMEOUT_SEC	10	/* smtpd's setup_done() precedent, minus its fatal()-on-timeout */
+/* like smtpd's setup_done(), non-fatal */
+#define STORE_SETUP_TIMEOUT_SEC	10
+/* l2tpd and pptpd both wait 5s for the same job */
+#define SHUTDOWN_TIMEOUT_SEC	5
 
-#define STORE_CHILD_MAX		64	/* caps concurrent store children so a fork flood can't exhaust PIDs/fds */
+/* caps children vs fork-flood PID/fd exhaustion */
+#define STORE_CHILD_MAX		64
 
-/* Caps open_sessions tracking as a generous secondary backstop above STORE_CHILD_MAX; count_startups()/startups_should_drop() below are the real admission throttle, and past this cap spawn_connection() refuses the connection outright. */
+/*
+ * Caps open_sessions tracking as a generous secondary backstop above
+ * STORE_CHILD_MAX; count_startups()/startups_should_drop() below are the real
+ * admission throttle, and past this cap spawn_connection() refuses the
+ * connection outright.
+ */
 #define OPEN_SESSION_MAX	4096
 
 struct child {
@@ -68,26 +86,37 @@ struct child {
 	TAILQ_ENTRY(child)		 entry;
 };
 
-/* per-session store child; "pending" until IMSG_SETUP_DONE arrives/times out; one struct, never copied (embeds struct event) */
+/*
+ * per-session store child; "pending" until IMSG_SETUP_DONE arrives or times
+ * out; one struct, never copied (embeds struct event)
+ */
 struct store_child {
 	uint32_t			 session_id;
 	pid_t				 pid;
 	struct imsgev			 iev;	/* parent<->this store child */
 	int				 pending;
 	struct event			 timeout_ev;
-	struct imsgev			*listener_iev; /* who to notify on failure, see store_child_fail() */
+	/* notify on failure, see store_child_fail() */
+	struct imsgev			*listener_iev;
 	TAILQ_ENTRY(store_child)	 entry;
 };
 
-/* Tracks one spawn_connection()-forked session from fork to reap_child()'s exit notice; authenticated flips true on a validated IMSG_AUTH_CRED (rejecting duplicates), and listener_iev/auth_iev are this session's own paired channels, cleared to NULL when reap_child() sees that worker exit. */
+/*
+ * Tracks one spawn_connection()-forked session from fork to reap_child()'s exit
+ * notice; authenticated flips true on a validated IMSG_AUTH_CRED (rejecting
+ * duplicates), and listener_iev/auth_iev are this session's own paired
+ * channels, cleared to NULL when reap_child() sees that worker exit.
+ */
 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 */
+	/* 0 if no auth-worker was spawned/is left */
+	pid_t				 auth_pid;
+	/* 0 if no search-oracle spawned/left */
+	pid_t				 search_pid;
 	TAILQ_ENTRY(open_session)	 entry;
 };
 
@@ -102,16 +131,24 @@ static struct imsgev	*iev_keymgr;
 static struct openimap_config *gconf;
 static char		 progpath[PATH_MAX];
 static char		**saved_argv;
-static const char	*conf_path;	/* set once in parent_main(); read by sighup_handler() to reload */
+/* set in parent_main(), reloaded on SIGHUP */
+static const char	*conf_path;
 
 static struct event	 ev_sighup, ev_sigterm, ev_sigchld;
 
-/* the accept-loop events parent now owns directly, see this file's header comment */
+/* the accept-loop events parent now owns, 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() */
+/* from listener.c; spawn_connection() */
+static uint32_t		 next_session_id = 1;
 
+/* shutdown bookkeeping; set and read by sigterm_handler() */
+static int		 shutting_down;
+static struct event	 ev_shutdown;
+/* static so sigterm_handler() can unhook the accept events too */
+static int		 n_cleartext, n_tls;
+
 static __dead void	 exec_self_as(const char *);
 static pid_t	 fork_child(enum openimap_proc_type, struct imsgev **,
 		    void (*)(int, short, void *));
@@ -143,6 +180,8 @@ static int	 send_tls_cert(struct imsgev *, struct open
 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 int	 shutdown_children_left(void);
+static void	 sigterm_force(int, short, void *);
 static void	 sigterm_handler(int, short, void *);
 static void	 sigchld_handler(int, short, void *);
 static void	 reap_child(pid_t, int);
@@ -153,15 +192,23 @@ parent_main(const char *conffile, int argc, char *argv
     struct openimap_config *conf)
 {
 	int	 cleartext_fds[LISTENER_MAX_ADDRS], tls_fds[LISTENER_MAX_ADDRS];
-	int	 n_cleartext, n_tls, i;
+	int	 i;
 	struct rlimit	 rl;
 
-	(void)argc;	/* fork_child()/parent_handle_store_fork()/spawn_connection() walk saved_argv, scanning for a NULL terminator */
+	/* fork_child() et al. walk saved_argv for a NULL terminator */
+	(void)argc;
 
+	/* main.c checks this first; kept so parent_main() stands alone */
 	if (geteuid() != 0)
-		fatalx("parent must start as root");
+		fatalx("parent must start as root (running as uid %u)",
+		    (unsigned int)geteuid());
 
-	/* Raise the descriptor soft limit to the hard limit (root, pre-pledge, pre-bind/fork): the default "daemon" login class caps descriptors well below what MaxStartups' default concurrency needs, at 3-4 fds per session. */
+	/*
+	 * Raise the descriptor soft limit to the hard limit (root, pre-pledge,
+	 * pre-bind/fork): the default "daemon" login class caps descriptors
+	 * well below what MaxStartups' default concurrency needs, at 3-4 fds
+	 * per session.
+	 */
 	if (getrlimit(RLIMIT_NOFILE, &rl) == -1)
 		log_warn("getrlimit RLIMIT_NOFILE");
 	else if (rl.rlim_cur < rl.rlim_max) {
@@ -179,7 +226,11 @@ parent_main(const char *conffile, int argc, char *argv
 	if (realpath(argv[0], progpath) == NULL)
 		fatal("realpath");
 
-	/* 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. */
+	/*
+	 * 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);
@@ -192,13 +243,26 @@ parent_main(const char *conffile, int argc, char *argv
 
 	event_init();
 
-	/* keymgr is the sole remaining boot-time child (per-connection listener/auth are spawned later by spawn_connection()); send IMSG_KEYMGR_INIT before the peer handshake so real key material is in place before its permission gate opens. */
+	/*
+	 * keymgr is the sole remaining boot-time child (per-connection
+	 * listener/auth are spawned later by spawn_connection()); send
+	 * IMSG_KEYMGR_INIT before the peer handshake so real key material is in
+	 * place before its permission gate opens.
+	 */
 	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() behavior. */
+	/*
+	 * Boot: a keymgr that cannot be initialized is not a daemon worth
+	 * starting, so this one caller keeps the old fatal() behavior.
+	 */
 	if (send_keymgr_init(iev_keymgr, conf) == -1)
 		fatalx("send_keymgr_init: could not initialize keymgr at boot");
 
-	/* keymgr gets no peer at boot (its first peer is wired later by spawn_connection()), so this IMSG_SETUP_DONE with zero preceding IMSG_SETUP_PEER is just the same boot-failure-detection handshake every boot-time child gets. */
+	/*
+	 * keymgr gets no peer at boot (its first peer is wired later by
+	 * spawn_connection()), so this IMSG_SETUP_DONE with zero preceding
+	 * IMSG_SETUP_PEER is just the same boot-failure-detection handshake
+	 * every boot-time child gets.
+	 */
 	setup_done_send(iev_keymgr);
 
 	signal_set(&ev_sighup, SIGHUP, sighup_handler, NULL);
@@ -207,7 +271,10 @@ parent_main(const char *conffile, int argc, char *argv
 	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 keymgr, which is 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
+	 */
 
 	for (i = 0; i < n_cleartext; i++) {
 		event_set(&ev_accept_cleartext[i], cleartext_fds[i],
@@ -220,7 +287,13 @@ parent_main(const char *conffile, int argc, char *argv
 		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 */
+	/* no setproctitle() here: rc.d matches the parent's command line */
+
+	/*
+	 * 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");
@@ -230,7 +303,11 @@ parent_main(const char *conffile, int argc, char *argv
 	fatalx("parent: exited event loop");
 }
 
-/* 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 */
+/*
+ * 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)
 {
@@ -240,19 +317,35 @@ exec_self_as(const char *role)
 	n = 0;
 	nargv[n++] = progpath;
 	nargv[n++] = "-x";
-	/* Casting away const on role for argv is the standard, unavoidable idiom: execv(3) requires char *const argv[] for historical reasons and never writes through the pointers. */
+	/*
+	 * Casting away const on role for argv is the standard, unavoidable
+	 * idiom: execv(3) requires char *const argv[] for historical reasons
+	 * and never writes through the pointers.
+	 */
 	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 */
+		/* skip any pre-existing -x <role>; the rest passes through */
 		if (strcmp(saved_argv[i], "-x") == 0) {
-			/* A trailing -x with no value would step the index past argv's NULL terminator and read one element beyond the array (in practice environ[0]); getopt(3) can't catch this because it fires on an operand after "--", which main() never checks via optind. */
+			/*
+			 * A trailing -x with no value would step the index past
+			 * argv's NULL terminator and read one element beyond
+			 * the array (in practice environ[0]); getopt(3) can't
+			 * catch this because it fires on an operand after "--",
+			 * which main() never checks via optind.
+			 */
 			if (saved_argv[i + 1] == NULL)
 				break;
 			i++;
 			continue;
 		}
 		if (n >= (int)(sizeof(nargv) / sizeof(nargv[0])) - 1) {
-			/* Refuse rather than truncate an overlong argv: silently dropping the tail could split an option from its value, and the child's own getopt(3) would then fail with a misleading usage() in the freshly forked child. */
+			/*
+			 * Refuse rather than truncate an overlong argv:
+			 * silently dropping the tail could split an option from
+			 * its value, and the child's own getopt(3) would then
+			 * fail with a misleading usage() in the freshly forked
+			 * child.
+			 */
 			log_warnx("exec_self_as: argv too long to pass to the "
 			    "%s child, refusing to exec a truncated command "
 			    "line", role);
@@ -262,12 +355,22 @@ exec_self_as(const char *role)
 	}
 	nargv[n] = NULL;
 
-	/* Use execv(3) not execvp(3): progpath is always absolute (realpath(3)) so PATH search was never needed, and execv() makes that property true by construction rather than by relying on a caller's care (see smtpd.c:856 for the same non-bug). */
+	/*
+	 * Use execv(3) not execvp(3): progpath is always absolute (realpath(3))
+	 * so PATH search was never needed, and execv() makes that property true
+	 * by construction rather than by relying on a caller's care (see
+	 * smtpd.c:856 for the same non-bug).
+	 */
 	execv(nargv[0], nargv);
 	_exit(1);
 }
 
-/* Re-exec mechanism (per smtpd.c's start_child()): socketpair/fork/dup2 onto fd 3/closefrom/execv -x <role>; fatal()s on failure since keymgr, its only caller, is boot-time-required, unlike fork_child_nonfatal() below for per-connection forks. */
+/*
+ * Re-exec mechanism (per smtpd.c's start_child()): socketpair/fork/dup2 onto fd
+ * 3/closefrom/execv -x <role>; fatal()s on failure since keymgr, its only
+ * caller, is boot-time-required, unlike fork_child_nonfatal() below for
+ * per-connection forks.
+ */
 static pid_t
 fork_child(enum openimap_proc_type type, struct imsgev **ievp,
     void (*handler)(int, short, void *))
@@ -279,7 +382,11 @@ fork_child(enum openimap_proc_type type, struct imsgev
 	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. */
+/*
+ * 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 *))
@@ -322,7 +429,7 @@ fork_child_nonfatal(enum openimap_proc_type type, stru
 	}
 	c->pid = pid;
 	c->type = type;
-	/* NULL here, not "c", handlers expect arg == &iev; imsgev_init()'s own fallback self-references &c->iev */
+	/* NULL, not "c": handlers expect &iev; imsgev_init() self-refs it */
 	imsgev_init(&c->iev, sp[0], handler, NULL);
 	TAILQ_INSERT_TAIL(&children, c, entry);
 
@@ -330,14 +437,26 @@ fork_child_nonfatal(enum openimap_proc_type type, stru
 	return (pid);
 }
 
-/* IMSG_SETUP_PEER / IMSG_SETUP_SEARCH_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 (both search-oracle wirings and the listener/auth pair), else session_id (keymgr's multi-peer case, store's own case below). imsgname is carried alongside imsg_type purely so a failure names the wiring that failed, the same pairing send_mbox_request() uses in listener.c. */
+/*
+ * IMSG_SETUP_PEER / IMSG_SETUP_SEARCH_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 (both search-oracle wirings and the
+ * listener/auth pair), else session_id (keymgr's multi-peer case, store's own
+ * case below). imsgname rides alongside imsg_type purely so a failure names the
+ * wiring that failed, the same pairing send_mbox_request() uses in listener.c.
+ */
 static int
 setup_peer_send(struct imsgev *a, struct imsgev *b, int imsg_type,
     const char *imsgname, uint32_t id)
 {
 	int sp[2];
 
-	/* Returns -1 instead of fatal()ing since every caller is now per-connection ("fail the session, not the daemon"); on the error paths, an fd is closed here only if imsg_compose() did NOT already take ownership of it. */
+	/*
+	 * Returns -1 instead of fatal()ing since every caller is now
+	 * per-connection ("fail the session, not the daemon"); on the error
+	 * paths, an fd is closed here only if imsg_compose() did NOT already
+	 * take ownership of it.
+	 */
 	if (socketpair(AF_UNIX, SOCK_STREAM, PF_UNSPEC, sp) == -1) {
 		log_warn("setup_peer_send %s: socketpair", imsgname);
 		return (-1);
@@ -366,7 +485,11 @@ setup_peer_send(struct imsgev *a, struct imsgev *b, in
 	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 */
+/*
+ * 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)
 {
@@ -378,7 +501,7 @@ setup_done_send(struct imsgev *iev)
 	if (imsgbuf_flush(&iev->ibuf) == -1)
 		fatal("imsgbuf_flush");
 
-	/* imsgbuf_get() before imsgbuf_read(): kernel may coalesce the setup_peer_send() reply with this ack already */
+	/* imsgbuf_get() before imsgbuf_read(): kernel may coalesce reply+ack */
 	for (;;) {
 		if ((n = imsgbuf_get(&iev->ibuf, &imsg)) == -1)
 			fatal("imsgbuf_get");
@@ -395,7 +518,11 @@ setup_done_send(struct imsgev *iev)
 	imsg_free(&imsg);
 }
 
-/* Dispatches every non-boot child channel (keymgr plus each spawn_connection()-forked listener/auth-worker) by imsg type alone, except IMSG_AUTH_CRED below, which also verifies sender identity per session_id. */
+/*
+ * Dispatches every non-boot child channel (keymgr plus each
+ * spawn_connection()-forked listener/auth-worker) by imsg type alone, except
+ * IMSG_AUTH_CRED below, which also verifies sender identity per session_id.
+ */
 static void
 parent_dispatch_child(int fd, short event, void *arg)
 {
@@ -403,7 +530,10 @@ parent_dispatch_child(int fd, short event, void *arg)
 	struct imsg	 imsg;
 	ssize_t		 n;
 
-	/* EV_WRITE: imsg_compose() only queues, imsgbuf_write() puts bytes on the wire */
+	/*
+	 * EV_WRITE: imsg_compose() queues, imsgbuf_write() puts bytes on the
+	 * wire
+	 */
 	if (event & EV_WRITE) {
 		if (imsgbuf_write(&iev->ibuf) == -1)
 			fatal("imsgbuf_write");
@@ -434,31 +564,54 @@ parent_dispatch_child(int fd, short event, void *arg)
 				log_warnx("bad IMSG_AUTH_CRED");
 				break;
 			}
-			/* imsg_get_data() guarantees size but not NUL termination, so an unterminated field here would be an unbounded read past a stack array in the root process, driven by the one child privsep exists to contain -- so force it, like auth.c does for its own inbound imsgs. */
+			/*
+			 * imsg_get_data() guarantees size but not NUL
+			 * termination, so an unterminated field here would be
+			 * an unbounded read past a stack array in the root
+			 * process, driven by the one child privsep exists to
+			 * contain -- so force it, like auth.c does for its own
+			 * inbound imsgs.
+			 */
 			req.maildir[sizeof(req.maildir) - 1] = '\0';
 
-			/* Verify the claimed session_id against a session parent independently knows is open, rather than trusting whatever auth claims (SS6.2 retrofit target 1). */
+			/*
+			 * Verify the claimed session_id against a session
+			 * parent independently knows is open, 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)",
+				    "by OPEN_SESSION_MAX)",
 				    req.session_id);
 				break;
 			}
-			/* Per-connection auth-workers (SS7) replace the old single global iev_auth sender check: each open_session remembers which auth-worker channel spawn_connection() paired it with, and only that channel's IMSG_AUTH_CRED is honored. */
+			/*
+			 * Per-connection auth-workers replace the old single
+			 * global iev_auth sender check: each open_session
+			 * remembers which auth-worker channel
+			 * spawn_connection() paired it with, and only that
+			 * channel's IMSG_AUTH_CRED is honored.
+			 */
 			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)",
+				    "auth-worker channel",
 				    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 still reply: that session's listener-worker is waiting in SESSION_STORE_PENDING with no timeout, a state reachable via an ordinary retry after a failed store spawn, not just misbehavior. */
+				    "duplicate grant", req.session_id);
+				/*
+				 * Refuse the grant but still reply: that
+				 * session's listener-worker is waiting in
+				 * SESSION_STORE_PENDING with no timeout, a
+				 * state reachable via an ordinary retry after a
+				 * failed store spawn, not just misbehavior.
+				 */
 				store_fork_failed(os);
 				break;
 			}
@@ -479,7 +632,7 @@ parent_dispatch_child(int fd, short event, void *arg)
 	(void)fd;
 }
 
-/* SS6.2's open_sessions lookup; see struct open_session's own comment. */
+/* The open_sessions lookup; see struct open_session's own comment. */
 static struct open_session *
 open_session_find(uint32_t session_id)
 {
@@ -492,7 +645,12 @@ open_session_find(uint32_t session_id)
 	return (NULL);
 }
 
-/* SS7: counts not-yet-authenticated open_sessions entries (imapd's analog of sshd's concurrent-unauthenticated-connections), moved here from listener.c now that parent tracks every session itself; O(n) is fine since n is bounded by max_startups_full. */
+/*
+ * Counts not-yet-authenticated open_sessions entries (imapd's analog of
+ * sshd's concurrent-unauthenticated-connections), moved here from listener.c
+ * now that parent tracks every session itself; O(n) is fine since n is bounded
+ * by max_startups_full.
+ */
 static unsigned int
 count_startups(void)
 {
@@ -506,7 +664,12 @@ count_startups(void)
 	return (n);
 }
 
-/* SS7: sshd_config(5)'s MaxStartups algorithm verbatim (accept below begin, ramp refusal probability linearly to full, always refuse at/above full, 0 disables it), moved from listener.c to read gconf->max_startups_* directly since SIGHUP updates gconf in place. */
+/*
+ * sshd_config(5)'s MaxStartups algorithm verbatim (accept below begin,
+ * ramp refusal probability linearly to full, always refuse at/above full, 0
+ * disables it), moved from listener.c to read gconf->max_startups_* directly
+ * since SIGHUP updates gconf in place.
+ */
 static int
 startups_should_drop(unsigned int nstartups)
 {
@@ -520,7 +683,11 @@ startups_should_drop(unsigned int nstartups)
 		return (1);
 	if (gconf->max_startups_rate >= 100)
 		return (1);
-	/* Misconfigured (full <= begin, which parse.y should already prevent): treat as no ramp region and fail toward refusing rather than silently accepting past the configured full. */
+	/*
+	 * Misconfigured (full <= begin, which parse.y should already prevent):
+	 * treat as no ramp region and fail toward refusing rather than silently
+	 * accepting past the configured full.
+	 */
 	if (gconf->max_startups_full <= gconf->max_startups_begin)
 		return (1);
 
@@ -530,7 +697,11 @@ startups_should_drop(unsigned int nstartups)
 	return (arc4random_uniform(100) < (uint32_t)p);
 }
 
-/* parent's own accept loop (SS7, moved from listener.c's listener_accept()); arg selects cleartext(0)/implicit-TLS(1) socket; MaxStartups is checked before any fork so a refused connection costs almost nothing. */
+/*
+ * parent's own accept loop (moved from listener.c's listener_accept());
+ * arg selects cleartext(0)/implicit-TLS(1) socket; MaxStartups is checked
+ * before any fork so a refused connection costs almost nothing.
+ */
 static void
 parent_accept(int fd, short event, void *arg)
 {
@@ -554,7 +725,11 @@ parent_accept(int fd, short event, void *arg)
 		return;
 	}
 
-	/* accept(2) doesn't inherit O_NONBLOCK from the listening socket, and the listener-worker this fd is handed to assumes non-blocking throughout, so set it here before the fd-pass. */
+	/*
+	 * accept(2) doesn't inherit O_NONBLOCK from the listening socket, and
+	 * the listener-worker this fd is handed to assumes non-blocking
+	 * throughout, so set it here before the fd-pass.
+	 */
 	if ((flags = fcntl(client_fd, F_GETFL)) == -1 ||
 	    fcntl(client_fd, F_SETFL, flags | O_NONBLOCK) == -1) {
 		log_warn("fcntl O_NONBLOCK");
@@ -562,11 +737,17 @@ parent_accept(int fd, short event, void *arg)
 		return;
 	}
 
-	implicit_tls = (arg != (void *)0);	/* (void *)1 == port-993 listener */
+	/* (void *)1 == port-993 listener */
+	implicit_tls = (arg != (void *)0);
 	spawn_connection(client_fd, implicit_tls, &ss, sslen);
 }
 
-/* SS7's replicated-listener model: forks a paired listener-worker(+auth-worker) for one accepted connection and wires them to each other and keymgr; never fatal()s -- failures degrade only this connection/session, and client_fd is always either consumed or closed before returning. */
+/*
+ * The replicated-listener model: forks a paired listener-worker(+auth-worker)
+ * for one accepted connection and wires them to each other and keymgr; never
+ * fatal()s -- failures degrade only this connection/session, and client_fd is
+ * always either consumed or closed before returning.
+ */
 static void
 spawn_connection(int client_fd, int implicit_tls,
     const struct sockaddr_storage *ss, socklen_t sslen)
@@ -577,11 +758,17 @@ spawn_connection(int client_fd, int implicit_tls,
 	struct imsgev				*new_search_iev;
 	struct imsg_listener_session_init	 init;
 	uint32_t				 session_id;
-	pid_t					 listener_pid, auth_pid, search_pid;
+	pid_t					 listener_pid, auth_pid;
+	pid_t					 search_pid;
 	unsigned int				 nopen = 0;
 
 	session_id = next_session_id++;
-	/* session_id 0 is reserved: listener.c's boot-drain loop treats peer id 0 as the auth peer and anything else as keymgr, so a session_id that wrapped to 0 would misfile its keymgr descriptor as its auth peer and hang. */
+	/*
+	 * session_id 0 is reserved: listener.c's boot-drain loop treats peer id
+	 * 0 as the auth peer and anything else as keymgr, so a session_id that
+	 * wrapped to 0 would misfile its keymgr descriptor as its auth peer and
+	 * hang.
+	 */
 	if (next_session_id == 0)
 		next_session_id = 1;
 
@@ -611,7 +798,12 @@ spawn_connection(int client_fd, int implicit_tls,
 	}
 	os->listener_pid = listener_pid;
 	os->listener_iev = new_listener_iev;
-	/* Inserted as soon as the listener-worker exists, even though this function can still fail below, so reap_child() always has a matching open_session to find and clean up regardless of how incompletely spawning finished. */
+	/*
+	 * Inserted as soon as the listener-worker exists, even though this
+	 * function can still fail below, so reap_child() always has a matching
+	 * open_session to find and clean up regardless of how incompletely
+	 * spawning finished.
+	 */
 	TAILQ_INSERT_TAIL(&open_sessions, os, entry);
 
 	if ((auth_pid = fork_child_nonfatal(PROC_AUTH, &new_auth_iev,
@@ -625,14 +817,23 @@ spawn_connection(int client_fd, int implicit_tls,
 	} 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 */
+		/*
+		 * 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,
 		    IMSG_SETUP_PEER, "IMSG_SETUP_PEER", 0) == -1)
 			goto fail_close;
 	}
 
-	/* SS8.1: search-oracle is forked and wired the same fail-soft way as auth-worker above; a missing oracle just degrades this session's SEARCH to NO [UNAVAILABLE], and it needs no config push unlike send_auth_init(). */
+	/*
+	 * search-oracle is forked and wired the same fail-soft way as
+	 * auth-worker above; a missing oracle just degrades this session's
+	 * SEARCH to NO [UNAVAILABLE], and it needs no config push unlike
+	 * send_auth_init().
+	 */
 	if ((search_pid = fork_child_nonfatal(PROC_SEARCH, &new_search_iev,
 	    parent_dispatch_child)) == -1) {
 		log_warnx("session %u: no search-oracle available, this "
@@ -642,12 +843,18 @@ spawn_connection(int client_fd, int implicit_tls,
 		/* os->search_pid stays 0; nothing to wire below. */
 	} else {
 		os->search_pid = search_pid;
+		/* session_id rides the imsg id field, unread on this type */
+		/* not for IMSG_SETUP_PEER: listener.c reads id 0 as "auth" */
 		if (setup_peer_send(new_listener_iev, new_search_iev,
-		    IMSG_SETUP_SEARCH_PEER, "IMSG_SETUP_SEARCH_PEER", 0) == -1)
+		    IMSG_SETUP_SEARCH_PEER, "IMSG_SETUP_SEARCH_PEER",
+		    session_id) == -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 */
+	/*
+	 * 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, IMSG_SETUP_PEER,
 	    "IMSG_SETUP_PEER", session_id) == -1)
 		goto fail_close;
@@ -657,8 +864,13 @@ spawn_connection(int client_fd, int implicit_tls,
 	init.implicit_tls = implicit_tls;
 	init.remote_ss = *ss;
 	init.remote_sslen = sslen;
-	/* Read fresh from gconf per connection so a SIGHUP-changed "idle poll" reaches every later connection -- see sighup_handler(). */
+	/*
+	 * Read fresh from gconf per connection so a SIGHUP-changed "idle poll"
+	 * reaches every later connection -- see sighup_handler().
+	 */
 	init.idle_poll_secs = gconf->idle_poll_secs;
+	init.login_grace_secs = gconf->login_grace_secs;
+	init.append_max = gconf->append_max;
 	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 "
@@ -667,7 +879,12 @@ spawn_connection(int client_fd, int implicit_tls,
 		close(client_fd);
 		goto fail_workers;
 	}
-	/* From here on client_fd belongs to imsg (ownership taken by the successful ibuf_fd_set(3) compose, closed by libutil after sendmsg(2)), so the unwind below must not close it -- hence two separate labels. */
+	/*
+	 * From here on client_fd belongs to imsg (ownership taken by the
+	 * successful ibuf_fd_set(3) compose, closed by libutil after
+	 * sendmsg(2)), so the unwind below must not close it -- hence two
+	 * separate labels.
+	 */
 	if (send_tls_cert(new_listener_iev, gconf) == -1)
 		goto fail_workers;
 	return;
@@ -675,7 +892,11 @@ spawn_connection(int client_fd, int implicit_tls,
 fail_close:
 	close(client_fd);	/* not yet handed to imsg; still ours */
 fail_workers:
-	/* Fail this connection, not the daemon: kill the workers forked above and leave the rest to reap_child(), which frees this open_session on the listener-worker's exit -- so do NOT free(os) here. */
+	/*
+	 * Fail this connection, not the daemon: kill the workers forked above
+	 * and leave the rest to reap_child(), which frees this open_session on
+	 * the listener-worker's exit -- so do NOT free(os) here.
+	 */
 	log_warnx("session %u: refusing connection: worker wiring failed",
 	    session_id);
 	kill(os->listener_pid, SIGKILL);
@@ -685,7 +906,7 @@ fail_workers:
 		kill(os->search_pid, SIGKILL);
 }
 
-/* rejects a maildir that could escape the spool subtree: NULL/empty, absolute paths, "." / ".." components */
+/* rejects a maildir that could escape the spool: empty/absolute/".." */
 static int
 maildir_path_is_safe(const char *p)
 {
@@ -695,7 +916,8 @@ maildir_path_is_safe(const char *p)
 		return (0);
 	for (;;) {
 		const char	*slash = strchr(seg, '/');
-		size_t		 len = slash ? (size_t)(slash - seg) : strlen(seg);
+		size_t		 len = slash ? (size_t)(slash - seg) :
+		    strlen(seg);
 
 		if (len == 1 && seg[0] == '.')
 			return (0);
@@ -708,7 +930,10 @@ maildir_path_is_safe(const char *p)
 	return (1);
 }
 
-/* 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 */
+/*
+ * 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(struct open_session *os, uid_t uid, gid_t gid,
     const char *maildir)
@@ -722,7 +947,12 @@ parent_handle_store_fork(struct open_session *os, uid_
 	unsigned int		 nchildren = 0;
 	uint32_t		 session_id = os->session_id;
 
-	/* os->listener_iev is who the fail: path reports to and who the new child's fd is passed to; it can legitimately be NULL if the listener-worker died between auth accepting credentials and this call, since it's never restarted. */
+	/*
+	 * os->listener_iev is who the fail: path reports to and who the new
+	 * child's fd is passed to; it can legitimately be NULL if the
+	 * listener-worker died between auth accepting credentials and this
+	 * call, since it's never restarted.
+	 */
 	if (os->listener_iev == NULL) {
 		log_warnx("refusing store spawn for session %u: "
 		    "listener-worker is gone", session_id);
@@ -732,7 +962,7 @@ parent_handle_store_fork(struct open_session *os, uid_
 	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,
+		    "for session %u", (unsigned int)uid, (unsigned int)gid,
 		    session_id);
 		goto fail;
 	}
@@ -743,7 +973,12 @@ parent_handle_store_fork(struct open_session *os, uid_
 	}
 
 	TAILQ_FOREACH(it, &store_children, entry) {
-		/* A second spawn for a live session would overwrite s->store_iev in listener.c's handler with a fresh calloc, leaking the old struct/fd and orphaning a store child; only a broken or compromised auth can trigger this. */
+		/*
+		 * A second spawn for a live session would overwrite
+		 * s->store_iev in listener.c's handler with a fresh calloc,
+		 * leaking the old struct/fd and orphaning a store child; only a
+		 * broken or compromised auth can trigger this.
+		 */
 		if (it->session_id == session_id) {
 			log_warnx("refusing store spawn: session %u already "
 			    "has a store child", session_id);
@@ -780,10 +1015,14 @@ parent_handle_store_fork(struct open_session *os, uid_
 
 	close(pair[1]);
 
-	/* allocated once, in place, sc->iev is never copied afterward (same reason as struct store_child) */
+	/* allocated once in place; sc->iev is never copied */
 	sc = calloc(1, sizeof(*sc));
 	if (sc == NULL) {
-		/* Fail this session, not the daemon: every other failure here degrades to one NO reply, and a transient allocation failure shouldn't be the exception that takes down the whole service. */
+		/*
+		 * Fail this session, not the daemon: every other failure here
+		 * degrades to one NO reply, and a transient allocation failure
+		 * shouldn't be the exception that takes down the whole service.
+		 */
 		log_warn("calloc store child (session %u)", session_id);
 		kill(pid, SIGKILL);
 		close(pair[0]);
@@ -795,8 +1034,16 @@ parent_handle_store_fork(struct open_session *os, uid_
 	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: without it, strlcpy(3)'s unused tail bytes and inter-field padding would leak roughly a kilobyte of root's stack contents to the store child, unlike every other imsg payload in this file which is already zeroed. */
+	/*
+	 * IMSG_STORE_INIT: the one message a store child needs that boot-time
+	 * children don't, runtime privilege target
+	 */
+	/*
+	 * memset first: without it, strlcpy(3)'s unused tail bytes and
+	 * inter-field padding would leak roughly a kilobyte of root's stack
+	 * contents to the store child, unlike every other imsg payload in this
+	 * file which is already zeroed.
+	 */
 	memset(&init_payload, 0, sizeof(init_payload));
 	init_payload.session_id = session_id;
 	init_payload.uid = uid;
@@ -818,13 +1065,18 @@ parent_handle_store_fork(struct open_session *os, uid_
 		goto fail_kill;
 	}
 	init_payload.bodystructure_read_max = gconf->bodystructure_read_max;
+	init_payload.append_max = gconf->append_max;
+	init_payload.lock_timeout_secs = gconf->lock_timeout_secs;
 	if (imsg_compose(&sc->iev.ibuf, IMSG_STORE_INIT, 0, 0, -1,
 	    &init_payload, sizeof(init_payload)) == -1) {
 		log_warn("imsg_compose IMSG_STORE_INIT");
 		goto fail_kill;
 	}
 
-	/* session_id rides as the imsg "id" field so listener.c can tell which in-flight handshake this fd belongs to */
+	/*
+	 * session_id rides as the imsg "id" field so listener.c can tell which
+	 * in-flight handshake this fd belongs to
+	 */
 	if (setup_peer_send(&sc->iev, os->listener_iev, IMSG_SETUP_PEER,
 	    "IMSG_SETUP_PEER", session_id) == -1)
 		goto fail_kill;
@@ -846,20 +1098,30 @@ parent_handle_store_fork(struct open_session *os, uid_
 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 */
+	/* mirrors store_child_teardown(), else fd leaks */
+	close(sc->iev.ibuf.fd);
+	/* imsgbuf_init() allocs, close(2) leaks it */
+	imsgbuf_clear(&sc->iev.ibuf);
 	free(sc);
 fail:
 	store_fork_failed(os);
 }
 
-/* Tells a session's listener-worker no store child is coming, so it answers its client instead of hanging forever in SESSION_STORE_PENDING; lifted out of parent_handle_store_fork()'s fail: tail so the duplicate-IMSG_AUTH_CRED path can reach it too. */
+/*
+ * Tells a session's listener-worker no store child is coming, so it answers its
+ * client instead of hanging forever in SESSION_STORE_PENDING; lifted out of
+ * parent_handle_store_fork()'s fail: tail so the duplicate-IMSG_AUTH_CRED path
+ * can reach it too.
+ */
 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 */
+	/*
+	 * 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));
@@ -877,7 +1139,7 @@ store_child_dispatch(int fd, short event, void *arg)
 	struct imsg		 imsg;
 	ssize_t			 n;
 
-	/* EV_WRITE: same as parent_dispatch_child(), imsg_compose() only queues */
+	/* EV_WRITE: as parent_dispatch_child(), imsg_compose() only queues */
 	if (event & EV_WRITE) {
 		if (imsgbuf_write(&sc->iev.ibuf) == -1) {
 			store_child_fail(sc);
@@ -927,7 +1189,11 @@ 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 the store child's own session's listener-worker 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)
 {
@@ -938,15 +1204,22 @@ store_child_teardown(struct store_child *sc, int alrea
 		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)");
+			log_warn("imsg_compose IMSG_STORE_FORK "
+			    "(failure reply)");
 	}
-	/* Unconditional: though pending is already cleared in store_child_dispatch(), disarming here too prevents a future clear-without-del from arming a timer on freed memory, regardless of whether the listener-worker is still reachable. */
+	/*
+	 * Unconditional: though pending is already cleared in
+	 * store_child_dispatch(), disarming here too prevents a future
+	 * clear-without-del from arming a timer on freed memory, regardless of
+	 * whether the listener-worker is still reachable.
+	 */
 	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 */
+	/* imsgbuf_init() allocs, close(2) leaks it */
+	imsgbuf_clear(&sc->iev.ibuf);
 	TAILQ_REMOVE(&store_children, sc, entry);
 	free(sc);
 }
@@ -957,7 +1230,7 @@ store_child_fail(struct store_child *sc)
 	store_child_teardown(sc, 0);
 }
 
-/* binds, sets SO_REUSEADDR, and listen(2)s one socket, the common tail end of every bind_listen_socket() case */
+/* binds, sets SO_REUSEADDR, listen(2)s; shared tail of bind_listen_socket() */
 static int
 bind_one(int family, const struct sockaddr *sa, socklen_t salen,
     uint16_t port)
@@ -979,7 +1252,7 @@ bind_one(int family, const struct sockaddr *sa, sockle
 	return (fd);
 }
 
-/* resolves+binds "addr" for "port"; deliberately "*" or a literal IPv4/IPv6 address only, no getaddrinfo(3) DNS */
+/* resolves+binds "addr"/"port"; "*" or literal IPv4/IPv6 only, no DNS */
 static int
 bind_listen_socket(const char *addr, uint16_t port, int fds[LISTENER_MAX_ADDRS])
 {
@@ -1032,8 +1305,21 @@ bind_listen_socket(const char *addr, uint16_t port, in
 	    "IPv4/IPv6 address", addr);
 }
 
-/* Reads at most bufsize bytes of an already-open file into the CALLER's buffer; feof(3) alone can't tell "the file is exactly bufsize bytes" from "there is more to come", so on an exactly-full read one extra fgetc(3) probes for a byte past the cap, and a short read that isn't a clean EOF is refused for the same reason. Returns 0 with *n_out set, or -1 (already logged) for an oversized file or an unclean short read -- and leaves *n_out 0 on -1, so a caller that composes anyway sends nothing rather than a silent truncation. */
-/* Takes an open FILE * rather than a path so the key file's ownership and permission policy stays at its own call site, and never allocates, so the bytes live only in the caller's buffer where a caller holding key material can explicit_bzero() them. */
+/*
+ * Reads at most bufsize bytes of an already-open file into the CALLER's buffer;
+ * feof(3) alone can't tell "exactly bufsize bytes" from "more to come", so on
+ * an exactly-full read one extra fgetc(3) probes for a byte past the cap, and a
+ * short read that isn't a clean EOF is refused for the same reason. Returns 0
+ * with *n_out set, or -1 (already logged) for an oversized file or an unclean
+ * short read -- and leaves *n_out 0 on -1, so a caller that composes anyway
+ * sends nothing rather than a silent truncation.
+ */
+/*
+ * Takes an open FILE * rather than a path so the key file's ownership and
+ * permission policy stays at its own call site, and never allocates, so the
+ * bytes live only in the caller's buffer where a caller holding key material
+ * can explicit_bzero() them.
+ */
 static int
 read_file_capped(FILE *fp, char *buf, size_t bufsize, size_t *n_out,
     const char *path, const char *what)
@@ -1061,7 +1347,11 @@ read_file_capped(FILE *fp, char *buf, size_t bufsize, 
 	return (0);
 }
 
-/* Sends IMSG_TLS_CERT to both a fresh listener-worker (for its tls_config) and keymgr (to compute the cert's pubkey hash); only the listener-worker case reads a fresh file per spawn, so keymgr needs a separate reload push. */
+/*
+ * Sends IMSG_TLS_CERT to both a fresh listener-worker (for its tls_config) and
+ * keymgr (to compute the cert's pubkey hash); only the listener-worker case
+ * reads a fresh file per spawn, so keymgr needs a separate reload push.
+ */
 static int
 send_tls_cert(struct imsgev *iev, struct openimap_config *conf)
 {
@@ -1074,7 +1364,12 @@ send_tls_cert(struct imsgev *iev, struct openimap_conf
 	if ((fp = fopen(conf->tls_cert_file, "r")) == NULL) {
 		log_warn("fopen %s", conf->tls_cert_file);
 	} else {
-		/* A short buffer used to silently be sent as if it were the whole file, failing later in the listener with a misleading error; read_file_capped() carries that check, shared with send_keymgr_init()'s key half. */
+		/*
+		 * A short buffer used to silently be sent as if it were the
+		 * whole file, failing later in the listener with a misleading
+		 * error; read_file_capped() carries that check, shared with
+		 * send_keymgr_init()'s key half.
+		 */
 		if (read_file_capped(fp, buf, sizeof(buf), &n,
 		    conf->tls_cert_file, "certificate") == -1)
 			n = 0;	/* send an empty cert, never a truncated one */
@@ -1093,7 +1388,12 @@ send_tls_cert(struct imsgev *iev, struct openimap_conf
 	return (0);
 }
 
-/* Sends keymgr its IMSG_TLS_CERT then IMSG_KEYMGR_INIT as two separate composes (each capped at 8192 bytes) rather than one combined blob, to leave headroom under imsg's 16384-byte MAX_IMSGSIZE; always sends both, even empty on failure, so keymgr never hangs. */
+/*
+ * Sends keymgr its IMSG_TLS_CERT then IMSG_KEYMGR_INIT as two separate composes
+ * (each capped at 8192 bytes) rather than one combined blob, to leave headroom
+ * under imsg's 16384-byte MAX_IMSGSIZE; always sends both, even empty on
+ * failure, so keymgr never hangs.
+ */
 static int
 send_keymgr_init(struct imsgev *iev, struct openimap_config *conf)
 {
@@ -1105,7 +1405,7 @@ send_keymgr_init(struct imsgev *iev, struct openimap_c
 	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()) */
+	/* key is private: checked uid 0, mode<=0740 (smtpd's ssl_load_key()) */
 	n = 0;
 	if ((fp = fopen(conf->tls_key_file, "r")) == NULL) {
 		log_warn("fopen %s", conf->tls_key_file);
@@ -1127,9 +1427,14 @@ send_keymgr_init(struct imsgev *iev, struct openimap_c
 		fclose(fp);
 	}
 
-	/* imsg_compose() copies buf immediately, so it's safe to scrub our stack copy right after this call */
-	/* Non-fatal here: parent_main() turns a boot-time -1 into a fatalx() itself, but a failed sighup_handler() reload push must not kill an otherwise-healthy daemon. */
-	if (imsg_compose(&iev->ibuf, IMSG_KEYMGR_INIT, 0, 0, -1, buf, n) == -1) {
+	/* imsg_compose() copies buf at once; safe to scrub our stack copy */
+	/*
+	 * Non-fatal here: parent_main() turns a boot-time -1 into a fatalx()
+	 * itself, but a failed sighup_handler() reload push must not kill an
+	 * otherwise-healthy daemon.
+	 */
+	if (imsg_compose(&iev->ibuf, IMSG_KEYMGR_INIT, 0, 0, -1, buf,
+	    n) == -1) {
 		explicit_bzero(buf, sizeof(buf));
 		log_warn("imsg_compose IMSG_KEYMGR_INIT");
 		return (-1);
@@ -1142,13 +1447,23 @@ send_keymgr_init(struct imsgev *iev, struct openimap_c
 	return (0);
 }
 
-/* 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 */
+/*
+ * 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, since spawn_connection() is now the only caller (once per connection); the truncation check below can't fire today since both fields are 1024 bytes, but it stays as the wire-struct invariant it documents. */
+	/*
+	 * Non-fatal, since spawn_connection() is now the only caller (once per
+	 * connection); the truncation check below can't fire today since both
+	 * fields are 1024 bytes, but it stays as the wire-struct invariant it
+	 * documents.
+	 */
 	memset(&init, 0, sizeof(init));
 	if (strlcpy(init.cred_file, conf->cred_file, sizeof(init.cred_file))
 	    >= sizeof(init.cred_file)) {
@@ -1169,7 +1484,11 @@ send_auth_init(struct imsgev *iev, struct openimap_con
 	return (0);
 }
 
-/* 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 */
+/*
+ * 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)
 {
@@ -1201,7 +1520,10 @@ sighup_handler(int fd, short event, void *arg)
 		    "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 */
+	/*
+	 * checked before writing gconf: overwritten in place, no saved old
+	 * value
+	 */
 	if (strlen(newconf.spool_root) >= sizeof(gconf->spool_root) ||
 	    strlen(newconf.tls_cert_file) >= sizeof(gconf->tls_cert_file) ||
 	    strlen(newconf.tls_key_file) >= sizeof(gconf->tls_key_file)) {
@@ -1215,8 +1537,16 @@ 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->append_max = newconf.append_max;
 	gconf->idle_poll_secs = newconf.idle_poll_secs;
-	/* No corresponding push needed for max_startups_* or tls_cert_file: startups_should_drop() and send_tls_cert() already read gconf fresh on every accept/spawn, so they pick up a SIGHUP change on the very next connection. */
+	gconf->login_grace_secs = newconf.login_grace_secs;
+	gconf->lock_timeout_secs = newconf.lock_timeout_secs;
+	/*
+	 * No corresponding push needed for max_startups_* or tls_cert_file:
+	 * startups_should_drop() and send_tls_cert() already read gconf fresh
+	 * on every accept/spawn, so they pick up a SIGHUP change 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;
@@ -1226,7 +1556,12 @@ sighup_handler(int fd, short event, void *arg)
 	(void)strlcpy(gconf->tls_key_file, newconf.tls_key_file,
 	    sizeof(gconf->tls_key_file));
 
-	/* 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. */
+	/*
+	 * 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 "
@@ -1239,24 +1574,102 @@ sighup_handler(int fd, short event, void *arg)
 	log_info("SIGHUP: reload complete");
 }
 
-static void
-sigterm_handler(int fd, short event, void *arg)
+/* children still to reap before the parent may exit */
+static int
+shutdown_children_left(void)
 {
 	struct child		*c;
 	struct store_child	*sc;
+	int			 n = 0;
 
-	(void)fd; (void)event; (void)arg;
-	log_info("SIGTERM: shutting down");
-
 	TAILQ_FOREACH(c, &children, entry)
-		kill(c->pid, SIGTERM);
+		n++;
 	TAILQ_FOREACH(sc, &store_children, entry)
-		kill(sc->pid, SIGTERM);
+		n++;
+	return (n);
+}
 
+/* SIGKILL whatever stayed, then go; also the 2nd-SIGTERM path */
+static void
+sigterm_force(int fd, short event, void *arg)
+{
+	struct child		*c;
+	struct store_child	*sc;
+	int			 n = 0;
+
+	(void)fd; (void)event; (void)arg;
+
+	TAILQ_FOREACH(c, &children, entry) {
+		kill(c->pid, SIGKILL);
+		n++;
+	}
+	TAILQ_FOREACH(sc, &store_children, entry) {
+		kill(sc->pid, SIGKILL);
+		n++;
+	}
+	if (n > 0)
+		log_warnx("shutdown: killed %d child(ren) that stayed", n);
+	log_info("shutdown complete");
 	exit(0);
 }
 
+/*
+ * Closes each listener worker's channel instead of signalling it: the
+ * worker tears its own session down on EOF, which asks its store child
+ * to exit and lets auth and search follow.
+ */
 static void
+sigterm_handler(int fd, short event, void *arg)
+{
+	struct child	*c;
+	struct timeval	 tv;
+	int		 i;
+
+	(void)fd; (void)event; (void)arg;
+
+	/* a 2nd SIGTERM does not wait, as l2tpd.c:509-512 also does */
+	if (shutting_down) {
+		log_info("SIGTERM again: not waiting for children");
+		sigterm_force(0, 0, NULL);
+	}
+	shutting_down = 1;
+	log_info("SIGTERM: shutting down");
+
+	/* no new session may start while draining */
+	for (i = 0; i < n_cleartext; i++)
+		event_del(&ev_accept_cleartext[i]);
+	for (i = 0; i < n_tls; i++)
+		event_del(&ev_accept_tls[i]);
+
+	TAILQ_FOREACH(c, &children, entry) {
+		if (c->type != PROC_LISTENER)
+			continue;
+		event_del(&c->iev.ev);
+		/* reap_child() must not close this a second time */
+		if (c->iev.ibuf.fd != -1) {
+			close(c->iev.ibuf.fd);
+			c->iev.ibuf.fd = -1;
+		}
+	}
+
+	/* keymgr is asked, not shot, so a bare EOF still means a crash */
+	if (iev_keymgr != NULL &&
+	    (imsg_compose(&iev_keymgr->ibuf, IMSG_KEYMGR_SHUTDOWN, 0, 0,
+	    -1, NULL, 0) == -1 || imsgbuf_flush(&iev_keymgr->ibuf) == -1))
+		log_warn("shutdown: could not tell keymgr to exit");
+
+	/* arm the timer only if there is anything to wait for */
+	if (shutdown_children_left() == 0) {
+		log_info("shutdown complete");
+		exit(0);
+	}
+	evtimer_set(&ev_shutdown, sigterm_force, NULL);
+	tv.tv_sec = SHUTDOWN_TIMEOUT_SEC;
+	tv.tv_usec = 0;
+	evtimer_add(&ev_shutdown, &tv);
+}
+
+static void
 sigchld_handler(int fd, short event, void *arg)
 {
 	pid_t	 pid;
@@ -1265,9 +1678,21 @@ sigchld_handler(int fd, short event, void *arg)
 	(void)fd; (void)event; (void)arg;
 	while ((pid = waitpid(-1, &status, WNOHANG)) > 0)
 		reap_child(pid, status);
+
+	/* the last child out ends the shutdown */
+	if (shutting_down && shutdown_children_left() == 0) {
+		evtimer_del(&ev_shutdown);
+		log_info("shutdown complete");
+		exit(0);
+	}
 }
 
-/* An unexpected child exit: keymgr (the sole boot-time child) is deliberately not auto-restarted and degrades the whole daemon; a per-connection listener/auth-worker or search-oracle exiting is the ordinary, expected way one session's resources get reclaimed, logged at debug level. */
+/*
+ * An unexpected child exit: keymgr (the sole boot-time child) is deliberately
+ * not auto-restarted and degrades the whole daemon; a per-connection
+ * listener/auth-worker or search-oracle exiting is the ordinary, expected way
+ * one session's resources get reclaimed, logged at debug level.
+ */
 static void
 reap_child(pid_t pid, int status)
 {
@@ -1280,11 +1705,17 @@ reap_child(pid_t pid, int status)
 
 			TAILQ_REMOVE(&children, c, entry);
 			event_del(&c->iev.ev);
-			close(c->iev.ibuf.fd);
+			/* sigterm_handler() may have closed this */
+			if (c->iev.ibuf.fd != -1)
+				close(c->iev.ibuf.fd);
 			imsgbuf_clear(&c->iev.ibuf);
 
 			if (c->type == PROC_KEYMGR) {
-				if (WIFSIGNALED(status))
+				/* asked to go on shutdown; not news */
+				if (shutting_down)
+					log_debug("keymgr[%d] exited on "
+					    "shutdown", pid);
+				else if (WIFSIGNALED(status))
 					log_warnx("keymgr[%d] terminated by "
 					    "signal %d, new TLS handshakes "
 					    "will now fail (already-"
@@ -1314,7 +1745,12 @@ reap_child(pid_t pid, int status)
 				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. */
+			/*
+			 * PROC_LISTENER, PROC_AUTH, or PROC_SEARCH: one of
+			 * spawn_connection()'s per-connection group,
+			 * search-oracle included. 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) ||
@@ -1338,7 +1774,13 @@ reap_child(pid_t pid, int status)
 				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, notice their own peer-channel EOF and exit on their own -- parent isn't in that data path (they're wired directly to listener-worker). */
+				/*
+				 * The paired auth-worker and search-oracle, if
+				 * still alive, notice their own peer-channel
+				 * EOF and exit on their own -- parent isn't in
+				 * that data path (they're wired directly to
+				 * listener-worker).
+				 */
 				os->listener_iev = NULL;
 				TAILQ_FOREACH(s, &store_children, entry) {
 					if (s->session_id == os->session_id)
blob - 14149db07500dc2c1e59623b7d51d131ead3a318
blob + 9c564ba78b6053da3d754665b6ae523b9de5bda0
--- src/parse.y
+++ src/parse.y
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  * Copyright (c) 2002, 2003, 2004 Henning Brauer <henning@openbsd.org>
@@ -101,9 +103,11 @@ typedef struct {
 
 %token	LISTEN ON TLS PORT
 %token	SPOOL CREDENTIALS CERTIFICATE KEY
-%token	ATTACHMENT MAX
+%token	APPEND ATTACHMENT MAX
 %token	STARTUPS BEGIN RATE FULL
 %token	IDLE POLL
+%token	LOGIN GRACE
+%token	LOCK TIMEOUT
 %token	INCLUDE
 %token	ERROR
 %token	<v.string>	STRING
@@ -285,6 +289,48 @@ main		: LISTEN ON STRING opttls PORT NUMBER	{
 			}
 			conf->idle_poll_secs = (uint32_t)$3;
 		}
+		| LOGIN GRACE NUMBER		{
+			/*
+			 * 0 is legal and disables the timer, matching
+			 * sshd_config(5)'s LoginGraceTime and "idle poll"
+			 * above. It reopens the denial of service the timer
+			 * exists to stop, so it is an escape hatch rather
+			 * than a tuning knob.
+			 */
+			if ($3 < 0 || $3 > LOGIN_GRACE_MAX) {
+				yyerror("login grace out of range "
+				    "(0 to disable, otherwise 1-%d "
+				    "seconds): %lld", LOGIN_GRACE_MAX,
+				    (long long)$3);
+				YYERROR;
+			}
+			conf->login_grace_secs = (uint32_t)$3;
+		}
+		| LOCK TIMEOUT NUMBER		{
+			/*
+			 * 0 is legal and disables the bound, matching
+			 * "idle poll" and "login grace" above: a command
+			 * then waits for the index lock as long as the
+			 * holder takes, with nothing to interrupt it.
+			 */
+			if ($3 < 0 || $3 > LOCK_TIMEOUT_MAX) {
+				yyerror("lock timeout out of range "
+				    "(0 to disable, otherwise 1-%d "
+				    "seconds): %lld", LOCK_TIMEOUT_MAX,
+				    (long long)$3);
+				YYERROR;
+			}
+			conf->lock_timeout_secs = (uint32_t)$3;
+		}
+		| APPEND MAX NUMBER	{
+			if ($3 < 1 || $3 > APPEND_MAX_MAX) {
+				yyerror("append max out of range "
+				    "(1-%d bytes): %lld", APPEND_MAX_MAX,
+				    (long long)$3);
+				YYERROR;
+			}
+			conf->append_max = (uint64_t)$3;
+		}
 		| ATTACHMENT MAX NUMBER	{
 			/*
 			 * Range-validated
@@ -299,7 +345,7 @@ main		: LISTEN ON STRING opttls PORT NUMBER	{
 		}
 		| STARTUPS BEGIN NUMBER RATE NUMBER FULL NUMBER {
 			/*
-			 * SS7's admission-control throttle, modeled on
+			 * The 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
@@ -370,15 +416,19 @@ lookup(char *s)
 {
 	/* this has to be sorted always */
 	static const struct keywords keywords[] = {
+	    {"append",			APPEND},
 	    {"attachment",		ATTACHMENT},
 	    {"begin",			BEGIN},
 	    {"certificate",		CERTIFICATE},
 	    {"credentials",		CREDENTIALS},
 	    {"full",			FULL},
+	    {"grace",			GRACE},
 	    {"idle",			IDLE},
 	    {"include",			INCLUDE},
 	    {"key",			KEY},
 	    {"listen",			LISTEN},
+	    {"lock",			LOCK},
+	    {"login",			LOGIN},
 	    {"max",			MAX},
 	    {"on",			ON},
 	    {"poll",			POLL},
@@ -386,6 +436,7 @@ lookup(char *s)
 	    {"rate",			RATE},
 	    {"spool",			SPOOL},
 	    {"startups",		STARTUPS},
+	    {"timeout",			TIMEOUT},
 	    {"tls",			TLS},
 	};
 	const struct keywords	*p;
@@ -756,6 +807,8 @@ config_load(const char *path, struct openimap_config *
 	conf->port_cleartext = 143;
 	conf->port_implicit_tls = 993;
 	conf->idle_poll_secs = IDLE_POLL_DEFAULT;
+	conf->login_grace_secs = LOGIN_GRACE_DEFAULT;
+	conf->lock_timeout_secs = LOCK_TIMEOUT_DEFAULT;
 	(void)strlcpy(conf->spool_root, "/var/mail/imapd",
 	    sizeof(conf->spool_root));
 	(void)strlcpy(conf->cred_file, "/etc/imapd/credentials",
@@ -765,6 +818,7 @@ config_load(const char *path, struct openimap_config *
 	(void)strlcpy(conf->tls_key_file, "/etc/ssl/private/imapd.key",
 	    sizeof(conf->tls_key_file));
 	conf->bodystructure_read_max = BODYSTRUCTURE_READ_DEFAULT;
+	conf->append_max = APPEND_MAX_DEFAULT;
 
 	/* sshd_config(5)'s own MaxStartups default is "10:30:100". */
 	conf->max_startups_begin = 10;
@@ -800,6 +854,19 @@ config_load(const char *path, struct openimap_config *
 			conf->port_implicit_tls = 0;
 	}
 
+	/*
+	 * Checked here, not in either rule, since the two directives may
+	 * come in either order. BODYSTRUCTURE and BODY[<part>] read a
+	 * message whole, up to "attachment max", so an upload larger than
+	 * that could be stored but never described.
+	 */
+	if (conf->append_max > conf->bodystructure_read_max) {
+		log_warnx("%s: append max %llu exceeds attachment max %u",
+		    path, (unsigned long long)conf->append_max,
+		    conf->bodystructure_read_max);
+		errors++;
+	}
+
 	/* Free macros and warn about any that were never referenced. */
 	TAILQ_FOREACH_SAFE(sym, &symhead, entry, next) {
 		if (!sym->used)
blob - eea834acd4869670b5e32b0af3bad5d9eec0270a
blob + 8bed78212415bc8cc45642b8d4ddc3428619f9f9
--- src/search_cmd.c
+++ src/search_cmd.c
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  *
@@ -14,7 +16,7 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* search_cmd.c: SEARCH key parsing, evaluation dispatch, and the async IMSG_MBOX_SEARCH_MATCH/IMSG_MBOX_RESULT completion path. */
+/* search_cmd.c: SEARCH key parsing, dispatch, async completion path */
 
 #include <sys/types.h>
 #include <sys/queue.h>
@@ -76,13 +78,14 @@ parse_search_date(const char *s, int64_t *out)
 	return (0);
 }
 
-/* accumulator for parse_search_key()'s compiled postfix program (flat-array wire format is in imapd.h) */
+/* accumulator for parse_search_key()'s postfix program (wire: imapd.h) */
 #define SEARCH_MAX_DEPTH	64	/* max parse_search_key() recursion */
 struct search_parse_ctx {
 	struct search_node	nodes[SEARCH_PROGRAM_MAX_NODES];
 	uint32_t		n;
 	int			depth;	/* current recursion depth */
-	int			uses_modseq;	/* set when a MODSEQ key is pushed; see cmd_search() */
+	/* set when a MODSEQ key is pushed; see cmd_search() */
+	int			uses_modseq;
 };
 
 
@@ -98,7 +101,13 @@ search_push(struct search_parse_ctx *ctx, const struct
 	return (0);
 }
 
-/* Reads one sequence-set token (RFC 9051 SS9's full comma-separated grammar) and pushes it as OR'd SEARCH_OP_SEQSET/SEARCH_OP_UIDSET leaf nodes; copied out first (not NUL-terminated in place) since a trailing ')' must remain visible to the caller, into a buffer sized off SESSION_INBUF_MAX so it can never truncate. */
+/*
+ * Reads one sequence-set token (RFC 9051 SS9's full comma-separated
+ * grammar) and pushes it as OR'd SEARCH_OP_SEQSET/SEARCH_OP_UIDSET
+ * leaf nodes; copied out first (not NUL-terminated in place) since
+ * a trailing ')' must remain visible to the caller, into a buffer
+ * sized off SESSION_INBUF_MAX so it can never truncate.
+ */
 static int
 parse_search_seqset(char **pp, struct search_parse_ctx *ctx, int op,
     const char **errmsg)
@@ -155,13 +164,16 @@ parse_search_seqset(char **pp, struct search_parse_ctx
 	return (0);
 }
 
-/* RFC 9051 SS6.4.4 search-key grammar, recursive-descent; returns 0 ok, -1 (BAD) malformed, -2 (NO) recognized-but-unsupported key. */
+/* RFC 9051 SS6.4.4 search-key grammar, recursive-descent: ok=0 BAD=-1 NO=-2 */
 int
 parse_search_key(char **pp, struct search_parse_ctx *ctx, const char **errmsg)
 {
 	int	rc;
 
-	/* every nesting level (parens, NOT, OR) re-enters this function; bound against crafted deep nesting */
+	/*
+	 * every nesting level (parens, NOT, OR) re-enters here; bounds deep
+	 * nesting
+	 */
 	if (++ctx->depth > SEARCH_MAX_DEPTH) {
 		ctx->depth--;
 		*errmsg = "search criteria nested too deeply";
@@ -173,7 +185,14 @@ 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. */
+/*
+ * 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)
@@ -384,7 +403,12 @@ parse_search_key_inner(char **pp, struct search_parse_
 			*errmsg = "malformed octet count";
 			return (-1);
 		}
-		/* node.num is int64_t compared signed by mbox_search.c, so an unchecked cast turns "LARGER 18446744073709551615" into "size > -1" (matches everything) -- reachable since numbuf holds 23 digits and ULLONG_MAX is 20. */
+		/*
+		 * node.num is int64_t compared signed by mbox_search.c, so an
+		 * unchecked cast turns "LARGER 18446744073709551615" into
+		 * "size > -1" (matches everything) -- reachable since numbuf
+		 * holds 23 digits and ULLONG_MAX is 20.
+		 */
 		if (v > (unsigned long long)INT64_MAX) {
 			*errmsg = "octet count out of range";
 			return (-1);
@@ -419,7 +443,10 @@ parse_search_key_inner(char **pp, struct search_parse_
 		while (*p == ' ')
 			p++;
 
-		/* RFC 7162 SS3.1.5: optional entry-name/entry-type-req (METADATA, unimplemented) parsed past and ignored */
+		/*
+		 * RFC 7162 SS3.1.5: optional entry-name/type-req
+		 * (unimplemented) skipped
+		 */
 		if (*p != '\0' && !isdigit((unsigned char)*p)) {
 			if (*p == '"') {
 				char	*end = strchr(p + 1, '"');
@@ -536,7 +563,7 @@ parse_search_key_inner(char **pp, struct search_parse_
 	return (-1);
 }
 
-/* search-key *(SP search-key), ANDed left to right; in_parens stops at (but doesn't consume) the closing ')' */
+/* search-key *(SP search-key), ANDed left-right; in_parens stops at ')' */
 int
 parse_search_key_list(char **pp, struct search_parse_ctx *ctx,
     const char **errmsg, int in_parens)
@@ -587,7 +614,14 @@ parse_search_key_list(char **pp, struct search_parse_c
 	}
 }
 
-/* search_oracle.c's one entry point into this file's private struct search_parse_ctx: parses already-stripped SEARCH argument text into nodes_out (a caller-supplied SEARCH_PROGRAM_MAX_NODES buffer), returning parse_search_key_list()'s rc (0/-1/-2) with errmsg copied out on failure; the empty-criteria check moved here too as grammar validation. */
+/*
+ * search_oracle.c's one entry point into this file's private
+ * struct search_parse_ctx: parses already-stripped SEARCH argument
+ * text into nodes_out (a caller-supplied SEARCH_PROGRAM_MAX_NODES
+ * buffer), returning parse_search_key_list()'s rc (0/-1/-2) with
+ * errmsg copied out on failure; the empty-criteria check moved
+ * here too as grammar validation.
+ */
 int
 search_oracle_parse(char *args, struct search_node *nodes_out,
     uint32_t *nnodes_out, int *uses_modseq_out, char *errmsg_out,
@@ -616,7 +650,7 @@ search_oracle_parse(char *args, struct search_node *no
 	return (0);
 }
 
-/* RFC 9051 SS6.4.4 search-return-opts; SAVE ("$" result variable) recognized but rejected -2/NO, needs cross-cutting support elsewhere */
+/* RFC 9051 SS6.4.4 search-return-opts; SAVE recognized but rejected -2/NO */
 int
 parse_search_return_opts(char **pp, uint32_t *opts_out, const char **errmsg)
 {
@@ -678,14 +712,14 @@ parse_search_return_opts(char **pp, uint32_t *opts_out
 	}
 }
 
-/* RFC 9051 SS6.4.4 SEARCH; CHARSET accepted only as US-ASCII/UTF-8, else NO [BADCHARSET]; content/header-based keys unsupported (no content access) */
+/* RFC 9051 SS6.4.4 SEARCH; CHARSET US-ASCII/UTF-8 only, else unsupported */
 int
 cmd_search(struct session *s, const char *tag, char *args)
 {
 	return search_dispatch(s, tag, args, 0);
 }
 
-/* shared by cmd_search() and UID SEARCH; by_uid only changes what's reported per match (UID vs seqno), not parsing/matching (SS6.4.9) */
+/* shared by cmd_search()/UID SEARCH; by_uid changes reporting, not parsing */
 int
 search_dispatch(struct session *s, const char *tag, char *args, int by_uid)
 {
@@ -751,7 +785,10 @@ search_dispatch(struct session *s, const char *tag, ch
 
 		if (strcasecmp(charset, "US-ASCII") != 0 &&
 		    strcasecmp(charset, "UTF-8") != 0) {
-			/* RFC 9051 SS9 ABNF requires parens around the charset list; normative over SS6.4.4.4's prose example */
+			/*
+			 * RFC 9051 SS9 ABNF requires parens around charset
+			 * list; over SS6.4.4.4
+			 */
 			session_reply(s, tag, "NO",
 			    "[BADCHARSET (US-ASCII UTF-8)] unsupported "
 			    "CHARSET");
@@ -770,7 +807,14 @@ search_dispatch(struct session *s, const char *tag, ch
 		return (1);
 	}
 
-	/* SS8.1: grammar parsing no longer happens in this process -- the highest-risk remainder is handed to the per-connection search-oracle (same fail-soft channel check sasl_plain_finish() uses); the old inline parsing and its aftermath now live in search_dispatch_finish(), invoked once IMSG_SEARCH_PARSE_RESULT arrives. */
+	/*
+	 * Grammar parsing no longer happens in this process -- the
+	 * highest-risk remainder is handed to the per-connection
+	 * search-oracle (same fail-soft channel check sasl_plain_finish()
+	 * uses); the old inline parsing and its aftermath now live in
+	 * search_dispatch_finish(), invoked once IMSG_SEARCH_PARSE_RESULT
+	 * arrives.
+	 */
 	if (strlen(p) >= SEARCH_ORACLE_ARGS_MAX) {
 		session_reply(s, tag, "BAD", "SEARCH criteria too long");
 		return (1);
@@ -782,7 +826,7 @@ search_dispatch(struct session *s, const char *tag, ch
 		return (1);
 	}
 
-	/* defensive cleanup of a previous SEARCH's leftovers; shouldn't actually find anything here */
+	/* defensive cleanup of a previous SEARCH; should not find any */
 	free(s->search_matches);
 	s->search_matches = NULL;
 	s->search_nmatches = 0;
@@ -800,7 +844,12 @@ search_dispatch(struct session *s, const char *tag, ch
 
 	if (imsg_compose(&iev_search.ibuf, IMSG_SEARCH_PARSE_REQUEST, 0, 0,
 	    -1, p, strlen(p)) == -1) {
-		/* The oracle never received this request, so without an explicit reply here the session would wait forever in SESSION_SEARCH_PARSING (no inactivity timeout), queueing commands until SESSION_CMD_QUEUE_MAX drops the connection. */
+		/*
+		 * The oracle never received this request, so without an
+		 * explicit reply here the session would wait forever in
+		 * SESSION_SEARCH_PARSING (no inactivity timeout), queueing
+		 * commands 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",
@@ -812,7 +861,14 @@ search_dispatch(struct session *s, const char *tag, ch
 	return (1);
 }
 
-/* SS8.1: completes search_dispatch() once listener_dispatch_search() gets this session's IMSG_SEARCH_PARSE_RESULT -- replies BAD/NO from the oracle's (rc, errmsg) or, on rc==0, does what a successful parse_search_key_list() call used to; nodes is non-NULL only when rc==0 and nnodes>0, and the caller owns freeing it. */
+/*
+ * Completes search_dispatch() once listener_dispatch_search()
+ * gets this session's IMSG_SEARCH_PARSE_RESULT -- replies BAD/NO
+ * from the oracle's (rc, errmsg) or, on rc==0, does what a
+ * successful parse_search_key_list() call used to; nodes is
+ * non-NULL only when rc==0 and nnodes>0, and the caller owns
+ * freeing it.
+ */
 void
 search_dispatch_finish(struct session *s,
     const struct imsg_search_parse_result *res, struct search_node *nodes)
@@ -831,7 +887,10 @@ search_dispatch_finish(struct session *s,
 	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 */
+	/*
+	 * RFC 7162 SS3.1: a SEARCH with MODSEQ item is a CONDSTORE enabling
+	 * command
+	 */
 	if (res->uses_modseq)
 		session_condstore_enable(s);
 
@@ -877,20 +936,27 @@ session_handle_mbox_search_match(struct session *s,
 		s->search_matches_cap = newcap;
 	}
 
-	/* RFC 9051 SS6.4.9: UID SEARCH reports UIDs instead of sequence numbers in ESEARCH data */
+	/*
+	 * RFC 9051 SS6.4.9: UID SEARCH reports UIDs, not seqnos, in ESEARCH
+	 * data
+	 */
 	s->search_matches[s->search_nmatches++] = s->cmd_by_uid ?
 	    m->uid : m->seqno;
 
-	/* RFC 7162 SS3.1.6: running max modseq; printed only if MODSEQ was used (session_finish_search()) */
+	/* RFC 7162 SS3.1.6: running max modseq; printed only if MODSEQ used */
 	if (m->modseq > s->search_max_modseq)
 		s->search_max_modseq = m->modseq;
 }
 
-/* Fixed part of an ESEARCH line (correlator + tag + " UID" + MIN/MAX/COUNT/MODSEQ + CRLF) with headroom; the variable ALL list is budgeted separately via SEARCH_ALL_PER_MATCH below. */
+/*
+ * Fixed part of an ESEARCH line (correlator + tag + " UID" +
+ * MIN/MAX/COUNT/MODSEQ + CRLF) with headroom; the variable ALL
+ * list is budgeted separately via 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) */
+/* builds/sends ESEARCH response once store's SEARCH pass completes (SS6.4.4) */
 void
 session_finish_search(struct session *s, struct imsg_mbox_result *res)
 {
@@ -901,14 +967,28 @@ session_finish_search(struct session *s, struct imsg_m
 
 	s->state = SESSION_SELECTED;
 
-	/* res->error is enum mbox_op_error, not a boolean (MBOX_OP_OK is 1, MBOX_ERR_UNSET is 0), so print it as an OK/error label rather than a raw number that would misread success as a fault. */
+	/*
+	 * res->error is enum mbox_op_error, not a boolean (MBOX_OP_OK is
+	 * 1, MBOX_ERR_UNSET is 0), so print it as an OK/error label
+	 * rather than a raw number that would misread success as a fault.
+	 */
 	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_ERR_BUSY) {
+		session_reply(s, s->pending_tag, "NO", IMAP_BUSY_TEXT);
+		goto cleanup;
+	}
 	if (res->error != MBOX_OP_OK || s->search_alloc_failed)
 		goto fail;
 
-	/* Heap-allocated 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 silently produce a short-but-valid ESEARCH, making a bulk MOVE/STORE/EXPUNGE quietly operate on a subset. */
+	/*
+	 * Heap-allocated 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 silently produce a short-but-valid
+	 * ESEARCH, making a bulk MOVE/STORE/EXPUNGE 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) {
@@ -918,14 +998,19 @@ session_finish_search(struct session *s, struct imsg_m
 
 	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 can't hold a quote or backslash that would break the quoting above -- checked rather than assumed. */
+		/*
+		 * pending_tag is IMAP_TAG_MAX-bounded and
+		 * tag_is_valid()-checked by session_handle_line(), so it can't
+		 * hold a quote or backslash that would 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 */
+	/* RFC 9051 SS9: "UID" after correlator, before MIN/MAX/ALL/MODSEQ */
 	if (s->cmd_by_uid && len < bufsize)
 		len += (size_t)snprintf(buf + len, bufsize - len, " UID");
 
@@ -962,14 +1047,21 @@ session_finish_search(struct session *s, struct imsg_m
 		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 */
+	/*
+	 * 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 < 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; bufsize is sized for the worst case so this can't actually fire, but keep the defensive clamp anyway rather than trust that reasoning blindly. */
+	/*
+	 * len holds snprintf(3)'s "would-be" length; bufsize is sized
+	 * for the worst case so this can't actually fire, but keep the
+	 * defensive clamp anyway rather than trust that reasoning blindly.
+	 */
 	if (len >= bufsize - 1)
 		len = bufsize - 2;
 
@@ -978,7 +1070,10 @@ session_finish_search(struct session *s, struct imsg_m
 	session_write(s, buf, len);
 	free(buf);
 
-	/* "UID SEARCH completed" follows SS6.4.9's "UID <cmd> completed" pattern */
+	/*
+	 * "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;
blob - e1d5652a643230ab97c02d67a8bb286fc2538f9f
blob + a67fc4d0d6d609010a94600dcbce2e6c785531b5
--- src/search_oracle.c
+++ src/search_oracle.c
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  *
@@ -14,7 +16,12 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* search_oracle.c: per-connection SEARCH-grammar parsing process (SS8.1) forked by parent.c, holding only its one peer channel and no TLS/creds/other channels, so a grammar memory-safety bug reaches nothing else; exits on peer EOF like auth.c. */
+/*
+ * search_oracle.c: per-connection SEARCH-grammar parsing process, forked
+ * by parent.c, holding only its one peer channel and no TLS/creds/other
+ * channels, so a grammar memory-safety bug reaches nothing else; exits on peer
+ * EOF like auth.c.
+ */
 
 #include <sys/types.h>
 
@@ -31,7 +38,8 @@
 #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 */
+/* fd 3 -- nothing else arrives on it; see dispatch_parent()'s own comment */
+static struct imsgev	 iev_parent;
 
 static void	 search_oracle_dispatch(int, short, void *);
 static void	 search_oracle_dispatch_parent(int, short, void *);
@@ -45,11 +53,19 @@ search_oracle_main(void)
 	struct passwd	*pw;
 	int		 peer_fd;
 	ssize_t		 n;
+	uint32_t	 session_id;
 
-	/* fd-passing is allowed on this channel for the IMSG_SETUP_SEARCH_PEER peer fd below; see imsgev_ibuf_init()'s own comment */
+	/*
+	 * 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 or listener.c, this process needs no config at all beyond the one peer fd -- its whole boot sequence is draining this one message. */
+	/*
+	 * Unlike auth.c or listener.c, this process needs no config at all
+	 * beyond the one peer fd -- its whole boot sequence is draining this
+	 * one message.
+	 */
 	for (;;) {
 		if ((n = imsgbuf_get(&ibuf3, &imsg)) == -1)
 			fatal("imsgbuf_get");
@@ -65,11 +81,17 @@ search_oracle_main(void)
 		fatalx("search-oracle: expected IMSG_SETUP_SEARCH_PEER, got "
 		    "%d", imsg_get_type(&imsg));
 	peer_fd = imsg_get_fd(&imsg);
+	/* the id field carries this worker's session; see parent.c */
+	session_id = imsg_get_id(&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/auth's -- it holds no secrets, but a dedicated uid still keeps its compromise domain separate, per project convention. */
+	/*
+	 * search-oracle's own daemon-user identity, distinct from
+	 * listener's/auth's -- it holds no secrets, but a dedicated uid still
+	 * keeps its compromise domain separate, per project convention.
+	 */
 	if ((pw = getpwnam("_imapsearch")) == NULL)
 		fatalx("getpwnam _imapsearch: no such user "
 		    "(expected, not yet provisioned by an install script)");
@@ -84,12 +106,20 @@ search_oracle_main(void)
 	    setresuid(pw->pw_uid, pw->pw_uid, pw->pw_uid) == -1)
 		fatal("cannot drop privileges to _imapsearch");
 
+	setproctitle("session %u search", session_id);
+
 	event_init();
 	imsgev_init(&iev_listener, peer_fd, search_oracle_dispatch, NULL);
-	imsgev_init_from_ibuf(&iev_parent, &ibuf3, search_oracle_dispatch_parent,
+	imsgev_init_from_ibuf(&iev_parent, &ibuf3,
+	    search_oracle_dispatch_parent,
 	    NULL);
 
-	/* No rpath (touches no file, ever), no inet (only its one already-open peer channel), no recvfd/sendfd (never receives or attaches a descriptor beyond the one peer fd drained at boot) -- leaving just "stdio", matching auth.c minus rpath. */
+	/*
+	 * No rpath (touches no file, ever), no inet (only its one already-open
+	 * peer channel), no recvfd/sendfd (never receives or attaches a
+	 * descriptor beyond the one peer fd drained at boot) -- leaving just
+	 * "stdio", matching auth.c minus rpath.
+	 */
 #ifdef __OpenBSD__
 	if (pledge("stdio", NULL) == -1)
 		fatal("pledge");
@@ -99,7 +129,7 @@ search_oracle_main(void)
 	fatalx("search-oracle: exited event loop");
 }
 
-/* EV_WRITE must be handled: imsg_compose() only queues, imsgbuf_write() puts it on the wire */
+/* EV_WRITE must be handled: imsg_compose() queues, imsgbuf_write() sends it */
 static void
 search_oracle_dispatch(int fd, short event, void *arg)
 {
@@ -116,7 +146,13 @@ search_oracle_dispatch(int fd, short event, void *arg)
 		if ((n = imsgbuf_read(&iev->ibuf)) == -1)
 			fatal("imsgbuf_read");
 		if (n == 0) {
-			/* SS7/SS8: this process serves exactly one connection and exits when it's done rather than idling in event_dispatch(), matching auth.c/listener.c; parent.c's reap_child() treats this as expected, not a warning. */
+			/*
+			 * This process serves exactly one connection and exits
+			 * when it's done rather than idling in
+			 * event_dispatch(), matching auth.c/listener.c;
+			 * parent.c's reap_child() treats this as expected, not
+			 * a warning.
+			 */
 			log_debug("search-oracle: listener closed channel, "
 			    "exiting");
 			exit(0);
@@ -131,8 +167,8 @@ search_oracle_dispatch(int fd, short event, void *arg)
 
 		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];
+			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;
@@ -141,7 +177,7 @@ search_oracle_dispatch(int fd, short event, void *arg)
 			if (len > (size_t)SEARCH_ORACLE_ARGS_MAX) {
 				log_warnx("IMSG_SEARCH_PARSE_REQUEST too "
 				    "large (%zu > %u)", len,
-				    (unsigned)SEARCH_ORACLE_ARGS_MAX);
+				    (unsigned int)SEARCH_ORACLE_ARGS_MAX);
 				search_oracle_fail(iev,
 				    "SEARCH criteria too long");
 				break;
@@ -152,7 +188,10 @@ search_oracle_dispatch(int fd, short event, void *arg)
 				    "malformed SEARCH request");
 				break;
 			}
-			/* imsg_get_data() guarantees size, not NUL termination -- same discipline as auth.c's own inbound imsgs */
+			/*
+			 * imsg_get_data() guarantees size, not NUL term, same
+			 * as auth.c's imsgs
+			 */
 			args[len] = '\0';
 
 			memset(&res, 0, sizeof(res));
@@ -178,7 +217,10 @@ search_oracle_dispatch(int fd, short event, void *arg)
 			    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 */
+				/*
+				 * the small reply may still fit where the full
+				 * one did not
+				 */
 				search_oracle_fail(iev,
 				    "SEARCH temporarily unavailable");
 			}
@@ -196,7 +238,12 @@ search_oracle_dispatch(int fd, short event, void *arg)
 	(void)fd;
 }
 
-/* Answers a parse request this process couldn't carry out so the listener-worker is never left waiting -- every handler path replies, even "cannot happen" ones, since a missing reply wedges the session forever; rc = -2 (NO) since these are local failures, not bad search criteria. */
+/*
+ * Answers a parse request this process couldn't carry out so the
+ * listener-worker is never left waiting -- every handler path replies, even
+ * "cannot happen" ones, since a missing reply wedges the session forever; rc =
+ * -2 (NO) since these are local failures, not bad search criteria.
+ */
 static void
 search_oracle_fail(struct imsgev *iev, const char *errmsg)
 {
@@ -211,7 +258,10 @@ search_oracle_fail(struct imsgev *iev, const char *err
 		log_warn("imsg_compose IMSG_SEARCH_PARSE_RESULT (failure)");
 }
 
-/* parent never sends search-oracle anything post-boot, so this only notices if parent's end closes, same as auth.c's auth_dispatch_parent(). */
+/*
+ * parent never sends search-oracle anything post-boot, so this only notices 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)
 {
blob - 5d2c2f86c509d2d7f1b06c1f850cf4f11f3f7995
blob + 913b498731d9da804ddf2018bad6702f97c313af
--- src/store.c
+++ src/store.c
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  *
@@ -14,7 +16,7 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* store.c, mailbox-store process: one per session, privilege-dropped, handles IMSG_MBOX_*. */
+/* store.c: privilege-dropped mailbox-store process; handles IMSG_MBOX_*. */
 
 #include <sys/types.h>
 #include <sys/file.h>
@@ -40,15 +42,23 @@
 
 static struct imsgev	 iev_listener;
 
-/* Defined below store_main(); declared here so boot can apply the same NUL-termination rule to IMSG_STORE_INIT as every runtime message. */
+/*
+ * Defined below store_main(); declared here so boot can apply the
+ * same NUL-termination rule to IMSG_STORE_INIT as every runtime
+ * message.
+ */
 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 */
+/* definitions for store_internal.h's extern globals, shared across store_*.c */
 uint32_t		 session_id;
 uint32_t		 append_counter;
-char			 current_mailbox_dir[MBOX_NAME_MAX];
+char			 selected_mailbox[MBOX_NAME_MAX];
 int			 mailbox_selected;
 uint32_t		 bodystructure_read_max;
+uint64_t		 append_max;
+uint32_t		 lock_timeout_secs;
+int			 maildir_root_fd = -1;
+int			 mailbox_dir_fd = -1;
 
 __dead void
 store_main(void)
@@ -60,12 +70,15 @@ store_main(void)
 	int			 peer_fd;
 	gid_t			 gid;
 
-	/* store children take no imapd.conf; uid/gid/spool_root arrive via IMSG_STORE_INIT below instead */
+	/* store children take no imapd.conf; uid/gid/spool_root via INIT */
 
-	/* fd-passing is allowed on this channel for the SETUP_PEER peer fd below; see imsgev_ibuf_init()'s own comment */
+	/*
+	 * fd-passing allowed on channel for SETUP_PEER fd; see
+	 * imsgev_ibuf_init()
+	 */
 	imsgev_ibuf_init(&ibuf3, 3);
 
-	/* IMSG_STORE_INIT must be read first: the privilege target is a runtime value, needed before chroot() */
+	/* IMSG_STORE_INIT first: privilege target is runtime, pre-chroot() */
 	for (;;) {
 		if ((n = imsgbuf_get(&ibuf3, &imsg)) == -1)
 			fatal("imsgbuf_get");
@@ -85,8 +98,15 @@ store_main(void)
 
 	session_id = init.session_id;
 	bodystructure_read_max = init.bodystructure_read_max;
+	append_max = init.append_max;
+	lock_timeout_secs = init.lock_timeout_secs;
 
-	/* imsg_get_data() guarantees payload size, not NUL-termination, and these fields feed chroot(2)/snprintf("%s") directly; fatalx() here since this is trusted boot-time data, not a runtime message that gets a graceful refusal. */
+	/*
+	 * imsg_get_data() guarantees payload size, not NUL-termination,
+	 * and these fields feed chroot(2)/snprintf("%s") directly;
+	 * fatalx() here since this is trusted boot-time data, not a
+	 * runtime message that gets a graceful refusal.
+	 */
 	if (!imsg_field_valid(init.spool_root, sizeof(init.spool_root),
 	    "IMSG_STORE_INIT.spool_root") ||
 	    !imsg_field_valid(init.maildir, sizeof(init.maildir),
@@ -98,7 +118,10 @@ store_main(void)
 	if (chdir("/") == -1)
 		fatal("chdir /");
 
-	/* drop to the authenticated user's own uid/gid so a bug here is confined to this user's files */
+	/*
+	 * drop to authenticated user's uid/gid, confining bugs to that user's
+	 * files
+	 */
 	gid = init.gid;
 	if (setgroups(1, &gid) == -1 ||
 	    setresgid(init.gid, init.gid, init.gid) == -1 ||
@@ -106,9 +129,16 @@ store_main(void)
 		fatal("session %u: cannot drop privileges to uid %u gid %u",
 		    session_id, init.uid, init.gid);
 
-	/* Confinement happens before the handshake ack, not after: acking first would leave a later filesystem failure with no reply, while acking last lets a bad maildir trip parent's pending-timeout and the designed failure path. */
+	setproctitle("session %u store", session_id);
 
-	/* unveil() scoped to THIS session's own mailbox subdir, narrowing the view past chroot alone */
+	/*
+	 * Confinement happens before the handshake ack, not after:
+	 * acking first would leave a later filesystem failure with no
+	 * reply, while acking last lets a bad maildir trip parent's
+	 * pending-timeout and the designed failure path.
+	 */
+
+	/* unveil() scoped to the session's mailbox subdir, past chroot */
 	{
 		char	unveil_path[sizeof(init.maildir) + 1];
 
@@ -119,23 +149,47 @@ store_main(void)
 		if (unveil(unveil_path, "rwc") == -1)
 			fatal("unveil %s", unveil_path);
 
-		/* chdir in once so every handler below can use bare relative paths under the unveiled dir */
+		/*
+		 * chdir once so handlers can use bare relative paths under
+		 * unveiled dir
+		 */
 		if (chdir(unveil_path) == -1)
 			fatal("chdir %s", unveil_path);
 	}
+
+	/*
+	 * Descriptors for the maildir root and for the directory this
+	 * child stands in. unveil(2) covers a lookup relative to a
+	 * descriptor exactly as it covers one relative to the cwd: namei()
+	 * calls unveil_start_relative() on the starting vnode in both
+	 * cases (sys/kern/vfs_lookup.c).
+	 */
+	if ((maildir_root_fd = open(".", O_RDONLY | O_DIRECTORY)) == -1)
+		fatal("open maildir root");
+	if ((mailbox_dir_fd = open(".", O_RDONLY | O_DIRECTORY)) == -1)
+		fatal("open maildir root as the selected mailbox");
+
 	if (unveil(NULL, NULL) == -1)
 		fatal("unveil lock");
 
-	/* privilege-dropped and confined now; finish handshake like a boot-time child (one peer, then SETUP_DONE+ack) */
+	/*
+	 * privilege-dropped, confined; finish handshake like boot child (peer,
+	 * 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);
 
-	/* pledges flock/rpath/wpath/cpath for index and delivery, no fattr; no recvfd/sendfd since this process's one peer fd already arrived and it never attaches a descriptor to an imsg itself. */
+	/*
+	 * flock/rpath/wpath/cpath for index and delivery, no fattr;
+	 * sendfd for FETCH, which hands the listener a read-only
+	 * descriptor on a message rather than its octets. No recvfd:
+	 * this process's one peer fd has already arrived.
+	 */
 #ifdef __OpenBSD__
-	if (pledge("stdio rpath wpath cpath flock", NULL) == -1)
+	if (pledge("stdio rpath wpath cpath flock sendfd", NULL) == -1)
 		fatal("pledge");
 #endif
 
@@ -150,7 +204,14 @@ store_main(void)
 int
 mailbox_name_valid(const char *name)
 {
-	/* This name came off the imsg wire as a fixed-size field, so before treating it as a C string at all: imsg_get_data() guarantees the payload's SIZE, never that the field inside it is terminated. The listener's own copy has no equivalent check because its names come out of its own parser already terminated. Everything after this is the shared rule. */
+	/*
+	 * This name came off the imsg wire as a fixed-size field, so
+	 * before treating it as a C string at all: imsg_get_data()
+	 * guarantees the payload's SIZE, never that the field inside
+	 * it is terminated. The listener's own copy has no equivalent
+	 * check because its names come out of its own parser already
+	 * terminated. Everything after this is the shared rule.
+	 */
 	if (memchr(name, '\0', MBOX_NAME_MAX) == NULL) {
 		log_warnx("session %u: mailbox name field is not "
 		    "NUL-terminated, refusing", session_id);
@@ -160,62 +221,22 @@ mailbox_name_valid(const char *name)
 	return (mailbox_name_syntax_ok(name));
 }
 
-/* chdir's to `target` (empty string = INBOX root), tracked in current_mailbox_dir; restores cwd on failure */
+
+
+/*
+ * Opens a mailbox directory by name, "" being the maildir root
+ * (INBOX). Returns -1 with errno set when it is absent or is not a
+ * directory, and the caller closes what it gets. Nothing chdir(2)s
+ * after boot: a mailbox is named by a descriptor, never by where this
+ * process happens to be standing.
+ */
 int
-select_mailbox_dir(const char *target)
+mailbox_open_dir(const char *target)
 {
-	if (strcmp(current_mailbox_dir, target) == 0)
-		return (0);
-
-	if (current_mailbox_dir[0] != '\0' && chdir("..") == -1) {
-		log_warn("session %u: chdir .. (leaving %s)", session_id,
-		    current_mailbox_dir);
-		return (-1);
-	}
-
-	if (target[0] != '\0') {
-		struct stat	st;
-		int		exists;
-
-		exists = (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)
-				log_warn("session %u: chdir %s", session_id,
-				    target);
-			if (current_mailbox_dir[0] != '\0' &&
-			    chdir(current_mailbox_dir) == -1)
-				log_warn("session %u: chdir %s (restoring "
-				    "after failed select)", session_id,
-				    current_mailbox_dir);
-			return (-1);
-		}
-	}
-
-	/* Invalidate index.c's IDLE stat(2) sample here, the only place the mailbox changes -- otherwise the first poll after SELECT would report "unchanged" for a directory it 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 */
-		log_warnx("session %u: select_mailbox_dir: target truncated "
-		    "-- can't happen (callers bound target under "
-		    "MBOX_NAME_MAX, same size as current_mailbox_dir)",
-		    session_id);
-		if (chdir("..") == -1)
-			log_warn("session %u: chdir .. (leaving %s after "
-			    "truncated select)", session_id, target);
-		else if (current_mailbox_dir[0] != '\0' &&
-		    chdir(current_mailbox_dir) == -1)
-			log_warn("session %u: chdir %s (restoring after "
-			    "truncated select)", session_id,
-			    current_mailbox_dir);
-		return (-1);
-	}
-	return (0);
+	return (openat(maildir_root_fd, target[0] != '\0' ? target : ".",
+	    O_RDONLY | O_DIRECTORY));
 }
 
-
 /* 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)
@@ -245,7 +266,14 @@ search_nodes_valid(const struct search_node *nodes, ui
 	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). */
+/*
+ * 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)
@@ -284,14 +312,298 @@ recv_trailing_array(struct imsg *imsg, const char *wha
 	return (out);
 }
 
-/* SS6.2 retrofit: mailbox_selected verifies independently, in this process's own state, that MBOX_SELECT succeeded before FETCH/STORE/EXPUNGE/SEARCH/COPY/MOVE/IDLE_REFRESH, rather than trusting listener.c's gating -- a compromised listener could send these out of order. */
+/*
+ * A command that could not take a mailbox's index lock, kept until it can
+ * or until "lock timeout" expires. One slot is enough: the listener holds
+ * a session's next command while one is in flight (session_is_busy(),
+ * listener.c), so a store child has at most one command outstanding.
+ *
+ * The command is re-run from the top rather than resumed, so a handler
+ * that defers must not have touched the mailbox or this session first.
+ */
+static struct {
+	int			 active;
+	uint32_t		 type;
+	struct imsgev		*iev;
+	struct event		 ev;
+	struct timespec		 give_up_at;
+	unsigned int		 wait_ms;
+	union {
+		struct imsg_mbox_store		 store;
+		struct imsg_mbox_expunge	 expunge;
+		struct imsg_mbox_fetch		 fetch;
+		struct imsg_mbox_search		 search;
+		struct imsg_mbox_select		 select;
+		struct imsg_mbox_status		 status;
+		struct imsg_mbox_copy		 copy;
+	} req;
+	union {
+		struct seq_range	 ranges[SEQSET_MAX_RANGES];
+		struct search_node	 nodes[SEARCH_PROGRAM_MAX_NODES];
+	} elts;
+	uint32_t		 nelts;
+} deferred;
+
+/*
+ * Doubling from the first to the cap keeps a long wait cheap in wakeups,
+ * and the cap is also the worst-case delay between the lock coming free
+ * and this command noticing. Measured on a 100,000 message mailbox, a
+ * command blocked behind a 46 second STORE costs about 190 wakeups at
+ * this cap, which is four a second; raising it would buy fewer wakeups
+ * than the machine will notice and pay for them in latency.
+ */
+#define LOCK_RETRY_FIRST_MS	50
+#define LOCK_RETRY_CAP_MS	250
+
+static void	 deferred_retry(int, short, void *);
+static void	 deferred_answer_busy_for(uint32_t, struct imsgev *);
+
+/* True once "lock timeout" has passed. 0 disables the bound entirely. */
 static int
+deferred_expired(void)
+{
+	struct timespec	 now;
+
+	if (lock_timeout_secs == 0)
+		return (0);
+	if (clock_gettime(CLOCK_MONOTONIC, &now) == -1) {
+		log_warn("session %u: clock_gettime", session_id);
+		return (1);	/* cannot tell how long: stop waiting */
+	}
+	if (now.tv_sec != deferred.give_up_at.tv_sec)
+		return (now.tv_sec > deferred.give_up_at.tv_sec);
+	return (now.tv_nsec >= deferred.give_up_at.tv_nsec);
+}
+
+/* RFC 9051 SS7.1 INUSE: answers one command, having changed nothing. */
+static void
+deferred_answer_busy_for(uint32_t type, struct imsgev *iev)
+{
+	struct imsg_mbox_result		 result;
+	struct imsg_mbox_selected	 selected;
+	struct imsg_mbox_status_result	 status;
+	struct imsg_mbox_appended	 appended;
+	const void			*data = NULL;
+	size_t				 len = 0;
+	uint32_t			 rtype = 0;
+
+	switch (type) {
+	case IMSG_MBOX_STORE:
+	case IMSG_MBOX_EXPUNGE:
+	case IMSG_MBOX_FETCH:
+	case IMSG_MBOX_SEARCH:
+	case IMSG_MBOX_COPY:
+	case IMSG_MBOX_MOVE:
+		memset(&result, 0, sizeof(result));
+		result.error = MBOX_OP_ERR_BUSY;
+		rtype = IMSG_MBOX_RESULT;
+		data = &result;
+		len = sizeof(result);
+		break;
+	case IMSG_MBOX_SELECT:
+		memset(&selected, 0, sizeof(selected));
+		selected.error = MBOX_OP_ERR_BUSY;
+		rtype = IMSG_MBOX_SELECTED;
+		data = &selected;
+		len = sizeof(selected);
+		break;
+	case IMSG_MBOX_STATUS:
+		memset(&status, 0, sizeof(status));
+		status.error = MBOX_OP_ERR_BUSY;
+		rtype = IMSG_MBOX_STATUS_RESULT;
+		data = &status;
+		len = sizeof(status);
+		break;
+	case IMSG_MBOX_APPEND_END:
+		/* the tmp/ file and its descriptors go with the command */
+		append_abort();
+		memset(&appended, 0, sizeof(appended));
+		appended.error = MBOX_OP_ERR_BUSY;
+		rtype = IMSG_MBOX_APPENDED;
+		data = &appended;
+		len = sizeof(appended);
+		break;
+	default:
+		log_warnx("session %u: no busy reply for imsg %u, can't "
+		    "happen; only deferrable types are deferred", session_id,
+		    type);
+		break;
+	}
+	if (data != NULL && imsg_compose(&iev->ibuf, rtype, 0, 0, -1, data,
+	    len) == -1)
+		log_warn("session %u: imsg_compose busy reply %u", session_id,
+		    rtype);
+	/* composed outside store_dispatch(), so arm EV_WRITE here */
+	imsgev_rearm_read(iev);
+}
+
+/* The deferred command has run out of time. */
+static void
+deferred_give_up(void)
+{
+	log_debug("session %u: gave up waiting for the index lock after "
+	    "%u seconds", session_id, lock_timeout_secs);
+	deferred_answer_busy_for(deferred.type, deferred.iev);
+	deferred.active = 0;
+}
+
+static void
+deferred_arm(void)
+{
+	struct timeval	 tv;
+
+	tv.tv_sec = deferred.wait_ms / 1000;
+	tv.tv_usec = (deferred.wait_ms % 1000) * 1000;
+	evtimer_set(&deferred.ev, deferred_retry, NULL);
+	if (evtimer_add(&deferred.ev, &tv) == -1) {
+		log_warnx("session %u: no timer for the index lock retry; "
+		    "answering busy now rather than waiting for ever",
+		    session_id);
+		deferred_give_up();
+		return;
+	}
+	if (deferred.wait_ms < LOCK_RETRY_CAP_MS)
+		deferred.wait_ms *= 2;
+}
+
+/* Runs the saved command again; gives up once the deadline has passed. */
+static void
+deferred_retry(int fd, short event, void *arg)
+{
+	int	again;
+
+	(void)fd;
+	(void)event;
+	(void)arg;
+
+	switch (deferred.type) {
+	case IMSG_MBOX_STORE:
+		again = handle_mbox_store(&deferred.req.store,
+		    deferred.elts.ranges, deferred.nelts, deferred.iev);
+		break;
+	case IMSG_MBOX_EXPUNGE:
+		again = handle_mbox_expunge(&deferred.req.expunge,
+		    deferred.nelts > 0 ? deferred.elts.ranges : NULL,
+		    deferred.nelts, deferred.iev);
+		break;
+	case IMSG_MBOX_FETCH:
+		again = handle_mbox_fetch(&deferred.req.fetch,
+		    deferred.elts.ranges, deferred.nelts, deferred.iev);
+		break;
+	case IMSG_MBOX_SEARCH:
+		again = handle_mbox_search(&deferred.req.search,
+		    deferred.nelts > 0 ? deferred.elts.nodes : NULL,
+		    deferred.nelts, deferred.iev);
+		break;
+	case IMSG_MBOX_SELECT:
+		again = handle_mbox_select(&deferred.req.select,
+		    deferred.nelts > 0 ? deferred.elts.ranges : NULL,
+		    deferred.nelts, deferred.iev);
+		break;
+	case IMSG_MBOX_STATUS:
+		again = handle_mbox_status(&deferred.req.status,
+		    deferred.iev);
+		break;
+	case IMSG_MBOX_COPY:
+		again = handle_mbox_copy(&deferred.req.copy,
+		    deferred.elts.ranges, deferred.nelts, deferred.iev);
+		break;
+	case IMSG_MBOX_MOVE:
+		again = handle_mbox_move(&deferred.req.copy,
+		    deferred.elts.ranges, deferred.nelts, deferred.iev);
+		break;
+	case IMSG_MBOX_APPEND_END:
+		/* its state is the in-flight APPEND, not a saved request */
+		again = handle_mbox_append_end(deferred.iev);
+		break;
+	default:
+		/*
+		 * Nothing has answered the listener, so give up rather than
+		 * drop the command and leave the session waiting for ever.
+		 */
+		log_warnx("session %u: cannot re-run imsg %u, can't happen",
+		    session_id, deferred.type);
+		deferred_give_up();
+		return;
+	}
+	if (!again) {
+		deferred.active = 0;
+		imsgev_rearm_read(deferred.iev);
+		return;
+	}
+	if (deferred_expired())
+		deferred_give_up();
+	else
+		deferred_arm();
+}
+
+/*
+ * Takes over a command whose handler could not lock: copies the request
+ * and its trailing array, sets the deadline on the first deferral only,
+ * and starts retrying. nelts was bounded by maxelts on receipt.
+ */
+static void
+defer_command(uint32_t type, const void *req, size_t reqlen,
+    const void *elts, uint32_t nelts, uint32_t maxelts, size_t eltlen,
+    struct imsgev *iev)
+{
+	struct timespec	 now;
+
+	if (deferred.active) {
+		/*
+		 * The listener holds a session's next command while one is
+		 * in flight, so this cannot arrive in the ordinary way; do
+		 * not overwrite the command already waiting.
+		 */
+		log_warnx("session %u: a second command to defer while one "
+		    "waits, refusing the new one", session_id);
+		deferred_answer_busy_for(type, iev);
+		return;
+	}
+	if (reqlen > sizeof(deferred.req) || nelts > maxelts ||
+	    (size_t)nelts * eltlen > sizeof(deferred.elts)) {
+		/* the handler answered nothing, so something must */
+		log_warnx("session %u: imsg %u too large to defer, can't "
+		    "happen, it is checked on receipt", session_id, type);
+		deferred_answer_busy_for(type, iev);
+		return;
+	}
+	if (clock_gettime(CLOCK_MONOTONIC, &now) == -1) {
+		log_warn("session %u: clock_gettime", session_id);
+		now.tv_sec = 0;
+		now.tv_nsec = 0;
+	}
+	deferred.active = 1;
+	deferred.type = type;
+	deferred.iev = iev;
+	if (reqlen > 0)
+		memcpy(&deferred.req, req, reqlen);
+	if (nelts > 0)
+		memcpy(&deferred.elts, elts, (size_t)nelts * eltlen);
+	deferred.nelts = nelts;
+	deferred.give_up_at = now;
+	deferred.give_up_at.tv_sec += lock_timeout_secs;
+	deferred.wait_ms = LOCK_RETRY_FIRST_MS;
+
+	log_debug("session %u: index lock held elsewhere, waiting up to "
+	    "%u seconds", session_id, lock_timeout_secs);
+	deferred_arm();
+}
+
+/*
+ * The store's own check: mailbox_selected verifies independently, in this
+ * process's own state, that MBOX_SELECT succeeded before
+ * FETCH/STORE/EXPUNGE/SEARCH/COPY/MOVE/IDLE_REFRESH, rather than
+ * trusting listener.c's gating -- a compromised listener could
+ * send these out of order.
+ */
+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);
+	    "refusing", session_id, what);
 	return (0);
 }
 
@@ -302,19 +614,23 @@ store_dispatch(int fd, short event, void *arg)
 	struct imsg	 imsg;
 	ssize_t		 n;
 
-	/* every IMSG_MBOX_*_RESULT queued via imsg_compose() needs an actual imsgbuf_write() once writable */
+	/* queued IMSG_MBOX_*_RESULT needs an imsgbuf_write() once writable */
 	if (event & EV_WRITE) {
 		if (imsgbuf_write(&iev->ibuf) == -1)
 			fatal("imsgbuf_write");
+		/* a FETCH paused for its queue to drain carries on here */
+		fetch_walk_resume(iev);
 	}
 
 	if (event & EV_READ) {
 		if ((n = imsgbuf_read(&iev->ibuf)) == -1)
 			fatal("imsgbuf_read");
 		if (n == 0) {
-			/* listener's end closed, treat like IMSG_STORE_SHUTDOWN: nothing left to serve */
+			/*
+			 * listener's end closed; treat like
+			 * IMSG_STORE_SHUTDOWN: nothing to serve
+			 */
 			store_shutdown();
-			/* NOTREACHED */
 		}
 	}
 
@@ -328,14 +644,18 @@ store_dispatch(int fd, short event, void *arg)
 		case IMSG_STORE_SHUTDOWN:
 			imsg_free(&imsg);
 			store_shutdown();
-			/* NOTREACHED */
 			break;
 		case IMSG_MBOX_SELECT: {
 			struct imsg_mbox_select		 req;
 			struct seq_range		*ranges;
 			int				 ok;
 
-			/* Same header-plus-variable-body shape as STORE/FETCH/SEARCH, but nranges may legitimately be 0 here: plain SELECT/EXAMINE or QRESYNC without known-uids carry no trailing sequence-set. */
+			/*
+			 * Same header-plus-variable-body shape as
+			 * STORE/FETCH/SEARCH, but nranges may legitimately be 0
+			 * here: plain SELECT/EXAMINE or QRESYNC without
+			 * known-uids carry no trailing sequence-set.
+			 */
 			if (imsg_get_buf(&imsg, &req, sizeof(req)) == -1) {
 				log_warnx("bad IMSG_MBOX_SELECT (header)");
 				break;
@@ -345,12 +665,24 @@ store_dispatch(int fd, short event, void *arg)
 			    sizeof(struct seq_range), &ok);
 			if (!ok)
 				break;
-			/* RFC 9051 SS6.3.2: a failed SELECT leaves no mailbox selected, matching listener's own SESSION_AUTHENTICATED fallback -- clear the SS6.2 gate before dispatching so a failed re-SELECT doesn't leave this process still answering "selected". */
+			/*
+			 * RFC 9051 SS6.3.2: a failed SELECT leaves no mailbox
+			 * selected, matching listener's own
+			 * SESSION_AUTHENTICATED fallback -- clear the SS6.2
+			 * gate before dispatching so a failed re-SELECT doesn't
+			 * leave this process still answering "selected".
+			 */
 			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);
+			/*
+			 * composes own reply like handle_mbox_*(); EV_WRITE via
+			 * imsgev_on_compose
+			 */
+			if (handle_mbox_select(&req, ranges,
+			    req.qresync_nranges, iev) == 1)
+				defer_command(IMSG_MBOX_SELECT, &req,
+				    sizeof(req), ranges, req.qresync_nranges,
+				    SEQSET_MAX_RANGES, sizeof(*ranges), iev);
 			free(ranges);
 			break;
 		}
@@ -362,7 +694,10 @@ store_dispatch(int fd, short event, void *arg)
 			if (!require_mailbox_selected("IMSG_MBOX_FETCH"))
 				break;
 
-			/* same header-plus-variable-body shape as SEARCH, trailing struct seq_range[] not raw bytes */
+			/*
+			 * same header+variable-body as SEARCH, trailing
+			 * seq_range[] not raw bytes
+			 */
 			if (imsg_get_buf(&imsg, &req, sizeof(req)) == -1) {
 				log_warnx("bad IMSG_MBOX_FETCH (header)");
 				break;
@@ -384,7 +719,11 @@ store_dispatch(int fd, short event, void *arg)
 			    sizeof(struct seq_range), &ok);
 			if (!ok)
 				break;
-			handle_mbox_fetch(&req, ranges, req.nranges, iev);
+			if (handle_mbox_fetch(&req, ranges, req.nranges,
+			    iev) == 1)
+				defer_command(IMSG_MBOX_FETCH, &req,
+				    sizeof(req), ranges, req.nranges,
+				    SEQSET_MAX_RANGES, sizeof(*ranges), iev);
 			free(ranges);
 			break;
 		}
@@ -396,7 +735,10 @@ store_dispatch(int fd, short event, void *arg)
 			if (!require_mailbox_selected("IMSG_MBOX_STORE"))
 				break;
 
-			/* same header-plus-variable-body shape as SEARCH/FETCH, trailing struct seq_range[] not raw bytes */
+			/*
+			 * same header+variable-body as SEARCH/FETCH, trailing
+			 * seq_range[] not raw
+			 */
 			if (imsg_get_buf(&imsg, &req, sizeof(req)) == -1) {
 				log_warnx("bad IMSG_MBOX_STORE (header)");
 				break;
@@ -414,7 +756,11 @@ store_dispatch(int fd, short event, void *arg)
 			    sizeof(struct seq_range), &ok);
 			if (!ok)
 				break;
-			handle_mbox_store(&req, ranges, req.nranges, iev);
+			if (handle_mbox_store(&req, ranges, req.nranges,
+			    iev) == 1)
+				defer_command(IMSG_MBOX_STORE, &req,
+				    sizeof(req), ranges, req.nranges,
+				    SEQSET_MAX_RANGES, sizeof(*ranges), iev);
 			free(ranges);
 			break;
 		}
@@ -423,7 +769,12 @@ store_dispatch(int fd, short event, void *arg)
 			struct seq_range		*ranges;
 			int				 ok;
 
-			/* Same header-plus-variable-body shape as STORE/FETCH/SEARCH, but nranges may legitimately be 0: plain EXPUNGE and CLOSE (silent=1) carry no sequence-set, only UID EXPUNGE does. */
+			/*
+			 * Same header-plus-variable-body shape as
+			 * STORE/FETCH/SEARCH, but nranges may legitimately be
+			 * 0: plain EXPUNGE and CLOSE (silent=1) carry no
+			 * sequence-set, only UID EXPUNGE does.
+			 */
 			if (!require_mailbox_selected("IMSG_MBOX_EXPUNGE"))
 				break;
 
@@ -436,37 +787,61 @@ store_dispatch(int fd, short event, void *arg)
 			    SEQSET_MAX_RANGES, sizeof(struct seq_range), &ok);
 			if (!ok)
 				break;
-			handle_mbox_expunge(&req, ranges, req.nranges, iev);
+			if (handle_mbox_expunge(&req, ranges, req.nranges,
+			    iev) == 1)
+				defer_command(IMSG_MBOX_EXPUNGE, &req,
+				    sizeof(req), ranges, req.nranges,
+				    SEQSET_MAX_RANGES, sizeof(*ranges), iev);
 			free(ranges);
 			break;
 		}
-		case IMSG_MBOX_IDLE_REFRESH:
-			/* no request payload, see imapd.h's imsg_mbox_idle_uid comment */
+		case IMSG_MBOX_IDLE_REFRESH: {
+			struct imsg_mbox_idle_refresh	 req;
+
+			if (imsg_get_data(&imsg, &req, sizeof(req)) == -1) {
+				log_warnx("bad IMSG_MBOX_IDLE_REFRESH");
+				break;
+			}
 			if (!require_mailbox_selected("IMSG_MBOX_IDLE_REFRESH"))
 				break;
-			handle_mbox_idle_refresh(iev);
+			handle_mbox_idle_refresh(&req, iev);
 			break;
+		}
 		case IMSG_MBOX_APPEND: {
 			struct imsg_mbox_append	 req;
-			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)");
+			/*
+			 * No reply here even when refused: the literal follows
+			 * regardless; handle_mbox_append_end() answers.
+			 */
+			if (imsg_get_data(&imsg, &req, sizeof(req)) == -1) {
+				log_warnx("bad IMSG_MBOX_APPEND");
 				break;
 			}
 			if (!imsg_field_valid(req.keywords,
 			    sizeof(req.keywords), "IMSG_MBOX_APPEND.keywords"))
 				break;
-			body = recv_trailing_array(&imsg, "IMSG_MBOX_APPEND",
-			    req.msglen, APPEND_LITERAL_MAX, 1, &ok);
-			if (!ok)
+			handle_mbox_append_begin(&req);
+			break;
+		}
+		case IMSG_MBOX_APPEND_DATA: {
+			char		buf[MAX_IMSGSIZE];
+			size_t		len;
+
+			len = imsg_get_len(&imsg);
+			if (len == 0 || len > sizeof(buf) ||
+			    imsg_get_buf(&imsg, buf, len) == -1) {
+				log_warnx("bad IMSG_MBOX_APPEND_DATA");
 				break;
-			handle_mbox_append(&req, body, req.msglen, iev);
-			free(body);
+			}
+			handle_mbox_append_data(buf, len);
 			break;
 		}
+		case IMSG_MBOX_APPEND_END:
+			if (handle_mbox_append_end(iev) == 1)
+				defer_command(IMSG_MBOX_APPEND_END, NULL, 0,
+				    NULL, 0, 0, 0, iev);
+			break;
 		case IMSG_MBOX_SEARCH: {
 			struct imsg_mbox_search	 req;
 			struct search_node	*nodes;
@@ -475,7 +850,10 @@ store_dispatch(int fd, short event, void *arg)
 			if (!require_mailbox_selected("IMSG_MBOX_SEARCH"))
 				break;
 
-			/* same header-plus-variable-body shape as APPEND, trailing struct search_node[] not raw bytes */
+			/*
+			 * same header+variable-body as APPEND, trailing
+			 * search_node[] not raw
+			 */
 			if (imsg_get_buf(&imsg, &req, sizeof(req)) == -1) {
 				log_warnx("bad IMSG_MBOX_SEARCH (header)");
 				break;
@@ -490,7 +868,12 @@ store_dispatch(int fd, short event, void *arg)
 				free(nodes);
 				break;
 			}
-			handle_mbox_search(&req, nodes, req.nnodes, iev);
+			if (handle_mbox_search(&req, nodes, req.nnodes,
+			    iev) == 1)
+				defer_command(IMSG_MBOX_SEARCH, &req,
+				    sizeof(req), nodes, req.nnodes,
+				    SEARCH_PROGRAM_MAX_NODES, sizeof(*nodes),
+				    iev);
 			free(nodes);
 			break;
 		}
@@ -501,7 +884,9 @@ store_dispatch(int fd, short event, void *arg)
 				log_warnx("bad IMSG_MBOX_STATUS");
 				break;
 			}
-			handle_mbox_status(&req, iev);
+			if (handle_mbox_status(&req, iev) == 1)
+				defer_command(IMSG_MBOX_STATUS, &req,
+				    sizeof(req), NULL, 0, 0, 0, iev);
 			break;
 		}
 		case IMSG_MBOX_COPY: {
@@ -512,7 +897,10 @@ store_dispatch(int fd, short event, void *arg)
 			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 */
+			/*
+			 * same header+variable-body as STORE/FETCH/SEARCH,
+			 * trailing seq_range[]
+			 */
 			if (imsg_get_buf(&imsg, &req, sizeof(req)) == -1) {
 				log_warnx("bad IMSG_MBOX_COPY (header)");
 				break;
@@ -527,7 +915,11 @@ store_dispatch(int fd, short event, void *arg)
 			    sizeof(struct seq_range), &ok);
 			if (!ok)
 				break;
-			handle_mbox_copy(&req, ranges, req.nranges, iev);
+			if (handle_mbox_copy(&req, ranges, req.nranges,
+			    iev) == 1)
+				defer_command(IMSG_MBOX_COPY, &req,
+				    sizeof(req), ranges, req.nranges,
+				    SEQSET_MAX_RANGES, sizeof(*ranges), iev);
 			free(ranges);
 			break;
 		}
@@ -554,13 +946,24 @@ store_dispatch(int fd, short event, void *arg)
 			    sizeof(struct seq_range), &ok);
 			if (!ok)
 				break;
-			handle_mbox_move(&req, ranges, req.nranges, iev);
+			if (handle_mbox_move(&req, ranges, req.nranges,
+			    iev) == 1)
+				defer_command(IMSG_MBOX_MOVE, &req,
+				    sizeof(req), ranges, req.nranges,
+				    SEQSET_MAX_RANGES, sizeof(*ranges), iev);
 			free(ranges);
 			break;
 		}
-		case IMSG_MBOX_LIST:
-			handle_mbox_list(iev);
+		case IMSG_MBOX_LIST: {
+			struct imsg_mbox_list	 req;
+
+			if (imsg_get_data(&imsg, &req, sizeof(req)) == -1) {
+				log_warnx("bad IMSG_MBOX_LIST");
+				break;
+			}
+			handle_mbox_list(&req, iev);
 			break;
+		}
 		case IMSG_MBOX_CREATE: {
 			struct imsg_mbox_create	 req;
 
@@ -591,21 +994,55 @@ store_dispatch(int fd, short event, void *arg)
 			handle_mbox_rename(&req, iev);
 			break;
 		}
+		case IMSG_MBOX_SUBSCRIBE: {
+			struct imsg_mbox_subscribe	 req;
+
+			if (imsg_get_data(&imsg, &req, sizeof(req)) == -1) {
+				log_warnx("bad IMSG_MBOX_SUBSCRIBE");
+				break;
+			}
+			handle_mbox_subscribe(&req, iev);
+			break;
+		}
+		case IMSG_MBOX_UNSUBSCRIBE: {
+			struct imsg_mbox_subscribe	 req;
+
+			if (imsg_get_data(&imsg, &req, sizeof(req)) == -1) {
+				log_warnx("bad IMSG_MBOX_UNSUBSCRIBE");
+				break;
+			}
+			handle_mbox_unsubscribe(&req, iev);
+			break;
+		}
 		default:
 			log_debug("store_dispatch: unhandled %d (session %u)",
 			    imsg_get_type(&imsg), session_id);
 			break;
 		}
 		imsg_free(&imsg);
+
+		/*
+		 * One command's view of cur/ does not outlive the command;
+		 * see mime.c. The next request reads the directory again
+		 * rather than trusting what this one saw. A paused FETCH
+		 * keeps its snapshot: its command has not finished, and
+		 * rebuilding it per batch would undo what it is for.
+		 */
+		if (!fetch_walk_paused())
+			cur_snapshot_discard();
 	}
 	imsgev_rearm_read(iev);
 	(void)fd;
 }
 
-/* IMSG_STORE_SHUTDOWN arrives directly from listener, no round-trip through parent */
+/* IMSG_STORE_SHUTDOWN arrives directly from listener; no round-trip parent */
 __dead void
 store_shutdown(void)
 {
 	log_debug("session %u: store shutting down", session_id);
+	if (deferred.active)
+		evtimer_del(&deferred.ev);
+	fetch_walk_abort();
+	append_abort();
 	exit(0);
 }
blob - 3d0a8220eedf3e17e5a205269608622ec3aefeab
blob + 9631a04f8feb7ca94034ebea550d884825269adc
--- src/store_cmd.c
+++ src/store_cmd.c
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  *
@@ -14,7 +16,10 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* store_cmd.c: STORE/EXPUNGE/CLOSE/UNSELECT/IDLE/COPY/MOVE/UID command-select handlers and their async completion paths. */
+/*
+ * store_cmd.c: STORE/EXPUNGE/CLOSE/UNSELECT/IDLE/COPY/MOVE/UID command-select
+ * handlers and their async completion paths.
+ */
 
 #include <sys/types.h>
 #include <sys/queue.h>
@@ -43,7 +48,8 @@
 int
 cmd_idle(struct session *s, const char *tag, char *args)
 {
-	(void)args;	/* RFC 2177 takes no args; same leniency as every zero-arg command here */
+	/* RFC 2177 takes no args, like other zero-arg commands */
+	(void)args;
 
 	if (strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
 	    sizeof(s->pending_tag)) {
@@ -54,14 +60,15 @@ cmd_idle(struct session *s, const char *tag, char *arg
 	session_write(s, "+ idling\r\n", 10);
 
 	if (s->state == SESSION_SELECTED) {
-		session_request_idle_refresh(s);	/* seeds the baseline */
-		session_idle_poll_arm(s);		/* and keeps it current */
+		session_request_idle_refresh(s, 1);	/* seeds the baseline */
+		/* and keeps it current */
+		session_idle_poll_arm(s);
 	}
 
 	return (1);
 }
 
-/* RFC 9051 SS6.4.1: CLOSE removes \Deleted with no untagged EXPUNGE, via session_request_expunge(silent=1). */
+/* RFC 9051 SS6.4.1: CLOSE removes \Deleted, no untagged EXPUNGE (silent=1). */
 int
 cmd_close(struct session *s, const char *tag, char *args)
 {
@@ -69,19 +76,18 @@ cmd_close(struct session *s, const char *tag, char *ar
 	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. */
+/* RFC 9051 SS6.4.2: like CLOSE, but removes nothing; a local state change. */
 int
 cmd_unselect(struct session *s, const char *tag, char *args)
 {
 	(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);
 }
 
-/* RFC 9051 SS6.4.3: sends the real (non-silent) IMSG_MBOX_EXPUNGE via session_request_expunge(). */
+/* SS6.4.3: sends real IMSG_MBOX_EXPUNGE via session_request_expunge(). */
 int
 cmd_expunge(struct session *s, const char *tag, char *args)
 {
@@ -89,7 +95,7 @@ cmd_expunge(struct session *s, const char *tag, char *
 	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. */
+/* Parses store-att-flags (paren or bare) into a sysflags bitmap + keywords. */
 int
 parse_store_flags(char *flagspec, uint32_t *sysflags_out, char *keywords_out,
     size_t keywords_out_size, const char **errmsg)
@@ -114,7 +120,10 @@ parse_store_flags(char *flagspec, uint32_t *sysflags_o
 		p[len - 1] = '\0';
 		p++;
 	}
-	/* Empty flag-list is valid ABNF; only SET (bare FLAGS/FLAGS.SILENT) may use it, enforced by cmd_store_cmd(). */
+	/*
+	 * Empty flag-list is valid ABNF; SET (bare) may use it
+	 * (cmd_store_cmd()).
+	 */
 	if (*p == '\0')
 		return (0);
 
@@ -151,7 +160,10 @@ parse_store_flags(char *flagspec, uint32_t *sysflags_o
 			return (-2);
 		}
 
-		/* No RFC-mandated flag-list length cap; checked explicitly instead of letting strlcat(3) truncate mid-keyword. */
+		/*
+		 * No RFC flag-list cap; checked explicitly, not via strlcat(3)
+		 * truncation.
+		 */
 		if (!first) {
 			if (strlcat(keywords_out, ",", keywords_out_size) >=
 			    keywords_out_size) {
@@ -171,7 +183,10 @@ parse_store_flags(char *flagspec, uint32_t *sysflags_o
 	return (0);
 }
 
-/* RFC 7162 SS3.1.3: only UNCHANGEDSINCE implemented; value may be 0, so has_unchangedsince flags it, not != 0. */
+/*
+ * RFC 7162 SS3.1.3: only UNCHANGEDSINCE implemented; value may be 0, so
+ * has_unchangedsince flags it, not != 0.
+ */
 int
 parse_store_modifiers(char *modspec, struct imsg_mbox_store *req,
     const char **errmsg)
@@ -199,7 +214,13 @@ parse_store_modifiers(char *modspec, struct imsg_mbox_
 				    "mod-sequence value";
 				return (-1);
 			}
-			/* RFC 7162 SS7: mod-sequence-valzer allows 0 here, but strtoull(3) accepts a leading sign, so "UNCHANGEDSINCE -1" became ULLONG_MAX and silently turned a conditional STORE unconditional -- must be rejected per RFC 7162 SS3.1.3. */
+			/*
+			 * RFC 7162 SS7: mod-sequence-valzer allows 0 here, but
+			 * strtoull(3) accepts a leading sign, so
+			 * "UNCHANGEDSINCE -1" became ULLONG_MAX and silently
+			 * turned a conditional STORE unconditional -- must be
+			 * rejected per RFC 7162 SS3.1.3.
+			 */
 			if (*valtok < '0' || *valtok > '9') {
 				*errmsg = "invalid UNCHANGEDSINCE mod-sequence";
 				return (-1);
@@ -221,14 +242,17 @@ parse_store_modifiers(char *modspec, struct imsg_mbox_
 	return (0);
 }
 
-/* RFC 9051 SS6.4.6: store-modifiers *lead* here, unlike FETCH's trailing fetch-modifiers. */
+/* SS6.4.6: store-modifiers *lead* here, unlike FETCH's trailing ones. */
 int
 cmd_store_cmd(struct session *s, const char *tag, char *args)
 {
 	return store_do(s, tag, args, 0);
 }
 
-/* Shared body for cmd_store_cmd()/cmd_uid()'s STORE branch; s->cmd_by_uid set so the STORE echo includes UID (SS6.4.9). */
+/*
+ * Shared body for cmd_store_cmd()/cmd_uid()'s STORE branch; s->cmd_by_uid set
+ * so the STORE echo includes UID (SS6.4.9).
+ */
 int
 store_do(struct session *s, const char *tag, char *args, int by_uid)
 {
@@ -354,7 +378,10 @@ store_do(struct session *s, const char *tag, char *arg
 	}
 
 	if (s->mbox_readonly) {
-		/* RFC 9051 SS6.3.3: no changes on EXAMINE'd mailbox (RFC 5530 CANNOT), same check as session_request_expunge(). */
+		/*
+		 * RFC 9051 SS6.3.3: no changes on EXAMINE'd mailbox (RFC 5530
+		 * CANNOT), same check as session_request_expunge().
+		 */
 		session_reply(s, tag, "NO", "[CANNOT] Mailbox is read-only "
 		    "(selected via EXAMINE)");
 		return (1);
@@ -368,7 +395,10 @@ store_do(struct session *s, const char *tag, char *arg
 		return (1);
 	}
 
-	/* req already memset(3)'d and has_unchangedsince set earlier, not re-zeroed here, to avoid wiping it out. */
+	/*
+	 * req already memset(3)'d; has_unchangedsince set earlier, kept as is
+	 * here.
+	 */
 	req.nranges = nranges;
 	req.mode = mode;
 	req.silent = silent;
@@ -386,7 +416,11 @@ store_do(struct session *s, const char *tag, char *arg
 	if (req.has_unchangedsince)
 		session_condstore_enable(s);
 
-	/* defensive cleanup of a previous STORE's leftovers; shouldn't actually find anything here, same reasoning (and same four fields plus flag) as search_dispatch()'s own pre-command reset */
+	/*
+	 * defensive cleanup of a previous STORE's leftovers; shouldn't actually
+	 * find anything here, same reasoning (and same four fields plus flag)
+	 * as search_dispatch()'s own pre-command reset
+	 */
 	free(s->store_modified);
 	s->store_modified = NULL;
 	s->store_modified_n = 0;
@@ -406,7 +440,10 @@ store_do(struct session *s, const char *tag, char *arg
 	return (1);
 }
 
-/* RFC 9051 SS6.4.7/SS6.4.8: invalid mailbox name is BAD; nonexistent is TRYCREATE via res->error == MBOX_OP_ERR_NO_SUCH_MAILBOX. */
+/*
+ * RFC 9051 SS6.4.7/SS6.4.8: invalid mailbox name is BAD; nonexistent is
+ * TRYCREATE via res->error == MBOX_OP_ERR_NO_SUCH_MAILBOX.
+ */
 int
 copy_move_dispatch(struct session *s, const char *tag, char *args,
     int by_uid, int is_move)
@@ -462,13 +499,18 @@ copy_move_dispatch(struct session *s, const char *tag,
 
 	if (listener_reject_bad_utf8(s, tag, mailbox))
 		return (1);
-	if (!mailbox_name_is_inbox(mailbox) && !listener_mailbox_name_valid(mailbox)) {
-		session_reply(s, tag, "BAD", "invalid mailbox name");
+	/* a name the server refuses is RFC 5530 SS3 NO [CANNOT], not BAD */
+	if (!mailbox_name_is_inbox(mailbox) &&
+	    !listener_mailbox_name_valid(mailbox)) {
+		session_reply(s, tag, "NO", "[CANNOT] invalid mailbox name");
 		return (1);
 	}
 
 	if (is_move && s->mbox_readonly) {
-		/* MOVE removes source messages (SS6.4.8), a change SS6.3.3 prohibits when EXAMINE'd; COPY is unaffected. */
+		/*
+		 * MOVE removes msgs (SS6.4.8), forbidden by SS6.3.3 on
+		 * EXAMINE'd; COPY OK.
+		 */
 		session_reply(s, tag, "NO", "[CANNOT] Mailbox is read-only "
 		    "(selected via EXAMINE)");
 		return (1);
@@ -521,7 +563,7 @@ cmd_move(struct session *s, const char *tag, char *arg
 	return copy_move_dispatch(s, tag, args, 0, 1);
 }
 
-/* RFC 9051 SS6.4.9 UID; dispatches to the same *_dispatch() bodies as base commands with by_uid=1. */
+/* SS6.4.9 UID; dispatches to the same *_dispatch() bodies, by_uid=1. */
 int
 cmd_uid(struct session *s, const char *tag, char *args)
 {
@@ -561,7 +603,7 @@ cmd_uid(struct session *s, const char *tag, char *args
 	return (1);
 }
 
-/* RFC 9051 SS7.5.1 untagged EXPUNGE; RFC 7162 SS3.2.10.2 sends VANISHED <uid> instead once s->qresync_enabled. */
+/* SS7.5.1 untagged EXPUNGE; RFC7162 SS3.2.10.2 sends VANISHED <uid> instead. */
 void
 session_send_expunge_response(struct session *s,
     const struct imsg_mbox_expunged *exp)
@@ -575,7 +617,7 @@ session_send_expunge_response(struct session *s,
 	session_untagged(s, buf);
 }
 
-/* RFC 7162 SS3.2.6: one VANISHED (EARLIER) range, written immediately, unlike QRESYNC SELECT's buffered resync. */
+/* RFC 7162 SS3.2.6: VANISHED (EARLIER) range written now; SELECT buffers */
 void
 session_handle_fetch_vanished(struct session *s,
     const struct imsg_mbox_select_vanished *v)
@@ -590,7 +632,7 @@ session_handle_fetch_vanished(struct session *s,
 	session_untagged(s, buf);
 }
 
-/* Shared by cmd_expunge()/cmd_close()/UID EXPUNGE; SS6.4.9: '*' number is always a seqno, even for UID commands. */
+/* Shared by expunge/close/UID EXPUNGE: '*' is always a seqno, even for UID. */
 int
 session_request_expunge(struct session *s, const char *tag, int is_close,
     int by_uid, const struct seq_range *ranges, uint32_t nranges)
@@ -601,13 +643,18 @@ session_request_expunge(struct session *s, const char 
 
 	if (s->mbox_readonly) {
 		if (is_close) {
-			/* RFC 9051 SS6.4.1: EXAMINE'd, skip the round trip, just deselect and reply OK, no error. */
+			/*
+			 * SS6.4.1: EXAMINE'd, skip round trip; just deselect
+			 * and reply OK.
+			 */
 			s->state = SESSION_AUTHENTICATED;
-			session_reset_idle_baseline(s);
 			session_reply(s, tag, "OK", "CLOSE completed");
 			return (1);
 		}
-		/* Unlike CLOSE, plain/UID EXPUNGE has no read-only exception, SS6.3.3 applies directly (RFC 5530 CANNOT). */
+		/*
+		 * Unlike CLOSE, EXPUNGE has no read-only exception; SS6.3.3
+		 * applies here.
+		 */
 		session_reply(s, tag, "NO", "[CANNOT] Mailbox is read-only "
 		    "(selected via EXAMINE)");
 		return (1);
@@ -646,7 +693,10 @@ session_request_expunge(struct session *s, const char 
 	return (1);
 }
 
-/* cmd_uid()'s EXPUNGE branch: requires a sequence set (plain EXPUNGE takes none); never reached with is_close set. */
+/*
+ * cmd_uid()'s EXPUNGE branch: requires a sequence set (plain EXPUNGE takes
+ * none); never reached with is_close set.
+ */
 int
 uid_expunge_dispatch(struct session *s, const char *tag, const char *args)
 {
@@ -667,7 +717,14 @@ uid_expunge_dispatch(struct session *s, const char *ta
 	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; a failed grow sets s->store_modified_alloc_failed and stops collecting, since SS3.1.3's set must list every message that failed UNCHANGEDSINCE and session_handle_mbox_result() refuses to send a short one (the early return below then keeps one failure from logging once per remaining message, as session_handle_mbox_search_match() does). */
+/*
+ * Appends one MODIFIED entry (RFC 7162 SS3.1.3) to s->store_modified, growing
+ * by doubling from 16; a failed grow sets s->store_modified_alloc_failed and
+ * stops collecting, since SS3.1.3's set must list every message that failed
+ * UNCHANGEDSINCE and session_handle_mbox_result() refuses to send a short one
+ * (the early return below then keeps one failure from logging once per
+ * remaining message, as session_handle_mbox_search_match() does).
+ */
 void
 session_handle_store_modified(struct session *s,
     struct imsg_mbox_store_modified *m)
@@ -691,12 +748,25 @@ session_handle_store_modified(struct session *s,
 		s->store_modified_cap = newcap;
 	}
 
-	/* RFC 7162 SS3.1.3: MODIFIED lists UIDs for UID STORE, seqnos otherwise (see s->cmd_by_uid in struct session). */
+	/*
+	 * SS3.1.3: MODIFIED lists UIDs for UID STORE, else seqnos
+	 * (s->cmd_by_uid).
+	 */
 	s->store_modified[s->store_modified_n++] =
 	    s->cmd_by_uid ? m->uid : m->seqno;
 }
 
-/* Appends one "lo" or "lo:hi" token, with a separating comma unless it is the first, and refuses rather than truncates when it would not fit. format_seq_list() and format_range_list() differ only in where lo and hi come from; this is everything else they used to spell out twice, including the budget arithmetic that decides whether a list is complete -- the thing a caller must get right, and the reason both are fuzzed. Returns the new written length, or (size_t)-1 if the token did not fit, leaving buf as it was. *truncated is set only for a budget refusal, not for a snprintf(3) failure, which is what both callers did before. */
+/*
+ * Appends one "lo" or "lo:hi" token, with a separating comma unless it is the
+ * first, and refuses rather than truncates when it would not fit.
+ * format_seq_list() and format_range_list() differ only in where lo and hi come
+ * from; this is everything else they used to spell out twice, including the
+ * budget arithmetic that decides whether a list is complete -- the thing a
+ * caller must get right, and the reason both are fuzzed. Returns the new
+ * written length, or (size_t)-1 if the token did not fit, leaving buf as it
+ * was. *truncated is set only for a budget refusal, not for a snprintf(3)
+ * failure, which is what both callers did before.
+ */
 static size_t
 append_range_token(char *buf, size_t bufsize, size_t written, int *first,
     uint32_t lo, uint32_t hi, int *truncated)
@@ -725,7 +795,7 @@ append_range_token(char *buf, size_t bufsize, size_t w
 	return (written);
 }
 
-/* Formats nums as comma-separated bare/lo:hi ranges, RFC 9051 SS7.3.4 ESEARCH style; nums must be pre-sorted. */
+/* Formats nums as comma-sep bare/lo:hi ranges (SS7.3.4 ESEARCH); pre-sorted. */
 size_t
 format_seq_list(char *buf, size_t bufsize, const uint32_t *nums, uint32_t n,
     int *truncated)
@@ -743,7 +813,10 @@ format_seq_list(char *buf, size_t bufsize, const uint3
 		uint32_t	j = i + 1;
 		size_t		w;
 
-		/* the compaction, which is this function's own: collapse an ascending run into one lo:hi token */
+		/*
+		 * this function's compaction: collapse an ascending run into a
+		 * lo:hi token
+		 */
 		while (j < n && nums[j] == end + 1) {
 			end = nums[j];
 			j++;
@@ -760,9 +833,10 @@ format_seq_list(char *buf, size_t bufsize, const uint3
 	return (written);
 }
 
-/* RFC 7162 counterpart to format_seq_list() for VANISHED (EARLIER); ranges are pre-compacted by store.c already. */
+/* RFC7162 sibling of format_seq_list() for VANISHED; ranges pre-compacted. */
 size_t
-format_range_list(char *buf, size_t bufsize, const struct vanished_range *ranges,
+format_range_list(char *buf, size_t bufsize,
+    const struct vanished_range *ranges,
     uint32_t n, int *truncated)
 {
 	size_t		written = 0;
@@ -772,7 +846,10 @@ format_range_list(char *buf, size_t bufsize, const str
 	*truncated = 0;
 	buf[0] = '\0';
 
-	/* no compaction here, unlike format_seq_list(): store.c hands these over already compacted */
+	/*
+	 * no compaction here, unlike format_seq_list(): store.c precompacts
+	 * these
+	 */
 	for (i = 0; i < n; i++) {
 		size_t	w;
 
@@ -786,13 +863,21 @@ format_range_list(char *buf, size_t bufsize, const str
 	return (written);
 }
 
-/* Worst-case sizing for COPYUID's two UID sets ("4294967295" plus separator, matching store_ipc.c/search_cmd.c), plus the tag/"OK [COPYUID "/uidvalidity/cmdname/CRLF wrapper. */
+/*
+ * Worst-case sizing for COPYUID's two UID sets ("4294967295" plus separator,
+ * matching store_ipc.c/search_cmd.c), plus the tag/"OK [COPYUID
+ * "/uidvalidity/cmdname/CRLF wrapper.
+ */
 #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. */
+/*
+ * 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)
+session_finish_copy_or_move(struct session *s,
+    const struct imsg_mbox_result *res)
 {
 	const char	*cmdname = s->cmd_is_move ?
 	    (s->cmd_by_uid ? "UID MOVE" : "MOVE") :
@@ -804,18 +889,27 @@ session_finish_copy_or_move(struct session *s, const s
 	if (res->error != MBOX_OP_OK || s->copy_alloc_failed) {
 		char	text[48];
 
-		/* RFC 9051 SS6.4.7/SS6.4.8: destname valid but not found, TRYCREATE, mirroring imsg_mbox_appended's field. */
+		/*
+		 * SS6.4.7/8: destname valid, not found: TRYCREATE (as
+		 * imsg_mbox_appended).
+		 */
 		if (!s->copy_alloc_failed &&
 		    res->error == MBOX_OP_ERR_NO_SUCH_MAILBOX)
 			snprintf(text, sizeof(text), "[TRYCREATE] no such "
 			    "mailbox");
+		else if (!s->copy_alloc_failed &&
+		    res->error == MBOX_OP_ERR_BUSY)
+			snprintf(text, sizeof(text), "%s", IMAP_BUSY_TEXT);
 		else
 			snprintf(text, sizeof(text), "%s failed", cmdname);
 		session_reply(s, s->pending_tag, "NO", text);
 		goto cleanup;
 	}
 
-	/* RFC 7162 SS3.1.2.1: cache post-op HIGHESTMODSEQ (as for STORE/EXPUNGE) for session_condstore_enable()'s later use. */
+	/*
+	 * 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;
 
 
@@ -824,7 +918,13 @@ session_finish_copy_or_move(struct session *s, const s
 		size_t	 listsize, textsize;
 		int	 truncated, n, sent = 0;
 
-		/* Heap-allocated and worst-case sized (like session_finish_search()'s ESEARCH ALL list): fixed buffers here previously truncated independently, misaligning RFC 9051 SS7.1's COPYUID UID mapping and risking overflow-truncated responses. */
+		/*
+		 * Heap-allocated and worst-case sized (like
+		 * session_finish_search()'s ESEARCH ALL list): fixed buffers
+		 * here previously truncated independently, misaligning RFC 9051
+		 * SS7.1's COPYUID UID mapping and risking overflow-truncated
+		 * responses.
+		 */
 		listsize = (size_t)s->copy_n * COPYUID_PER_ENTRY + 1;
 		textsize = 2 * listsize + COPYUID_WRAPPER_MAX;
 
@@ -842,7 +942,10 @@ session_finish_copy_or_move(struct session *s, const s
 				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 its own tagged OK. */
+			/*
+			 * MOVE's COPYUID untagged (OK follows EXPUNGEs); COPY
+			 * rides its own OK.
+			 */
 			if (s->cmd_is_move)
 				n = snprintf(text, textsize,
 				    "* OK [COPYUID %u %s %s]\r\n",
@@ -873,7 +976,10 @@ session_finish_copy_or_move(struct session *s, const s
 
 			session_reply(s, s->pending_tag, "OK", "Done");
 		} else if (!sent) {
-			/* RFC 9051 SS6.4.7 makes COPYUID a SHOULD, so dropping it is a legal degradation; emitting a truncated or half-empty one is not. */
+			/*
+			 * SS6.4.7: COPYUID is a SHOULD; omitting is legal,
+			 * truncating is not.
+			 */
 			char	fallback[64];
 
 			snprintf(fallback, sizeof(fallback), "%s completed",
blob - 390001532828ddd0cdc979a6eb893be1ff110253
blob + 5ae316a2795414fad66a9ceb21cd3dbf5f43c4dc
--- src/store_internal.h
+++ src/store_internal.h
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  *
@@ -36,40 +38,21 @@ 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.
-	 */
+	/* Set by index_load() when it found no index and invented a header: */
+	/* this UIDVALIDITY is issued but not yet written down, so every */
+	/* caller holding an exclusive lock must persist it before returning. */
 	int		  fresh;
-	char		**lines;	/* raw "UID:basename:keywords:MODSEQ"
-					 * lines, no trailing newline, one
-					 * malloc(3) each; see
-					 * index_parse_line() */
+	/* raw "UID:basename:keywords:MODSEQ" lines, no trailing newline, */
+	/* one malloc(3) each; see index_parse_line() */
+	char		**lines;
 	size_t		  nlines;
 	size_t		  cap;
 };
 
-/*
- * One index message line, parsed, so handle_mbox_fetch()/handle_mbox_
- * store()/handle_mbox_expunge() don't each hand-roll their own
- * "UID:basename:keywords:MODSEQ" strchr() chain. Basename/keywords are
- * fixed-size copies, not pointers into the original line, callers may
- * mutate or discard the line string after parsing.
- *
- * A line with only three colon-delimited fields (pre-CONDSTORE) parses
- * successfully with modseq defaulted to 1.
- */
+/* One index line, parsed, so each caller need not hand-roll the strchr() */
+/* chain. basename and keywords are copies, so the line may be mutated or */
+/* discarded afterwards. A three-field pre-CONDSTORE line parses with */
+/* modseq defaulted to 1. */
 struct index_rec {
 	uint32_t	uid;
 	char		basename[512];
@@ -77,12 +60,8 @@ struct index_rec {
 	uint64_t	modseq;
 };
 
-/*
- * One message staged by stage_copy_messages() (mbox_copy.c) for
- * handle_mbox_copy()/handle_mbox_move(). Read fully into memory from
- * the source mailbox, not yet written. Shared here since commit_copy_
- * messages() and move_cross_mailbox() also take it as a parameter type.
- */
+/* One message staged by stage_copy_messages() for COPY/MOVE: read fully */
+/* into memory from the source mailbox, not yet written. */
 struct copy_staged {
 	uint32_t	src_uid;
 	uint32_t	dest_uid;
@@ -93,8 +72,10 @@ struct copy_staged {
 	size_t		bodylen;
 };
 
-#define COPY_STAGE_MSG_MAX	((uint64_t)64 * 1024 * 1024)	/* per message */
-#define COPY_STAGE_TOTAL_MAX	((uint64_t)512 * 1024 * 1024)	/* per operation */
+/* per message */
+#define COPY_STAGE_MSG_MAX	((uint64_t)64 * 1024 * 1024)
+/* per operation */
+#define COPY_STAGE_TOTAL_MAX	((uint64_t)512 * 1024 * 1024)
 
 /* Globals shared across the files this process's source is split into
  * (definitions live in store.c's core).
@@ -102,38 +83,39 @@ struct copy_staged {
 extern uint32_t	 session_id;
 extern uint32_t	 append_counter; /* per-store-child monotonic counter
 				 * feeding the maildir basename uniquer
-				 * (see handle_mbox_append()); shared with
+				 * (see handle_mbox_append_begin()); shared with
 				 * handle_mbox_copy() so a copy's minted
 				 * basename can't collide with an APPEND's */
-extern char	 current_mailbox_dir[MBOX_NAME_MAX]; /* named mailbox (if
-				 * any) this store child's cwd is chdir'd
-				 * 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. */
+/*
+ * The store's own gate: 1 iff this session's most recent SELECT succeeded
+ * and no CLOSE has followed. Needed separately from selected_mailbox,
+ * whose "" cannot distinguish "nothing selected" from "INBOX selected".
+ * store_dispatch() clears it before each SELECT, so a failed one leaves
+ * nothing selected (RFC 9051 SS6.3.2); CLOSE and a DELETE of the selected
+ * mailbox clear it too, since cwd is back at the maildir root by then and
+ * anything still "selected" would silently be INBOX.
+ */
+extern int	 mailbox_selected;
 
+/*
+ * The name of the mailbox this session has SELECTed, "" for INBOX,
+ * written by handle_mbox_select() and followed by a RENAME of that
+ * mailbox. Distinct from where the process happens to be standing:
+ * COPY needs the name to tell a cross-mailbox copy from a same-mailbox
+ * one, and DELETE needs it to know it is deleting the selection.
+ */
+extern char	 selected_mailbox[MBOX_NAME_MAX];
+
+/*
+ * maildir_root_fd names the account's maildir root for the life of this
+ * child; mailbox_dir_fd names the SELECTed mailbox and is replaced by
+ * handle_mbox_select(). Both are opened once the unveil is in place,
+ * and a lookup relative to either is unveiled exactly as a cwd-relative
+ * one is (namei(), sys/kern/vfs_lookup.c).
+ */
+extern int	 maildir_root_fd;
+extern int	 mailbox_dir_fd;
+
 /* Cross-file entry points: forward declarations for store.c (core) +
  * index.c + mime.c + envelope.c + mbox_fetch.c + mbox_search.c +
  * mbox_store.c + mbox_manage.c + mbox_copy.c.
@@ -143,39 +125,56 @@ extern uint32_t	 bodystructure_read_max; /* from IMSG_
 					 * BODYSTRUCTURE_READ_DEFAULT
 					 * comments */
 
+extern uint64_t	 append_max;	/* from IMSG_STORE_INIT, "append max" */
+/* from IMSG_STORE_INIT, "lock timeout"; 0 disables the bound */
+extern uint32_t	 lock_timeout_secs;
+
 void	 store_dispatch(int, short, void *);
 void	 store_shutdown(void);
-void	 handle_mbox_select(struct imsg_mbox_select *,
+/* The int handlers return 0 answered; 1 lock busy, nothing answered */
+int	 handle_mbox_select(struct imsg_mbox_select *,
 		    const struct seq_range *, uint32_t, struct imsgev *);
-void	 handle_mbox_fetch(struct imsg_mbox_fetch *,
+int	 handle_mbox_fetch(struct imsg_mbox_fetch *,
 		    const struct seq_range *, uint32_t, struct imsgev *);
-void	 handle_mbox_store(struct imsg_mbox_store *,
+int	 handle_mbox_store(struct imsg_mbox_store *,
 		    const struct seq_range *, uint32_t, struct imsgev *);
-void	 handle_mbox_expunge(struct imsg_mbox_expunge *,
+int	 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 *,
+void	 handle_mbox_append_begin(const struct imsg_mbox_append *);
+void	 handle_mbox_append_data(const char *, size_t);
+int	 handle_mbox_append_end(struct imsgev *);
+void	 append_abort(void);
+int	 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 *,
+int	 handle_mbox_status(struct imsg_mbox_status *, struct imsgev *);
+int	 handle_mbox_copy(struct imsg_mbox_copy *,
 		    const struct seq_range *, uint32_t, struct imsgev *);
-void	 handle_mbox_move(struct imsg_mbox_copy *,
+int	 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 *);
-void	 handle_mbox_list(struct imsgev *);
+void	 handle_mbox_list(struct imsg_mbox_list *, struct imsgev *);
+void	 handle_mbox_subscribe(struct imsg_mbox_subscribe *,
+	    struct imsgev *);
+void	 handle_mbox_unsubscribe(struct imsg_mbox_subscribe *,
+	    struct imsgev *);
 int	 mailbox_name_valid(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 *);
-int	 read_message_body(const char *, int, size_t, const char *,
+int	 mailbox_open_dir(const char *);
+void	 cur_snapshot_discard(void);
+int	 fetch_walk_paused(void);
+void	 fetch_walk_resume(struct imsgev *);
+void	 fetch_walk_abort(void);
+int	 locate_message_file(int, const char *, off_t *, char *,
+		    size_t);
+int	 open_message_file(int, const char *);
+int	 read_message_header(int, const char *, char **, uint32_t *);
+int	 read_message_body(int, const char *, int, size_t,
+		    const char *,
 		    char **, uint32_t *);
 int	 header_field_name_matches(const char *, size_t, const char *);
-int	 read_message_header_fields(const char *, const char *, int,
+int	 read_message_header_fields(int, const char *, const char *,
+		    int,
 		    char **, uint32_t *);
 void	 build_flags_string(const char *, const char *, char *,
 		    size_t);
@@ -191,7 +190,7 @@ int	 envbuf_append_address_list(char *, size_t, size_t
 		    const char *, size_t);
 int	 append_field_nstring(char *, size_t, size_t *, const char *,
 		    uint32_t, const char *);
-int	 build_envelope(const char *, char **, uint32_t *);
+int	 build_envelope(int, const char *, char **, uint32_t *);
 int	 find_header_body_split(const char *, size_t, size_t *);
 int	 mime_is_tspecial(char);
 int	 mime_read_token_or_qstring(const char *, size_t, size_t *,
@@ -203,89 +202,57 @@ int	 split_multipart(const char *, size_t, const char 
 		    size_t *, size_t *, int *, int);
 int	 build_body_structure(int, int *, const char *, size_t,
 		    const char *, size_t, char *, size_t, size_t *);
-int	 build_bodystructure(const char *, char **, uint32_t *);
+int	 build_bodystructure(int, const char *, char **, uint32_t *);
 int	 parse_section_part(const char *, int *, int);
 int	 find_mime_part(int, const char *, size_t, const char *,
 		    size_t, const int *, int, const char **, size_t *);
 int	 locate_mime_part(const char *, size_t, const char *, size_t,
 		    const int *, int, const char **, size_t *);
-void	 apply_partial_range(const char *, size_t, int, uint32_t,
-		    uint32_t, const char **, size_t *);
-int	 extract_mime_part(const char *, const int *, int, int,
-		    uint32_t, uint32_t, char **, uint32_t *);
+void	 partial_range(int, uint32_t, uint32_t, uint64_t *, uint64_t *);
+int	 extract_mime_part(int, const char *, const int *, int,
+		    uint64_t *, uint64_t *);
+int	 message_body_range(int, const char *, int, uint64_t *,
+		    uint64_t *, int *);
 
 /*
- * The maildir+index format: stock maildir (tmp/new/cur) for message
- * bodies, plus one small line-oriented text index per mailbox holding
- * UIDVALIDITY/UIDNEXT and the UID<->basename(<->keywords) map. The index
- * lives directly in that mailbox's maildir root, a sibling of tmp/new/
- * cur. Name is plain and `ls`-visible on purpose.
- *
- * The four reserved filenames themselves (STORE_INDEX_NAME,
- * STORE_INDEX_TMP_NAME, STORE_INDEX_LOCK_NAME, STORE_UIDVALIDITY_NAME) are
- * defined in mboxname.h, included above: a mailbox may not be NAMED after one,
- * and the listener has to refuse such a name too without including this
- * store-private header. What each file is FOR stays documented here, and each
- * block below names the constant it belongs to.
+ * The maildir+index format: stock maildir (tmp/new/cur) for bodies, plus
+ * one line-oriented text index per mailbox holding UIDVALIDITY/UIDNEXT and
+ * the UID to basename and keywords map, in that mailbox's maildir root.
+ * The reserved filenames live in mboxname.h, since the listener must
+ * refuse them as mailbox names without including this header; what each
+ * file is FOR is documented below, per constant.
  */
 
-/*
- * STORE_INDEX_LOCK_NAME: 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.
- */
+/* STORE_INDEX_LOCK_NAME: the index's lock is taken on this file, not on */
+/* the index. index_save() commits by rename(2), so the index's inode is */
+/* replaced on every save, while flock(2) binds to one inode. This file is */
+/* never replaced, so its inode is stable. It holds no content. */
+/* mailbox_name_valid() refuses it as a mailbox name, and */
+/* remove_maildir_subtree() unlinks it with the mailbox. */
 
 #define STORE_INDEX_LINE_MAX	1024
 
-/*
- * STORE_UIDVALIDITY_NAME: 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.
- */
+/* STORE_UIDVALIDITY_NAME: per-user floor, the highest UIDVALIDITY ever */
+/* issued to this user, as decimal digits. One file at the maildir root. */
+/* RFC 9051 SS2.3.1.1 wants a value that always increases, and a time(NULL) */
+/* seed is not unique at one-second granularity, so a mailbox deleted and */
+/* recreated quickly could reuse one. Unlike the index this file is its own */
+/* lock: it is rewritten in place, so its inode never changes. */
+/* LOCK ORDERING, which a later edit must not break: this lock is an */
+/* INNERMOST LEAF. uidvalidity_next() runs with a mailbox index lock held, */
+/* and must never acquire a mailbox lock itself. */
 
-/*
- * 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.
- */
+/* STORE_SUBSCRIPTIONS_NAME: per-account subscribed-mailbox list, one flat */
+/* name per line, at the maildir root. INBOX is never named in it. */
+/* An ABSENT file means every mailbox is subscribed; only UNSUBSCRIBE makes */
+/* one, and it writes out every other mailbox as it goes. */
+/* Its lock is a separate file for STORE_INDEX_LOCK_NAME's reason, and is */
+/* taken ALONE. Nothing holds another lock while holding it. */
+
+/* A held index lock: the flock(2)ed lock file, plus the index descriptor, */
+/* which is opened only AFTER the lock is held so it cannot name an inode */
+/* a concurrent save has replaced. Both are -1 when nothing is held, so */
+/* declare with INDEX_LOCK_INIT: an all-zero struct would name fd 0. */
 struct index_lock {
 	int	lockfd;
 	int	fd;
@@ -293,15 +260,13 @@ struct index_lock {
 
 #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
- * discarded. Never kept around between imsg messages.
- */
+/* In-memory copy of one mailbox's index for the duration of one mutating */
+/* op: built from disk, mutated, rewritten in full, discarded. Never held */
+/* across imsg messages. */
 int	 index_load(int, struct mbox_index *);
 int	 index_has_basename(struct mbox_index *, const char *);
 int	 index_append(struct mbox_index *, uint32_t, const char *);
-int	 index_save(const struct mbox_index *);
+int	 index_save(int, 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 --
@@ -318,13 +283,10 @@ uint32_t	 seqset_resolve(const struct seq_range *, uin
 int	 seqset_contains(const struct seq_range *, uint32_t, uint32_t);
 uint32_t	 seqset_max_hi(const struct seq_range *, uint32_t);
 
-/*
- * Where one message sits relative to a resolved sequence-set, for the
- * ascending single-pass scans in handle_mbox_fetch(), handle_mbox_store() and
- * stage_copy_messages(). PAST_END means the scan can stop, not merely that
- * this message is unmatched -- it is only sound because those loops walk
- * idx->lines in ascending order.
- */
+/* Where one message sits relative to a resolved sequence-set, for the */
+/* ascending single-pass scans. PAST_END means the scan may stop, not */
+/* merely that this message is unmatched, which is sound only because */
+/* those loops walk idx->lines in ascending order. */
 enum seqset_pos {
 	SEQSET_PAST_END,
 	SEQSET_SKIP,
@@ -333,12 +295,14 @@ enum seqset_pos {
 
 enum seqset_pos	 seqset_position(const struct seq_range *, uint32_t, uint32_t,
 		    int, uint32_t, uint32_t);
-int	 refresh_index(struct mbox_index *, int);
+int	 refresh_index(int, struct mbox_index *, int);
 uint32_t uidvalidity_next(void);
 void	 idle_probe_reset(void);
-int	 index_lock_acquire(struct index_lock *, int);
+void	 idle_baseline_reset(void);
+int	 index_lock_acquire(int, struct index_lock *, int);
 void	 index_lock_release(struct index_lock *);
-void	 handle_mbox_idle_refresh(struct imsgev *);
+void	 handle_mbox_idle_refresh(const struct imsg_mbox_idle_refresh *,
+		    struct imsgev *);
 void	 qresync_send_resync(const struct imsg_mbox_select *,
 		    const struct seq_range *, uint32_t, struct mbox_index *,
 		    struct imsgev *);
@@ -348,7 +312,7 @@ void	 qresync_send_resync(const struct imsg_mbox_selec
  * from more than one of the split-out files.
  */
 const char	*append_hostname(void);
-int		 ensure_maildir_dirs(const char *);
+int		 ensure_maildir_dirs(int, const char *);
 uint32_t	 letters_to_sysflags(const char *);
 int		 merge_keywords(int, const char *, const char *, char *,
 		    size_t);
blob - 29c7870d537508b2b47163c6c726534b52ccd0a8
blob + 68036f9bd0c0512b3cf54cf574d492051f023cb2
--- src/store_ipc.c
+++ src/store_ipc.c
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  *
@@ -14,7 +16,11 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* store_ipc.c: listener-side dispatch of the store process's async imsg protocol (session_handle_mbox_*()/session_finish_*() reply handlers). */
+/*
+ * store_ipc.c: listener-side dispatch of the store process's async
+ * imsg protocol (session_handle_mbox_*()/session_finish_*() reply
+ * handlers).
+ */
 
 #include <sys/types.h>
 #include <sys/queue.h>
@@ -39,7 +45,7 @@
 #include "log.h"
 #include "listener.h"
 
-/* Shared receive-side handling for the four IMSG_MBOX_FETCH_{HEADER,BODY,ENVELOPE,BODYSTRUCTURE} messages. */
+/* Shared receive-side handling for the four IMSG_MBOX_FETCH_* 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,
@@ -53,7 +59,8 @@ fetch_part_recv(struct session *s, struct imsg *imsg, 
 	*foundp = 0;
 
 	if (!found)
-		return;		/* store.c found/built nothing for this message */
+		/* store.c found/built nothing for this message */
+		return;
 
 	wirelen = imsg_get_len(imsg);
 	if (wirelen != declared_len) {
@@ -87,11 +94,14 @@ session_store_dispatch(int fd, short event, void *arg)
 	struct imsg	 imsg;
 	ssize_t		 n;
 
-	/* EV_WRITE: queued IMSG_MBOX_* requests need imsgbuf_write() once the fd is writable. */
+	/*
+	 * EV_WRITE: queued IMSG_MBOX_* requests need imsgbuf_write() once
+	 * writable.
+	 */
 	if (event & EV_WRITE) {
 		if (imsgbuf_write(&s->store_iev->ibuf) == -1) {
 			log_warnx("session %u: imsgbuf_write (store)", s->id);
-			session_teardown(s);
+			session_teardown(s, "io-error");
 			return;
 		}
 	}
@@ -99,12 +109,12 @@ session_store_dispatch(int fd, short event, void *arg)
 	if (event & EV_READ) {
 		if ((n = imsgbuf_read(&s->store_iev->ibuf)) == -1) {
 			log_warnx("session %u: imsgbuf_read (store)", s->id);
-			session_teardown(s);
+			session_teardown(s, "io-error");
 			return;
 		}
 		if (n == 0) {
 			log_warnx("session %u: store closed channel", s->id);
-			session_teardown(s);
+			session_teardown(s, "io-error");
 			return;
 		}
 	}
@@ -112,7 +122,7 @@ session_store_dispatch(int fd, short event, void *arg)
 	for (;;) {
 		if ((n = imsgbuf_get(&s->store_iev->ibuf, &imsg)) == -1) {
 			log_warnx("session %u: imsg_get (store)", s->id);
-			session_teardown(s);
+			session_teardown(s, "io-error");
 			return;
 		}
 		if (n == 0)
@@ -142,9 +152,13 @@ session_store_dispatch(int fd, short event, void *arg)
 		case IMSG_MBOX_FETCH_HEADER: {
 			struct imsg_mbox_fetch_header	 hdr;
 
-			/* Fixed-header-plus-variable-trailing-bytes, same technique as store.c's handle_mbox_append(). */
+			/*
+			 * Fixed header plus trailing bytes, both read with
+			 * imsg_get_buf().
+			 */
 			if (imsg_get_buf(&imsg, &hdr, sizeof(hdr)) == -1) {
-				log_warnx("bad IMSG_MBOX_FETCH_HEADER (header)");
+				log_warnx("bad IMSG_MBOX_FETCH_HEADER "
+				    "(header)");
 				break;
 			}
 			fetch_part_recv(s, &imsg, "IMSG_MBOX_FETCH_HEADER",
@@ -154,27 +168,55 @@ session_store_dispatch(int fd, short event, void *arg)
 		}
 		case IMSG_MBOX_FETCH_BODY: {
 			struct imsg_mbox_fetch_body	 bodyhdr;
+			int				 bodyfd;
 
-			/* Same technique as IMSG_MBOX_FETCH_HEADER just above, see that case's comment. */
-			if (imsg_get_buf(&imsg, &bodyhdr, sizeof(bodyhdr)) ==
-			    -1) {
-				log_warnx("bad IMSG_MBOX_FETCH_BODY (header)");
+			/*
+			 * No octets on this imsg: a found body is a read-only
+			 * descriptor and a range, read when the response is
+			 * written. The label, has_partial and partial_origin
+			 * are set once per FETCH in fetch_dispatch().
+			 */
+			bodyfd = imsg_get_fd(&imsg);
+			if (s->pending_body_fd != -1)
+				close(s->pending_body_fd);
+			s->pending_body_fd = -1;
+			s->pending_body_off = 0;
+			s->pending_body_len = 0;
+			s->pending_body_found = 0;
+			if (imsg_get_data(&imsg, &bodyhdr,
+			    sizeof(bodyhdr)) == -1) {
+				log_warnx("bad IMSG_MBOX_FETCH_BODY");
+				if (bodyfd != -1)
+					close(bodyfd);
 				break;
 			}
-			/* pending_body_label/has_partial/partial_origin are NOT reset here, set once per FETCH in fetch_dispatch(). */
-			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);
+			if (!bodyhdr.found ||
+			    (bodyhdr.length > 0 && bodyfd == -1)) {
+				if (bodyhdr.found)
+					log_warnx("session %u: "
+					    "IMSG_MBOX_FETCH_BODY without its "
+					    "descriptor", s->id);
+				if (bodyfd != -1)
+					close(bodyfd);
+				break;
+			}
+			s->pending_body_fd = bodyfd;
+			s->pending_body_off = bodyhdr.offset;
+			s->pending_body_len = bodyhdr.length;
+			s->pending_body_found = 1;
 			break;
 		}
 		case IMSG_MBOX_FETCH_ENVELOPE: {
 			struct imsg_mbox_fetch_envelope	 envhdr;
 
-			/* Same technique as IMSG_MBOX_FETCH_HEADER/IMSG_MBOX_FETCH_BODY, see IMSG_MBOX_FETCH_HEADER's comment. */
+			/*
+			 * Same technique as FETCH_HEADER/FETCH_BODY, see
+			 * IMSG_MBOX_FETCH_HEADER's comment.
+			 */
 			if (imsg_get_buf(&imsg, &envhdr, sizeof(envhdr)) ==
 			    -1) {
-				log_warnx("bad IMSG_MBOX_FETCH_ENVELOPE (header)");
+				log_warnx("bad IMSG_MBOX_FETCH_ENVELOPE "
+				    "(header)");
 				break;
 			}
 			fetch_part_recv(s, &imsg, "IMSG_MBOX_FETCH_ENVELOPE",
@@ -186,14 +228,18 @@ session_store_dispatch(int fd, short event, void *arg)
 		case IMSG_MBOX_FETCH_BODYSTRUCTURE: {
 			struct imsg_mbox_fetch_bodystructure	 bshdr;
 
-			/* Same technique as IMSG_MBOX_FETCH_HEADER/IMSG_MBOX_FETCH_ENVELOPE, see IMSG_MBOX_FETCH_HEADER's comment. */
+			/*
+			 * Same technique as FETCH_HEADER/FETCH_ENVELOPE, see
+			 * IMSG_MBOX_FETCH_HEADER's comment.
+			 */
 			if (imsg_get_buf(&imsg, &bshdr, sizeof(bshdr)) ==
 			    -1) {
 				log_warnx("bad IMSG_MBOX_FETCH_BODYSTRUCTURE "
 				    "(header)");
 				break;
 			}
-			fetch_part_recv(s, &imsg, "IMSG_MBOX_FETCH_BODYSTRUCTURE",
+			fetch_part_recv(s, &imsg,
+			    "IMSG_MBOX_FETCH_BODYSTRUCTURE",
 			    bshdr.found, bshdr.bslen,
 			    &s->pending_bodystructure_buf,
 			    &s->pending_bodystructure_len,
@@ -207,9 +253,19 @@ session_store_dispatch(int fd, short event, void *arg)
 				log_warnx("bad IMSG_MBOX_FETCH_META");
 				break;
 			}
-			/* imsg_get_data() doesn't guarantee NUL termination, and flags is formatted with "%s" into client-visible replies, so an unterminated field from the (untrusted) store child would leak stack memory onto the wire -- same trust boundary as auth.c, parent.c, keymgr.c, and store.c's imsg_field_valid(). */
+			/*
+			 * imsg_get_data() doesn't guarantee NUL termination,
+			 * and flags is formatted with "%s" into client-visible
+			 * replies, so an unterminated field from the
+			 * (untrusted) store child would leak stack memory onto
+			 * the wire -- same trust boundary as auth.c, parent.c,
+			 * keymgr.c, and store.c's 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. */
+			/*
+			 * Shared reply type for FETCH/STORE/QRESYNC resync;
+			 * SELECTING buffers per RFC 7162 SS3.2.6 order.
+			 */
 			if (s->state == SESSION_SELECTING)
 				session_handle_select_fetch(s, &meta);
 			else if (s->state == SESSION_STORING)
@@ -225,9 +281,13 @@ session_store_dispatch(int fd, short event, void *arg)
 				log_warnx("bad IMSG_MBOX_EXPUNGED");
 				break;
 			}
-			/* During a MOVE, buffered here and flushed after COPYUID by session_finish_copy_or_move() (SS6.4.8 ordering). */
+			/*
+			 * During a MOVE, buffered here, flushed after COPYUID
+			 * by session_finish_copy_or_move() (SS6.4.8).
+			 */
 			if (s->state == SESSION_COPYING) {
-				if (s->move_expunged_n == s->move_expunged_cap) {
+				if (s->move_expunged_n ==
+				    s->move_expunged_cap) {
 					uint32_t	 newcap =
 					    s->move_expunged_cap ?
 					    s->move_expunged_cap * 2 : 16;
@@ -276,7 +336,10 @@ session_store_dispatch(int fd, short event, void *arg)
 				log_warnx("bad IMSG_MBOX_SELECT_VANISHED");
 				break;
 			}
-			/* Also reused for RFC 7162 SS3.2.6's VANISHED UID FETCH modifier; SELECTING buffers, FETCHING writes immediately. */
+			/*
+			 * Also reused for RFC 7162 SS3.2.6's VANISHED modifier;
+			 * SELECTING buffers, FETCHING writes now.
+			 */
 			if (s->state == SESSION_SELECTING)
 				session_handle_select_vanished(s, &v);
 			else
@@ -293,16 +356,28 @@ session_store_dispatch(int fd, short event, void *arg)
 			session_handle_store_modified(s, &m);
 			break;
 		}
-		case IMSG_MBOX_IDLE_UID: {
-			struct imsg_mbox_idle_uid	 item;
+		case IMSG_MBOX_IDLE_EXPUNGE: {
+			struct imsg_mbox_idle_expunge	 item;
 
 			if (imsg_get_data(&imsg, &item, sizeof(item)) == -1) {
-				log_warnx("bad IMSG_MBOX_IDLE_UID");
+				log_warnx("bad IMSG_MBOX_IDLE_EXPUNGE");
 				break;
 			}
-			session_handle_idle_uid(s, &item);
+			session_handle_idle_expunge(s, &item);
 			break;
 		}
+		case IMSG_MBOX_IDLE_FETCH: {
+			struct imsg_mbox_fetch_meta	 meta;
+
+			if (imsg_get_data(&imsg, &meta, sizeof(meta)) == -1) {
+				log_warnx("bad IMSG_MBOX_IDLE_FETCH");
+				break;
+			}
+			/* same risk as IMSG_MBOX_FETCH_META's flags above */
+			meta.flags[sizeof(meta.flags) - 1] = '\0';
+			session_handle_idle_fetch(s, &meta);
+			break;
+		}
 		case IMSG_MBOX_IDLE_REFRESHED: {
 			struct imsg_mbox_idle_refreshed	 res;
 
@@ -330,7 +405,12 @@ session_store_dispatch(int fd, short event, void *arg)
 				log_warnx("bad IMSG_MBOX_LIST_ITEM");
 				break;
 			}
-			/* Same NUL-termination risk as IMSG_MBOX_FETCH_META's flags: mailbox is walked as a C string by list_pattern_match() and formatted with "%s" into the untagged LIST response. */
+			/*
+			 * Same NUL-termination risk as IMSG_MBOX_FETCH_META's
+			 * flags: mailbox is 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;
@@ -355,25 +435,43 @@ session_store_dispatch(int fd, short event, void *arg)
 	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. */
+	/*
+	 * If the reply just processed was terminal, s->state is idle again;
+	 * drain anything pipelined behind it.
+	 */
 	if (!session_dequeue_next(s))
 		return;	/* s was torn down by a queued LOGOUT, do not touch */
 }
 
-/* Finishes SELECT (SS6.3.2); flushes QRESYNC resync in RFC order: VANISHED (EARLIER), then FETCH, then tagged OK. */
+/*
+ * Finishes SELECT (SS6.3.2); flushes QRESYNC resync in RFC order: VANISHED,
+ * FETCH, tagged OK.
+ */
 void
-session_handle_mbox_selected(struct session *s, const struct imsg_mbox_selected *res)
+session_handle_mbox_selected(struct session *s,
+    const struct imsg_mbox_selected *res)
 {
 	char	buf[128];
 
 	if (res->error != MBOX_OP_OK || s->qresync_alloc_failed) {
-		/* RFC 9051 SS6.3.2 failure -> authenticated (RFC 5530 NONEXISTENT); a dropped QRESYNC resync also fails SELECT here on purpose, before any HIGHESTMODSEQ is advertised, using RFC 5530 SS3 UNAVAILABLE for the transient condition. */
+		/*
+		 * RFC 9051 SS6.3.2 failure -> authenticated (RFC 5530
+		 * NONEXISTENT); a dropped QRESYNC resync also fails SELECT
+		 * here on purpose, before any HIGHESTMODSEQ is advertised,
+		 * using RFC 5530 SS3 UNAVAILABLE for the transient condition.
+		 */
 		s->state = SESSION_AUTHENTICATED;
 		session_reply(s, s->pending_tag, "NO",
 		    s->qresync_alloc_failed ? "[UNAVAILABLE] SELECT failed" :
+		    res->error == MBOX_OP_ERR_BUSY ? IMAP_BUSY_TEXT :
 		    "[NONEXISTENT] no such mailbox");
 
-		/* An honest store child never streams resync data before a failing IMSG_MBOX_SELECTED, but a compromised one could, and leftover data here would be replayed into the next SELECT's response. */
+		/*
+		 * An honest store child never streams resync data before a
+		 * failing IMSG_MBOX_SELECTED, but a compromised one could,
+		 * and leftover data here would be replayed into the next
+		 * SELECT's response.
+		 */
 		free(s->vanished_ranges);
 		s->vanished_ranges = NULL;
 		s->vanished_nranges = 0;
@@ -389,7 +487,10 @@ session_handle_mbox_selected(struct session *s, const 
 	s->state = SESSION_SELECTED;
 	s->mbox_highestmodseq = res->highestmodseq;
 
-	/* Order matches RFC 9051 SS6.3.2's worked example; that section says the order itself isn't actually significant. */
+	/*
+	 * Order matches RFC 9051 SS6.3.2's worked example; that section says
+	 * the order isn't significant.
+	 */
 	snprintf(buf, sizeof(buf), "%u EXISTS", res->exists);
 	session_untagged(s, buf);
 
@@ -407,7 +508,10 @@ session_handle_mbox_selected(struct session *s, const 
 		session_untagged(s, buf);
 	}
 
-	/* PERMANENTFLAGS: EXAMINE gets none (SS6.3.3); SELECT adds "\*" since arbitrary keywords may be created (SS6.3.2). */
+	/*
+	 * PERMANENTFLAGS: EXAMINE gets none (SS6.3.3); SELECT adds "\*" since
+	 * keywords may be created (SS6.3.2).
+	 */
 	session_untagged(s,
 	    "FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)");
 	if (s->mbox_readonly)
@@ -418,7 +522,13 @@ 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; name goes through quote_mailbox() (may contain a space or SS4.3 quoted-special) and is written with session_write() since the escaped form can exceed session_untagged()'s 512-byte limit, same as VANISHED (EARLIER). */
+	/*
+	 * RFC 9051 SS6.3.2 LIST; name goes through quote_mailbox() (may
+	 * contain a space or SS4.3 quoted-special) and is written with
+	 * session_write() since the escaped form can exceed
+	 * session_untagged()'s 512-byte limit, same as VANISHED
+	 * (EARLIER).
+	 */
 	{
 		char	qname[MBOX_QUOTED_MAX];
 		char	listbuf[MBOX_QUOTED_MAX + 64];
@@ -444,14 +554,18 @@ session_handle_mbox_selected(struct session *s, const 
 		size_t	vlen;
 		int	flen;
 
-		/* session_untagged()'s 512-byte buffer is too small here, same bypass session_finish_search() uses for ESEARCH. */
+		/*
+		 * session_untagged()'s 512-byte buffer is too small here, same
+		 * bypass used for ESEARCH.
+		 */
 		vlen = format_range_list(vbuf, sizeof(vbuf),
 		    s->vanished_ranges, s->vanished_nranges, &truncated);
 		if (truncated)
 			log_warnx("session %u: VANISHED (EARLIER) list "
 			    "truncated at %zu bytes", s->id, vlen);
 
-		flen = snprintf(full, sizeof(full), "* VANISHED (EARLIER) %s\r\n",
+		flen = snprintf(full, sizeof(full),
+		    "* VANISHED (EARLIER) %s\r\n",
 		    vbuf);
 		if (flen > 0)
 			session_write(s, full, (size_t)flen >= sizeof(full) ?
@@ -475,7 +589,10 @@ session_handle_mbox_selected(struct session *s, const 
 	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. */
+	/*
+	 * RFC 9051 SS6.3.2/SS6.3.3: READ-WRITE for SELECT, READ-ONLY for
+	 * EXAMINE, keyed off s->mbox_readonly.
+	 */
 	if (s->mbox_readonly)
 		session_reply(s, s->pending_tag, "OK",
 		    "[READ-ONLY] EXAMINE completed");
@@ -484,13 +601,21 @@ session_handle_mbox_selected(struct session *s, const 
 		    "[READ-WRITE] SELECT completed");
 }
 
-/* Finishes STATUS (SS6.3.11); attrs emitted in fixed canonical order, not the client's request order. */
+/*
+ * Finishes STATUS (SS6.3.11); attrs emitted in fixed canonical order, not
+ * request order.
+ */
 void
 session_handle_mbox_status_result(struct session *s,
     const struct imsg_mbox_status_result *res)
 {
 	char	qname[MBOX_QUOTED_MAX];
-	/* F2 fix: buf[256] was too small for a near-max mailbox name plus STATUS attrs; now sized for the SS4.3-escaped name, leading "* " and trailing CRLF since this line bypasses session_untagged()'s 512-byte buffer. */
+	/*
+	 * F2 fix: buf[256] was too small for a near-max mailbox name
+	 * plus STATUS attrs; now sized for the SS4.3-escaped name,
+	 * leading "* " and trailing CRLF since this line bypasses
+	 * session_untagged()'s 512-byte buffer.
+	 */
 	char	buf[MBOX_QUOTED_MAX + 384];
 	size_t	len;
 	int	n, first = 1;
@@ -498,19 +623,28 @@ session_handle_mbox_status_result(struct session *s,
 	s->state = s->status_prev_state;
 
 	if (res->error != MBOX_OP_OK) {
-		/* Valid-but-missing name or store.c's open/flock/index_load failure, can't tell apart, so NONEXISTENT covers both. */
+		/*
+		 * Valid-but-missing name or an open/flock/index_load failure --
+		 * indistinguishable, so NONEXISTENT covers both.
+		 */
 		session_reply(s, s->pending_tag, "NO",
+		    res->error == MBOX_OP_ERR_BUSY ? IMAP_BUSY_TEXT :
 		    "[NONEXISTENT] no such mailbox");
 		return;
 	}
 
-	/* Quoted, not bare, since mailbox names can contain spaces; quote_mailbox() now renders proper SS4.3 quoting and parse_mailbox_arg() decodes it on input, closing both the output and input escaping gaps from the mailbox_cmd.c review. */
+	/*
+	 * Quoted, not bare, since mailbox names can contain spaces;
+	 * quote_mailbox() now renders proper SS4.3 quoting and
+	 * parse_mailbox_arg() decodes it on input, closing both the
+	 * output and input escaping gaps from the mailbox_cmd.c review.
+	 */
 	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 */	\
+	if (len < sizeof(buf)) {	/* never index past buf */	\
 		n = snprintf(buf + len, sizeof(buf) - len, "%s" fmt,	\
 		    first ? "" : " ", (val));				\
 		if (n > 0 && (size_t)n < sizeof(buf) - len)		\
@@ -522,7 +656,11 @@ session_handle_mbox_status_result(struct session *s,
 	if (s->status_attrs & STATUS_ATT_MESSAGES)
 		STATUS_APPEND("MESSAGES %u", res->messages);
 	if (s->status_attrs & STATUS_ATT_RECENT)
-		/* IMAP4rev2 dropped \Recent (RFC 9051 SS2.3.2); this server tracks no such state so always answers 0, kept here only to mirror IMAP4rev1's MESSAGES/RECENT ordering. */
+		/*
+		 * IMAP4rev2 dropped \Recent (RFC 9051 SS2.3.2); this server
+		 * tracks no such state so always answers 0, kept here only
+		 * to mirror IMAP4rev1's MESSAGES/RECENT ordering.
+		 */
 		STATUS_APPEND("RECENT %u", 0U);
 	if (s->status_attrs & STATUS_ATT_UIDNEXT)
 		STATUS_APPEND("UIDNEXT %u", res->uidnext);
@@ -540,7 +678,12 @@ session_handle_mbox_status_result(struct session *s,
 
 #undef STATUS_APPEND
 
-	/* Closing paren and CRLF are written here (not via session_untagged(), whose 512 bytes the escaped name can exceed); buf is sized to always fit, and the else is an unreachable guard answering NO. */
+	/*
+	 * Closing paren and CRLF are written here (not via
+	 * session_untagged(), whose 512 bytes the escaped name can
+	 * exceed); buf is sized to always fit, and the else is an
+	 * unreachable guard answering NO.
+	 */
 	if (len + 3 < sizeof(buf)) {
 		buf[len++] = ')';
 		buf[len++] = '\r';
@@ -556,7 +699,11 @@ session_handle_mbox_status_result(struct session *s,
 	session_reply(s, s->pending_tag, "OK", "STATUS completed");
 }
 
-/* Terminal CREATE/DELETE/RENAME reply; s->state is read (which command) before it's overwritten. */
+/*
+ * Terminal CREATE/DELETE/RENAME/SUBSCRIBE/UNSUBSCRIBE reply; s->state (which
+ * command) is read before overwritten. Only RENAME and DELETE act on
+ * s->mbox_op_name below; the other three have no effect on what is selected.
+ */
 void
 session_finish_mbox_op(struct session *s, const struct imsg_mbox_result *res)
 {
@@ -567,6 +714,10 @@ session_finish_mbox_op(struct session *s, const struct
 		cmdname = "CREATE";
 	else if (s->state == SESSION_DELETING)
 		cmdname = "DELETE";
+	else if (s->state == SESSION_SUBSCRIBING)
+		cmdname = "SUBSCRIBE";
+	else if (s->state == SESSION_UNSUBSCRIBING)
+		cmdname = "UNSUBSCRIBE";
 	else
 		cmdname = "RENAME";
 
@@ -576,23 +727,37 @@ session_finish_mbox_op(struct session *s, const struct
 	case MBOX_OP_OK:
 		break;
 	case MBOX_OP_ERR_NO_SUCH_MAILBOX:
-		/* RFC 5530 SS3 NONEXISTENT: DELETE of a missing mailbox, or RENAME with a missing source */
+		/*
+		 * RFC 5530 SS3 NONEXISTENT: DELETE of a missing mailbox, or
+		 * RENAME missing its source
+		 */
 		session_reply(s, s->pending_tag, "NO",
 		    "[NONEXISTENT] no such mailbox");
 		return;
 	case MBOX_OP_ERR_ALREADY_EXISTS:
-		/* RFC 5530 SS3 ALREADYEXISTS: CREATE of an existing mailbox, or RENAME to an existing destination */
+		/*
+		 * RFC 5530 SS3 ALREADYEXISTS: CREATE of an existing mailbox, or
+		 * RENAME to an existing dest
+		 */
 		session_reply(s, s->pending_tag, "NO",
 		    "[ALREADYEXISTS] mailbox already exists");
 		return;
 	default:
-		/* mkdir/stat/rename(2) I/O error or truncated buffer, no RFC 5530 code fits, plain NO. */
+		/*
+		 * mkdir/stat/rename(2) I/O error or truncated buffer; no RFC
+		 * 5530 code fits, plain NO.
+		 */
 		snprintf(text, sizeof(text), "%s failed", cmdname);
 		session_reply(s, s->pending_tag, "NO", text);
 		return;
 	}
 
-	/* The op succeeded on this session's own selected mailbox, so selection must follow; s->state (restored to mbox_op_prev_state above) gates the check since s->selected_mailbox is meaningful only while SESSION_SELECTED. */
+	/*
+	 * The op succeeded on this session's own selected mailbox, so
+	 * selection must follow; s->state (restored to
+	 * mbox_op_prev_state above) gates the check since
+	 * s->selected_mailbox is meaningful only while SESSION_SELECTED.
+	 */
 	if (s->state == SESSION_SELECTED &&
 	    strcmp(s->selected_mailbox, s->mbox_op_name) == 0) {
 		if (strcmp(cmdname, "RENAME") == 0) {
@@ -603,10 +768,16 @@ session_finish_mbox_op(struct session *s, const struct
 				    "truncated after RENAME, can't happen "
 				    "(both same size)", s->id);
 		} else if (strcmp(cmdname, "DELETE") == 0) {
-			/* Selected mailbox is gone, so clear selection here too (like CLOSE does): staying SESSION_SELECTED would let further FETCH/STORE/SEARCH/COPY/MOVE/EXPUNGE silently hit INBOX (the store child's cwd after delete), risking INBOX data loss under a name the client thinks it deleted. */
+			/*
+			 * Selected mailbox is gone, so clear selection here too
+			 * (like CLOSE does): staying SESSION_SELECTED would let
+			 * further FETCH/STORE/SEARCH/COPY/MOVE/EXPUNGE silently
+			 * hit INBOX (the store child's cwd after delete),
+			 * risking INBOX data loss under a name the client
+			 * thinks it deleted.
+			 */
 			s->state = SESSION_AUTHENTICATED;
 			s->selected_mailbox[0] = '\0';
-			session_reset_idle_baseline(s);
 		}
 	}
 
@@ -614,7 +785,10 @@ session_finish_mbox_op(struct session *s, const struct
 	session_reply(s, s->pending_tag, "OK", text);
 }
 
-/* One LIST_ITEM entry, matched case-sensitively against s->list_pattern; streamed straight through, no buffering needed. */
+/*
+ * One LIST_ITEM entry, matched case-sensitively vs s->list_pattern; streamed
+ * through, unbuffered.
+ */
 void
 session_handle_mbox_list_item(struct session *s,
     const struct imsg_mbox_list_item *item)
@@ -622,22 +796,50 @@ session_handle_mbox_list_item(struct session *s,
 	char		 qname[MBOX_QUOTED_MAX];
 	char		 buf[MBOX_QUOTED_MAX + 64];
 	const char	*kw = s->list_is_lsub ? "LSUB" : "LIST";
+	const char	*attrs;
 	int		 n;
 
 	if (!list_pattern_match(s->list_pattern, item->mailbox, 0))
 		return;
 
-	/* Quoted, not bare, same reasoning as session_handle_mbox_status_result(); "()" means no attributes (SS7.3.1). item->mailbox comes straight from readdir(2) in the store child, so this is the emission that made the escaping gap client-visible. */
+	/*
+	 * Mailbox attributes, RFC 9051 SS7.3.1; "()" is none. A plain LIST
+	 * reports nothing about subscription state, so it keeps the empty
+	 * list it has always sent. Otherwise the two commands say the same
+	 * thing in their own dialects: LIST (SUBSCRIBED) marks a subscribed
+	 * name whose mailbox is gone "\Subscribed \NonExistent"
+	 * (SS6.3.9.6's Table 3), LSUB marks it "\Noselect" (RFC 3501
+	 * SS6.3.9), and RFC 9051's Table 2 makes \NonExistent imply
+	 * \NoSelect.
+	 */
+	if (!s->list_subscribed_only)
+		attrs = "";
+	else if (s->list_is_lsub)
+		attrs = item->exists ? "" : "\\Noselect";
+	else
+		attrs = item->exists ? "\\Subscribed" :
+		    "\\Subscribed \\NonExistent";
+
+	/*
+	 * Quoted, not bare, same reasoning as
+	 * session_handle_mbox_status_result(). item->mailbox comes straight
+	 * from readdir(2) or the subscription file in the store child, so
+	 * this is the emission that made the escaping gap client-visible.
+	 */
 	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);
+	n = snprintf(buf, sizeof(buf), "* %s (%s) \"/\" %s\r\n", kw, attrs,
+	    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. */
+/*
+ * Terminal LIST/LSUB reply; a non-OK error means a real I/O error -- mismatches
+ * already silently produce no item.
+ */
 void
 session_finish_list(struct session *s, const struct imsg_mbox_result *res)
 {
@@ -656,7 +858,10 @@ session_finish_list(struct session *s, const struct im
 	session_reply(s, s->pending_tag, "OK", text);
 }
 
-/* Appends one COPYUID UID pair, growing both arrays together (doubling from 16); copy_alloc_failed short-circuits on OOM. */
+/*
+ * Appends one COPYUID UID pair, growing both arrays together (doubling from
+ * 16); OOM sets copy_alloc_failed.
+ */
 void
 session_handle_mbox_copy_mapping(struct session *s,
     const struct imsg_mbox_copy_mapping *m)
@@ -693,7 +898,11 @@ session_handle_mbox_copy_mapping(struct session *s,
 	s->copy_n++;
 }
 
-/* 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). */
+/*
+ * 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)
@@ -719,12 +928,17 @@ session_handle_select_vanished(struct session *s,
 	s->vanished_nranges++;
 }
 
-/* Appends one QRESYNC resync FETCH_META; held back for RFC 7162 SS3.2.6's VANISHED-before-FETCH ordering. */
+/*
+ * Appends one QRESYNC resync FETCH_META; held back for RFC 7162 SS3.2.6's
+ * ordering.
+ */
 void
-session_handle_select_fetch(struct session *s, const struct imsg_mbox_fetch_meta *m)
+session_handle_select_fetch(struct session *s,
+    const struct imsg_mbox_fetch_meta *m)
 {
 	if (s->qresync_nfetches == s->qresync_fetches_cap) {
-		uint32_t			 newcap = s->qresync_fetches_cap ?
+		uint32_t			 newcap =
+		    s->qresync_fetches_cap ?
 		    s->qresync_fetches_cap * 2 : 16;
 		struct imsg_mbox_fetch_meta	*n = reallocarray(
 		    s->qresync_fetches, newcap, sizeof(*n));
@@ -742,155 +956,124 @@ session_handle_select_fetch(struct session *s, const s
 	s->qresync_fetches[s->qresync_nfetches++] = *m;
 }
 
-/* Appends one IDLE_UID (SS6.3.13); accumulated until session_handle_idle_refreshed() has the complete list to diff. */
+/* One untagged EXPUNGE the store child says to print, RFC 9051 SS7.5.1. */
 void
-session_handle_idle_uid(struct session *s, const struct imsg_mbox_idle_uid *item)
+session_handle_idle_expunge(struct session *s,
+    const struct imsg_mbox_idle_expunge *item)
 {
-	if (s->idle_incoming_n == s->idle_incoming_cap) {
-		uint32_t	 newcap = s->idle_incoming_cap ?
-		    s->idle_incoming_cap * 2 : 16;
-		uint32_t	*n = reallocarray(s->idle_incoming_uids,
-		    newcap, sizeof(*n));
+	char	buf[32];
 
-		if (n == NULL) {
-			log_warn("session %u: realloc IDLE UID array", s->id);
-			s->idle_alloc_failed = 1;
-			return;
-		}
-		s->idle_incoming_uids = n;
-		s->idle_incoming_cap = newcap;
-	}
+	/* DONE may have arrived while the refresh was in flight. */
+	if (!s->idling || s->state != SESSION_SELECTED)
+		return;
 
-	s->idle_incoming_uids[s->idle_incoming_n++] = item->uid;
+	snprintf(buf, sizeof(buf), "%u EXPUNGE", item->seqno);
+	session_untagged(s, buf);
 }
 
-/* RFC 9051 SS7.5.1: seqnos decrement immediately, so seqno only advances on a present match (SS6.4.3's worked example). */
+/*
+ * One untagged FETCH for a message whose flags changed elsewhere, RFC 9051
+ * SS6.3.13. Any unsolicited FETCH must carry UID, per SS6.3.13 and SS7.5.2.
+ * RFC 7162 SS3.1 adds that once a CONDSTORE enabling command has been
+ * issued, mod-sequence data belongs in every later untagged FETCH, whatever
+ * caused it; s->condstore_enabled is that state.
+ */
 void
-session_push_idle_expunges(struct session *s, const uint32_t *old,
-    uint32_t oldn, const uint32_t *cur, uint32_t curn)
+session_handle_idle_fetch(struct session *s,
+    const struct imsg_mbox_fetch_meta *meta)
 {
-	uint32_t	i, j, seqno;
-	char		buf[32];
+	char	buf[MBOX_FLAGS_MAX + 96];
 
-	seqno = 1;
-	for (i = 0; i < oldn; i++) {
-		int	present = 0;
+	/* DONE may have arrived while the refresh was in flight. */
+	if (!s->idling || s->state != SESSION_SELECTED)
+		return;
 
-		for (j = 0; j < curn; j++) {
-			if (cur[j] == old[i]) {
-				present = 1;
-				break;
-			}
-		}
-		if (present) {
-			seqno++;
-			continue;
-		}
-		snprintf(buf, sizeof(buf), "%u EXPUNGE", seqno);
-		session_untagged(s, buf);
-	}
+	if (s->condstore_enabled)
+		snprintf(buf, sizeof(buf),
+		    "%u FETCH (UID %u FLAGS (%s) MODSEQ (%llu))", meta->seqno,
+		    meta->uid, meta->flags, (unsigned long long)meta->modseq);
+	else
+		snprintf(buf, sizeof(buf), "%u FETCH (UID %u FLAGS (%s))",
+		    meta->seqno, meta->uid, meta->flags);
+	session_untagged(s, buf);
 }
 
-/* Discards this session's IDLE snapshot; must be called whenever the selected mailbox changes, since a stale snapshot diffed against a different mailbox's UID list would report untouched messages as spuriously EXPUNGEd (group-13 review finding #1). */
+/*
+ * Terminal IDLE_REFRESH reply; any EXPUNGE lines have already been printed
+ * by session_handle_idle_expunge() and any FETCH lines by
+ * session_handle_idle_fetch(), and RFC 9051 SS7.5.1 wants the EXPUNGEs
+ * before the new count, which the imsg order gives.
+ */
 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,
     const struct imsg_mbox_idle_refreshed *res)
 {
-	uint32_t	*newlist = s->idle_incoming_uids;
-	uint32_t	 newn = s->idle_incoming_n;
-	int		 incomplete = s->idle_alloc_failed;
+	char	buf[32];
 
-	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;
 
-	/* Store child's cheap probe found nothing touched and sent no IMSG_MBOX_IDLE_UID messages; keeping the old baseline (not diffing against an empty list) avoids falsely reporting every message as EXPUNGEd. */
-	if (res->ok && res->unchanged) {
-		free(newlist);
-		goto maybe_again;
+	/*
+	 * The store child keeps the baseline, so a failed refresh needs
+	 * nothing undone here: it reported no change and kept what it had.
+	 */
+	if (!res->ok && res->busy)
+		log_debug("session %u: IDLE refresh skipped, index lock busy",
+		    s->id);
+	else if (!res->ok)
+		log_warnx("session %u: IDLE refresh failed, keeping last "
+		    "known state", s->id);
+	else if (res->exists_changed && s->idling &&
+	    s->state == SESSION_SELECTED) {
+		snprintf(buf, sizeof(buf), "%u EXISTS", res->exists);
+		session_untagged(s, buf);
 	}
 
-	/* An incomplete UID list is discarded rather than diffed or adopted as the new baseline: diffing it would falsely EXPUNGE still-existing messages, and adopting it would make the gap permanent; keeping the old baseline just costs one stale view. */
-	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;
-		s->idle_known_nuids = newn;
-		s->idle_known_cap = newn;
-		s->idle_baseline_valid = 1;
-		goto maybe_again;
-	}
-
-	if (s->idling && s->state == SESSION_SELECTED) {
-		session_push_idle_expunges(s, s->idle_known_uids,
-		    s->idle_known_nuids, newlist, newn);
-		if (newn != s->idle_known_nuids) {
-			char	buf[32];
-
-			snprintf(buf, sizeof(buf), "%u EXISTS", newn);
-			session_untagged(s, buf);
-		}
-	}
-
-	free(s->idle_known_uids);
-	s->idle_known_uids = newlist;
-	s->idle_known_nuids = newn;
-	s->idle_known_cap = newn;
-
-maybe_again:
 	if (s->idle_refresh_again) {
+		int	seed = s->idle_refresh_again_seed;
+
 		s->idle_refresh_again = 0;
-		session_request_idle_refresh(s);
+		s->idle_refresh_again_seed = 0;
+		session_request_idle_refresh(s, seed);
 	}
 }
 
-/* Sends IMSG_MBOX_IDLE_REFRESH; coalesces via s->idle_refresh_again instead of overlapping requests if one's in flight. */
+/*
+ * Sends IMSG_MBOX_IDLE_REFRESH; coalesces via s->idle_refresh_again instead of
+ * overlapping in-flight requests. seed asks the store child to adopt what it
+ * finds rather than report it, and outlives coalescing: a seed folded into a
+ * pending request must still seed, or the client would be told about changes
+ * made while it was not idling.
+ */
 void
-session_request_idle_refresh(struct session *s)
+session_request_idle_refresh(struct session *s, int seed)
 {
+	struct imsg_mbox_idle_refresh	 req;
+
 	if (s->idle_refresh_pending) {
 		s->idle_refresh_again = 1;
+		if (seed)
+			s->idle_refresh_again_seed = 1;
 		return;
 	}
 	if (s->store_iev == NULL)
 		return;		/* no store child wired, nothing to ask */
 
+	memset(&req, 0, sizeof(req));
+	req.seed = seed;
 	s->idle_refresh_pending = 1;
 	if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_IDLE_REFRESH, 0, 0,
-	    -1, NULL, 0) == -1)
+	    -1, &req, sizeof(req)) == -1)
 		log_warn("session %u: imsg_compose IMSG_MBOX_IDLE_REFRESH",
 		    s->id);
 }
 
-/* The IDLE poll (RFC 9051 SS6.3.13) that drives IDLE pushes; replaces the old per-session notification walk (broken under SS7's one-session-per-process model and blind to MTA deliveries) with a poll that's cheap when nothing changed (index.c's idle_probe_unchanged(), two stat(2) calls). */
+/*
+ * The IDLE poll (RFC 9051 SS6.3.13) that drives IDLE pushes;
+ * replaces the old per-session notification walk (broken under
+ * SS7's one-session-per-process model and blind to MTA deliveries)
+ * with a poll that's cheap when nothing changed
+ * (index.c's idle_probe_unchanged(), two stat(2) calls).
+ */
 static void
 session_idle_poll(int fd, short event, void *arg)
 {
@@ -899,20 +1082,30 @@ session_idle_poll(int fd, short event, void *arg)
 	(void)fd;
 	(void)event;
 
-	/* Both IDLE exits disarm this timer so these checks should never fail; they guard against a refresh reply arriving after the session already left IDLE, which would corrupt session_handle_idle_refreshed()'s baseline. */
+	/*
+	 * Both IDLE exits disarm this timer so these checks should never
+	 * fail; they guard against a refresh being asked for after the
+	 * session already left IDLE, which would advance the store child's
+	 * baseline past what this client has been told.
+	 */
 	if (!s->idling || s->state != SESSION_SELECTED) {
-		/* Should be unreachable; logged rather than silently ignored so a wrong-baseline bug (reply arriving after the session left IDLE) leaves evidence instead of just corrupting state invisibly. */
+		/*
+		 * Should be unreachable; logged rather than silently ignored
+		 * so a wrong-baseline bug (reply arriving after the session
+		 * left IDLE) leaves evidence instead of just corrupting
+		 * state invisibly.
+		 */
 		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_request_idle_refresh(s, 0);
 	session_idle_poll_arm(s);	/* an evtimer is one-shot */
 }
 
-/* Once per session, early enough that session_idle_poll_disarm() is always safe. */
+/* Once per session, early enough that session_idle_poll_disarm() is safe. */
 void
 session_idle_poll_init(struct session *s)
 {
@@ -940,7 +1133,10 @@ 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. */
+/*
+ * QRESYNC resync FETCH: always UID+FLAGS+MODSEQ (RFC 7162 SS3.2.5.1); no
+ * s->fetch_attrs, RFC fixes content.
+ */
 void
 session_send_qresync_fetch_response(struct session *s,
     const struct imsg_mbox_fetch_meta *meta)
@@ -953,11 +1149,20 @@ session_send_qresync_fetch_response(struct session *s,
 	session_untagged(s, buf);
 }
 
-/* Worst-case sizing for RFC 7162 SS3.1.3's MODIFIED list, mirroring search_cmd.c's SEARCH_ALL_PER_MATCH/SEARCH_RESP_PREFIX_MAX pair: each entry is at most "4294967295" plus separator, plus tag, fixed words, cmdname and CRLF. */
+/*
+ * Worst-case sizing for RFC 7162 SS3.1.3's MODIFIED list,
+ * mirroring search_cmd.c's SEARCH_ALL_PER_MATCH/
+ * SEARCH_RESP_PREFIX_MAX pair: each entry is at most
+ * "4294967295" plus separator, plus tag, 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. */
+/*
+ * Terminal reply hub; SEARCH/COPY/MOVE/CREATE/DELETE/RENAME/LIST peel off
+ * first; FETCH/STORE/EXPUNGE share rest.
+ */
 void
 session_handle_mbox_result(struct session *s, struct imsg_mbox_result *res)
 {
@@ -973,18 +1178,28 @@ session_handle_mbox_result(struct session *s, struct i
 		return;
 	}
 	if (s->state == SESSION_CREATING || s->state == SESSION_DELETING ||
-	    s->state == SESSION_RENAMING) {
-		/* RFC 9051 SS6.3.4-SS6.3.6: same peel-off pattern as SEARCH/COPY, reply text doesn't fit this function's cmdname table. */
+	    s->state == SESSION_RENAMING || s->state == SESSION_SUBSCRIBING ||
+	    s->state == SESSION_UNSUBSCRIBING) {
+		/*
+		 * RFC 9051 SS6.3.4-SS6.3.8: same peel-off as SEARCH/COPY; reply
+		 * text doesn't fit this cmdname table.
+		 */
 		session_finish_mbox_op(s, res);
 		return;
 	}
 	if (s->state == SESSION_LISTING) {
-		/* RFC 9051 SS6.3.9: same peel-off reasoning; LIST/LSUB text is keyed off s->list_is_lsub instead. */
+		/*
+		 * RFC 9051 SS6.3.9: same peel-off reasoning; LIST/LSUB text
+		 * keyed off s->list_is_lsub.
+		 */
 		session_finish_list(s, res);
 		return;
 	}
 
-	/* RFC 9051 SS6.4.9: becomes "UID <CMD>" when s->cmd_by_uid is set, CLOSE is the exception (no "UID CLOSE"). */
+	/*
+	 * RFC 9051 SS6.4.9: becomes "UID <CMD>" when s->cmd_by_uid is set;
+	 * CLOSE is the exception.
+	 */
 	if (s->state == SESSION_STORING) {
 		cmdname = s->cmd_by_uid ? "UID STORE" : "STORE";
 		was_storing = 1;
@@ -996,46 +1211,82 @@ session_handle_mbox_result(struct session *s, struct i
 	} else
 		cmdname = s->cmd_by_uid ? "UID FETCH" : "FETCH";
 
-	/* res->error is enum mbox_op_error where MBOX_OP_OK==1 (0 is MBOX_ERR_UNSET), so logging it raw made every success read "error=1"; same two-way label session_finish_search() already uses. */
+	/*
+	 * res->error is enum mbox_op_error where MBOX_OP_OK==1 (0 is
+	 * MBOX_ERR_UNSET), so logging it raw made every success read
+	 * "error=1"; same two-way label session_finish_search() already
+	 * uses.
+	 */
 	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. */
+	/*
+	 * RFC 9051 SS6.4.1: only a CLOSE that succeeded leaves the selected
+	 * state; a failed one removed nothing (handle_mbox_expunge()).
+	 */
+	s->state = (was_close && res->error == MBOX_OP_OK) ?
+	    SESSION_AUTHENTICATED : SESSION_SELECTED;
+
+	if (res->error != MBOX_OP_OK) {
+		/*
+		 * An I/O error or a keyword set that does not fit; RFC 9051
+		 * has no code for either, so plain NO is honest. The store
+		 * changed nothing, so HIGHESTMODSEQ is not adopted.
+		 */
+		char	text[32];
+
+		free(s->store_modified);
+		s->store_modified = NULL;
+		s->store_modified_n = 0;
+		s->store_modified_cap = 0;
+		s->store_modified_alloc_failed = 0;
+
+		if (res->error == MBOX_OP_ERR_BUSY) {
+			session_reply(s, s->pending_tag, "NO",
+			    IMAP_BUSY_TEXT);
+			return;
+		}
+		snprintf(text, sizeof(text), "%s failed", cmdname);
+		session_reply(s, s->pending_tag, "NO", text);
+		return;
+	}
+
+	/* RFC 7162: post-op HIGHESTMODSEQ for session_condstore_enable() */
 	if (was_storing || was_expunging)
 		s->mbox_highestmodseq = res->highestmodseq;
 
-	/* 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. */
-		char	text[32];
-
-		free(s->store_modified);
-		s->store_modified = NULL;
-		s->store_modified_n = 0;
-		s->store_modified_cap = 0;
-		s->store_modified_alloc_failed = 0;
-
-		snprintf(text, sizeof(text), "%s failed", cmdname);
-		session_reply(s, s->pending_tag, "NO", text);
-		return;
-	}
-
-	/* RFC 7162 SS3.1.3: a failed-conditional STORE gets MODIFIED on its tagged OK. The alloc_failed arm of this test matters: a grow that failed on the very first entry leaves store_modified_n at 0, which would otherwise fall through to the unqualified "STORE completed" below and tell the client every message passed UNCHANGEDSINCE. */
+	/*
+	 * RFC 7162 SS3.1.3: a failed-conditional STORE gets MODIFIED on
+	 * its tagged OK. The alloc_failed arm of this test matters: a
+	 * grow that failed on the very first entry leaves
+	 * store_modified_n at 0, which would otherwise fall through to
+	 * the unqualified "STORE completed" below and tell the client
+	 * every message passed UNCHANGEDSINCE.
+	 */
 	if (was_storing && (s->store_modified_n > 0 ||
 	    s->store_modified_alloc_failed)) {
 		char	*rbuf = NULL, *text = NULL;
 		size_t	 rbufsize, textsize;
 		int	 truncated, n, incomplete;
 
-		/* Every way the set can come up short lands here: a failed grow in session_handle_store_modified(), a formatter truncation, a response that doesn't fit, or buffers that don't allocate. */
+		/*
+		 * Every way the set can come up short lands here: a failed
+		 * grow in session_handle_store_modified(), a formatter
+		 * truncation, a response that doesn't fit, or buffers that
+		 * don't allocate.
+		 */
 		incomplete = s->store_modified_alloc_failed;
 
-		/* Heap-allocated and worst-case-sized like session_finish_search()'s ESEARCH ALL list, since the old fixed 2048-byte buffers weren't the real bound: session_reply()'s 512-byte overflow handling amputates the closing "]" into a malformed resp-text-code; composed in full and sent via session_write(), same as ESEARCH and VANISHED (EARLIER). */
+		/*
+		 * Heap-allocated and worst-case-sized like
+		 * session_finish_search()'s ESEARCH ALL list, since the old
+		 * fixed 2048-byte buffers weren't the real bound:
+		 * session_reply()'s 512-byte overflow handling amputates the
+		 * closing "]" into a malformed resp-text-code; composed in
+		 * full and sent via session_write(), same as ESEARCH and
+		 * VANISHED (EARLIER).
+		 */
 		rbufsize = (size_t)s->store_modified_n * MODIFIED_PER_ENTRY + 1;
 		textsize = rbufsize + MODIFIED_WRAPPER_MAX;
 
@@ -1045,7 +1296,14 @@ session_handle_mbox_result(struct session *s, struct i
 				format_seq_list(rbuf, rbufsize,
 				    s->store_modified, s->store_modified_n,
 				    &truncated);
-				/* Can't fire today (MODIFIED_PER_ENTRY sizes rbuf for the worst case, as SEARCH_ALL_PER_MATCH does for ESEARCH), but a short list is indistinguishable from a complete one to the client, so it is refused rather than sent. */
+				/*
+				 * Can't fire today (MODIFIED_PER_ENTRY sizes
+				 * rbuf for the worst case, as
+				 * SEARCH_ALL_PER_MATCH does for ESEARCH), but a
+				 * short list is indistinguishable from a
+				 * complete one to the client, so it is refused
+				 * rather than sent.
+				 */
 				if (truncated) {
 					log_warnx("session %u: MODIFIED list "
 					    "truncated", s->id);
@@ -1074,7 +1332,20 @@ session_handle_mbox_result(struct session *s, struct i
 		free(rbuf);
 		free(text);
 
-		/* SS3.1.3's set MUST list every message that failed UNCHANGEDSINCE, and per SS3.1.3's client guidance a message absent from it is one the client believes was stored and will never retry -- so a set known to be short is never sent, and dropping the code entirely would say the same thing (no MODIFIED means nothing failed). Refusing the command is what every other variable-length list here does on a failed grow; RFC 5530 SS3 UNAVAILABLE marks it transient, the same code session_handle_mbox_selected() uses for a dropped QRESYNC range, so a client retries rather than treating the STORE as rejected. */
+		/*
+		 * SS3.1.3's set MUST list every message that failed
+		 * UNCHANGEDSINCE, and per SS3.1.3's client guidance a message
+		 * absent from it is one the client believes was stored and
+		 * will never retry -- so a set known to be short is never
+		 * sent, and dropping the code entirely would say the same
+		 * thing (no MODIFIED means nothing failed). Refusing the
+		 * command is what every other variable-length list here does
+		 * on a failed grow; RFC 5530 SS3 UNAVAILABLE marks it
+		 * transient, the same code
+		 * session_handle_mbox_selected() uses for a dropped QRESYNC
+		 * range, so a client retries rather than treating the STORE
+		 * as rejected.
+		 */
 		if (incomplete) {
 			char	fail[64];
 
@@ -1097,7 +1368,10 @@ session_handle_mbox_result(struct session *s, struct i
 	s->store_modified_alloc_failed = 0;
 
 
-	/* RFC 7162 SS3.2.7: real EXPUNGE (not CLOSE, SS3.2.8 forbids it) with count>0 gets HIGHESTMODSEQ, once CONDSTORE-aware. */
+	/*
+	 * RFC 7162 SS3.2.7: real EXPUNGE (not CLOSE, forbidden by SS3.2.8) with
+	 * count>0 gets HIGHESTMODSEQ if CONDSTORE.
+	 */
 	if (was_expunging && !was_close && s->condstore_enabled &&
 	    res->count > 0) {
 		char	text[64];
@@ -1109,6 +1383,25 @@ session_handle_mbox_result(struct session *s, struct i
 		return;
 	}
 
+	/*
+	 * RFC 9051 SS6.4.5: "NO, fetch error: can't fetch that data". An
+	 * item the store could not produce was left out of the untagged
+	 * replies, so the command did not do what was asked, and SS7.1.1
+	 * makes a tagged OK a claim that it did. Untagged data already
+	 * sent stays valid, so the messages that worked are not withdrawn.
+	 * No RFC 5530 code fits every cause, so plain NO is honest.
+	 */
+	if (s->fetch_incomplete) {
+		char	text[96];
+
+		s->fetch_incomplete = 0;
+		snprintf(text, sizeof(text),
+		    "%s failed, some requested items could not be returned",
+		    cmdname);
+		session_reply(s, s->pending_tag, "NO", text);
+		return;
+	}
+
 	{
 		char	text[32];
 
blob - f163764dbe576e9f6320459fb57d8d8d868fb1e5
blob + 9adfc3af86fd43a4edbbee8c3ee5b0cacca4be55
--- src/utf8.c
+++ src/utf8.c
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  *
@@ -14,11 +16,21 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* utf8_mailbox_ok() enforces RFC 5198 SS2's UTF-8/C1-control/no-BOM requirements (3 of its 6 Net-Unicode items; NFC normalization and unassigned-code-point checks are deliberately unenforced, per imapd.8) via raw byte-range checks rather than decoding, since the well-formed-sequence ranges alone are what RFC 3629 SS4 requires. */
+/*
+ * utf8_mailbox_ok() enforces RFC 5198 SS2's UTF-8/C1-control/no-BOM
+ * requirements (3 of its 6 Net-Unicode items; NFC normalization and
+ * unassigned-code-point checks are deliberately unenforced, per imapd.8) via
+ * raw byte-range checks rather than decoding, since the well-formed-sequence
+ * ranges alone are what RFC 3629 SS4 requires.
+ */
 
 #include "utf8.h"
 
-/* Returns 1 if `name` is a NUL-terminated string whose encoding this server accepts as a mailbox name, 0 otherwise; other rules ("/", ".", "..", reserved imapd.* names, length) are each caller's own responsibility. */
+/*
+ * Returns 1 if `name` is a NUL-terminated string whose encoding this server
+ * accepts as a mailbox name, 0 otherwise; other rules ("/", ".", "..", reserved
+ * imapd.* names, length) are each caller's own responsibility.
+ */
 int
 utf8_mailbox_ok(const char *name)
 {
@@ -49,11 +61,19 @@ utf8_mailbox_ok(const char *name)
 		} else if (p[0] == 0xf4) {
 			need = 3; lo = 0x80; hi = 0x8f;	/* <= U+10FFFF */
 		} else {
-			/* 0x80-0xc1 (continuation or overlong lead) and 0xf5-0xff */
+			/*
+			 * 0x80-0xc1 (continuation or overlong lead) and
+			 * 0xf5-0xff
+			 */
 			return (0);
 		}
 
-		/* Reading p[1..3] can't run past the terminator: each byte is only reached after its predecessor tested inside 0x80-0xbf, so the walk always stops at the first bad byte, terminator included. */
+		/*
+		 * Reading p[1..3] can't run past the terminator: each byte is
+		 * only reached after its predecessor tested inside 0x80-0xbf,
+		 * so the walk always stops at the first bad byte, terminator
+		 * included.
+		 */
 		if (p[1] < lo || p[1] > hi)
 			return (0);
 		for (i = 2; i <= need; i++)
@@ -64,7 +84,12 @@ utf8_mailbox_ok(const char *name)
 		if (p[0] == 0xc2 && p[1] <= 0x9f)
 			return (0);
 
-		/* RFC 5198 SS2 item 5 bans a leading BOM; U+FEFF is refused anywhere in the name (stricter than the RFC, and imapd.8 says so) since a zero-width no-break space mid-name would make two mailboxes indistinguishable. */
+		/*
+		 * RFC 5198 SS2 item 5 bans a leading BOM; U+FEFF is refused
+		 * anywhere in the name (stricter than the RFC, and imapd.8 says
+		 * so) since a zero-width no-break space mid-name would make two
+		 * mailboxes indistinguishable.
+		 */
 		if (p[0] == 0xef && p[1] == 0xbb && p[2] == 0xbf)
 			return (0);
 
blob - 4d1f898ee071c990e1381e335363f9234bcc238e
blob + 55a0a1c34bc5f8d5174d4880637852c0cc9f5ec2
--- src/utf8.h
+++ src/utf8.h
@@ -1,3 +1,5 @@
+/*	$OpenIMAPD$	*/
+
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
  *
@@ -17,20 +19,11 @@
 #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.
- */
+/* The one UTF-8 predicate, shared by the listener's and the store's */
+/* mailbox-name validators. Those stay separate because the store does */
+/* not trust the listener, but well-formedness carries no policy, so both */
+/* call this rather than keeping a copy each. Depends on nothing, so */
+/* either side can include it. */
 
 int	 utf8_mailbox_ok(const char *);