Commit Diff


commit - e059bbcc10b86ee36a3b805c784b4057c0970bee
commit + 7d9334a7d40e0d8a8bbba80f1e1026a34ec35911
blob - a048b0269361225d8c0318f6c477b70062f00f9b
blob + 0ecebc52b885ae539520e05c47e61857f7d8be59
--- README.md
+++ README.md
@@ -6,15 +6,15 @@ A from-scratch IMAP4rev2 ([RFC 9051](https://www.rfc-e
 
 ## What it is
 
-- **Privilege-separated**, `smtpd`-style, across six processes: a root *parent* reads configuration and binds the listening sockets; unprivileged *listener* and *auth* children handle the network and credential checks; *keymgr* holds the TLS private key and performs every private-key operation on request, so the process terminating TLS never has the key in its address space; a per-connection *search-oracle* parses the `SEARCH` grammar, the largest attacker-reachable parser in the daemon — in a process with no descriptors and no filesystem; and a *store* child is forked per authenticated session, chroots into the mail spool, and drops privileges to that session's own user before ever touching a message. `pledge(2)`, `unveil(2)`, and `chroot(2)` enforce these boundaries, not just convention: *parent* is the only process that can pass a file descriptor at all, and *search-oracle* runs on bare `stdio`.
+- **Privilege-separated**, `smtpd`-style, across six processes: a root *parent* reads configuration and binds the listening sockets; unprivileged *listener* and *auth* children handle the network and credential checks; *keymgr* holds the TLS private key and performs every private-key operation on request, so the process terminating TLS never has the key in its address space; a *parser* child, spun up by a *store* child on demand and retired after a spell idle, does every parse of attacker-reachable content — `SEARCH`'s grammar and `FETCH`'s `ENVELOPE`/`BODYSTRUCTURE`/header-field parsing alike — confined to `pledge(2)` `"stdio recvfd"`: it opens nothing itself, reading each message over a descriptor the store child passes it already open; and a *store* child is forked per authenticated account, shared by all of that account's sessions, chroots into the mail spool, and drops privileges to that account's own user before ever touching a message. `pledge(2)`, `unveil(2)`, and `chroot(2)` enforce these boundaries, not just convention: *parent* is the only process that can pass a file descriptor at all.
 - **Storage**: stock maildir format (`tmp/`/`new/`/`cur/`, atomic delivery via `rename(2)`), readable with `ls` and `grep`, and natively understood by `smtpd(8)`'s own `maildir` delivery action. IMAP's extra bookkeeping (UIDs, UIDVALIDITY, per-message mod-sequences, keywords) lives in a small, `flock(2)`-guarded, line-oriented index file per mailbox, plain colon-delimited text, not a database.
 - **Transport**: STARTTLS on port 143 and implicit TLS on port 993 ([RFC 8314](https://www.rfc-editor.org/rfc/rfc8314)), via `libtls`. `AUTH=PLAIN` only, refused before TLS is established.
 
 ## Protocol coverage
 
-`CAPABILITY`, `STARTTLS`, `AUTHENTICATE`, `ID`, `ENABLE`, `SELECT`, `EXAMINE`, `CREATE`, `DELETE`, `RENAME`, `LIST`, `LSUB`, `NAMESPACE`, `STATUS`, `FETCH` (including `ENVELOPE`, `BODYSTRUCTURE`, and MIME-part-addressed `BODY[<part>]`/`BODY.PEEK[<part>]`), `STORE`, `SEARCH`, `APPEND`, `COPY`, `MOVE`, `EXPUNGE`, `UNSELECT`, `CLOSE`, the `UID`-prefixed form of every command that supports it, `IDLE` (with the cross-session caveat noted below), and the [RFC 7162](https://www.rfc-editor.org/rfc/rfc7162) `CONDSTORE`/`QRESYNC` extensions.
+`CAPABILITY`, `STARTTLS`, `AUTHENTICATE`, `ID`, `ENABLE`, `SELECT`, `EXAMINE`, `CREATE`, `DELETE`, `RENAME`, `LIST`, `LSUB`, `NAMESPACE`, `STATUS`, `FETCH` (including `ENVELOPE`, `BODYSTRUCTURE`, and MIME-part-addressed `BODY[<part>]`/`BODY.PEEK[<part>]`), `STORE`, `SEARCH`, `APPEND`, `COPY`, `MOVE`, `EXPUNGE`, `UNSELECT`, `CLOSE`, `SUBSCRIBE`, `UNSUBSCRIBE` (including LIST's [RFC 9051](https://www.rfc-editor.org/rfc/rfc9051) section 6.3.9.1 `SUBSCRIBED` selection option), the `UID`-prefixed form of every command that supports it, `IDLE` (with the cross-session caveat noted below), and the [RFC 7162](https://www.rfc-editor.org/rfc/rfc7162) `CONDSTORE`/`QRESYNC` extensions.
 
-`SUBSCRIBE`, `UNSUBSCRIBE`, and ACL/shared-mailbox support are deliberately left out.
+ACL/shared-mailbox support is deliberately left out.
 
 **`IDLE` is a poll, not a kernel-driven push.** An `IDLE`ing session rechecks its selected mailbox every `idle poll` seconds (default 5, see `imapd.conf`), so new mail, whether delivered by an external MTA or by another IMAP session, is reported within one interval rather than instantly. Most polls are two `stat(2)` calls and no lock: the store child only re-reads the index and streams UIDs when the mailbox directory or `new/` has actually been touched. Setting `idle poll 0` disables polling entirely, which restores the earlier behaviour where an `IDLE`ing session saw nothing until it sent `DONE`.
 
@@ -76,7 +76,7 @@ Beyond the deliberate protocol-scope decisions covered
 
 - If the listener or auth process exits unexpectedly after startup, it is not automatically restarted. Recovery is `rcctl restart imapd`. See `imapd(8)`.
 
-`SIGHUP` reloads `spool`, `attachment max`, `idle poll`, `startups`, and the TLS certificate/key without dropping connected sessions, `listen on` and `credentials` changes still require a restart. See `imapd(8)`.
+`SIGHUP` reloads `spool`, `append max`, `attachment max`, `account sessions`, `connections max`, `idle poll`, `lock timeout`, `login grace`, `startups`, and the TLS certificate/key without dropping connected sessions, `listen on` and `credentials` changes still require a restart. See `imapd(8)`.
 
 IPv6 is supported (`listen on ::` or `listen on *` for dual-stack) but not the default, see `imapd(8)`'s `listen on` directive.
 
blob - df2d90ea0e7fe4d18d6356afd2387738fd7053cb
blob + 275b0f615d50095ed9373998a6bc4fa669c369b6
--- contrib/imapduser.8
+++ contrib/imapduser.8
@@ -2,7 +2,7 @@
 .\"
 .\" Written for the OpenIMAPD project. Public domain / no rights reserved.
 .\"
-.Dd $Mdocdate: September 18 2026 $
+.Dd $Mdocdate: September 25 2026 $
 .Dt IMAPDUSER 8
 .Os
 .Sh NAME
@@ -170,4 +170,4 @@ Default spool root; see
 .Xr imapd 8
 .Sh HISTORY
 .Nm
-was written for the OpenIMAPD project.
\ No newline at end of file
+was written for the OpenIMAPD project.
blob - d2d44336dd52fce32311a046d5ceb0a96265e1fc
blob + d5bf488c9c64067329371bc773787b88e8e5e738
--- src/Makefile
+++ src/Makefile
@@ -11,9 +11,9 @@ SRCS=		main.c parent.c log.c imsgev.c parse.y utf8.c m
 		search_cmd.c store_cmd.c store_ipc.c \
 		auth.c \
 		keymgr.c \
-		search_oracle.c \
+		parser.c \
 		store.c index.c mime.c envelope.c mbox_fetch.c mbox_search.c \
-		mbox_store.c mbox_manage.c mbox_copy.c
+		mbox_store.c mbox_manage.c mbox_copy.c search_match.c
 
 BINDIR=		/usr/local/sbin
 MANDIR=		/usr/local/man/man
blob - 0b06f37a593afdff9c8300f25cf94fe6ea57708b
blob + b6d33d4ccde5be554ed9a3d89cfe51ff2455903e
--- src/append_cmd.c
+++ src/append_cmd.c
@@ -16,7 +16,6 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* append_cmd.c: APPEND literal upload, async IMSG_MBOX_APPENDED completion. */
 
 #include <sys/types.h>
 #include <sys/queue.h>
@@ -42,7 +41,7 @@
 #include "listener.h"
 #include "mboxname.h"
 
-/* RFC 9051 SS9 date-time via sscanf(3); timegm(3) normalizes bad dates. */
+/* RFC 9051 SS9 date-time */
 int
 parse_date_time(const char *s, int64_t *out)
 {
@@ -85,11 +84,7 @@ 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.
-	 */
+	/* refuse a zone offset that makes the time negative */
 	if ((int64_t)t - zoff < 0)
 		return (-1);
 
@@ -97,7 +92,6 @@ parse_date_time(const char *s, int64_t *out)
 	return (0);
 }
 
-/* Result struct for parse_append_args(), avoids many out-parameters. */
 struct append_parsed {
 	char		mailbox[MBOX_NAME_MAX];
 	uint32_t	sysflags;
@@ -108,7 +102,7 @@ struct append_parsed {
 	int		litnonsync;
 };
 
-/* RFC 9051 SS6.3.12 append grammar; reuses parse_store_flags() for flags. */
+/* RFC 9051 SS6.3.12 APPEND */
 int
 parse_append_args(char *args, struct append_parsed *out, const char **errmsg)
 {
@@ -122,7 +116,6 @@ parse_append_args(char *args, struct append_parsed *ou
 		return (-1);
 	}
 
-	/* one parser for every mailbox argument; see mailbox_cmd.c */
 	if (parse_mailbox_name(&p, out->mailbox, sizeof(out->mailbox),
 	    errmsg) == -1)
 		return (-1);
@@ -215,12 +208,7 @@ 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.
-		 */
+		/* strtoull(3) accepts a sign */
 		if (digitsbuf[0] < '0' || digitsbuf[0] > '9') {
 			*errmsg = "malformed literal octet count";
 			return (-1);
@@ -234,10 +222,7 @@ 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 4096B,
-			 * BAD not NO.
-			 */
+			/* RFC 9051 SS4.3 caps a non-synchronizing literal */
 			*errmsg = "non-synchronizing literal exceeds RFC "
 			    "9051 SS4.3's 4096-octet limit, use a "
 			    "synchronizing literal instead";
@@ -253,7 +238,6 @@ parse_append_args(char *args, struct append_parsed *ou
 	return (0);
 }
 
-/* Parses the literal announcement, starts the store's file, reads the rest. */
 int
 cmd_append(struct session *s, const char *tag, char *args)
 {
@@ -275,10 +259,7 @@ cmd_append(struct session *s, const char *tag, char *a
 	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.
-		 */
+		/* RFC 5530 LIMIT, stating the number as RFC 9051 SS7.1 does */
 		(void)snprintf(text, sizeof(text), "[LIMIT] message exceeds "
 		    "this server's %llu octet limit",
 		    (unsigned long long)listener_append_max);
@@ -287,10 +268,6 @@ cmd_append(struct session *s, const char *tag, char *a
 	}
 
 	if (s->store_iev == NULL) {
-		/*
-		 * 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");
@@ -313,17 +290,13 @@ cmd_append(struct session *s, const char *tag, char *a
 	req.msglen = parsed.litlen;
 	s->append_prev_state = s->state;
 
-	/* 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.
-	 */
+	/* the literal goes to the store in pieces as it arrives */
 	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);
@@ -335,12 +308,7 @@ cmd_append(struct session *s, const char *tag, char *a
 	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: "+" only for synchronizing literals */
 	if (!parsed.litnonsync) {
 		static const char cont[] = "+ Ready for literal data\r\n";
 
@@ -350,7 +318,6 @@ cmd_append(struct session *s, const char *tag, char *a
 	return (1);
 }
 
-/* Literal and its CRLF are in: tells the store to commit the message. */
 int
 session_finish_append(struct session *s)
 {
@@ -364,11 +331,7 @@ session_finish_append(struct session *s)
 
 	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.
-	 */
+	/* a failed compose must not leave the session busy */
 	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",
@@ -382,7 +345,6 @@ session_finish_append(struct session *s)
 	return (1);
 }
 
-/* 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)
@@ -394,10 +356,7 @@ 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",
-			    /*
-			     * SS6.3.12: reports why, not a promise CREATE would
-			     * help
-			     */
+			    /* RFC 9051 SS6.3.12 */
 			    "[TRYCREATE] no such mailbox");
 		else if (res->error == MBOX_OP_ERR_BUSY)
 			session_reply(s, s->pending_tag, "NO",
@@ -408,10 +367,7 @@ session_handle_mbox_appended(struct session *s,
 		return;
 	}
 
-	/*
-	 * INBOX compared case-insensitively (SS5.1); other names
-	 * case-sensitively.
-	 */
+	/* RFC 9051 SS5.1: INBOX is case-insensitive */
 	if (mailbox_name_is_inbox(s->append_mailbox) &&
 	    mailbox_name_is_inbox(s->selected_mailbox))
 		appended_to_selected = 1;
blob - 9e9fdeab180b7849b4578bc3b08810eb0bdc06be
blob + 15fc87a7f400101a1cce03f1c440bc849107d673
--- src/auth.c
+++ src/auth.c
@@ -16,8 +16,6 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* auth.c: credential verification, AUTHENTICATE PLAIN vs flat cred file. */
-
 #include <sys/types.h>
 #include <sys/stat.h>
 
@@ -44,7 +42,6 @@ struct cred_entry {
 };
 
 static struct imsgev	 iev_listener;
-/* fd 3, alive for the process's lifetime */
 static struct imsgev	 iev_parent;
 static char		 cred_file_basename[256];
 
@@ -55,26 +52,15 @@ static void	 auth_verify(struct imsg_auth_request *,
 static void	 auth_dispatch(int, short, void *);
 static void	 auth_dispatch_parent(int, short, void *);
 
-/*
- * 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).
- */
+/* refuse a replayed request for a resolved session_id */
+/* sshd's MaxAuthTries default */
 #define AUTH_MAX_TRIES	6
 static unsigned int	 auth_failures;
 
 #define AUTH_RESOLVED_MAX	256
 static uint32_t	 auth_resolved[AUTH_RESOLVED_MAX];
-static size_t	 auth_resolved_next;	/* ring cursor */
-static int	 auth_resolved_full;	/* 1 once the ring has wrapped once */
+static size_t	 auth_resolved_next;
+static int	 auth_resolved_full;
 
 static int
 auth_session_already_resolved(uint32_t sid)
@@ -112,16 +98,8 @@ auth_main(void)
 	char			 chrootdir[1024];
 	ssize_t			 n;
 
-	/*
-	 * fd-passing allowed here for IMSG_SETUP_PEER below; see
-	 * imsgev_ibuf_init()
-	 */
 	imsgev_ibuf_init(&ibuf3, 3);
 
-	/*
-	 * IMSG_AUTH_INIT read first: cred_file needed before chroot() is
-	 * computed
-	 */
 	for (;;) {
 		if ((n = imsgbuf_get(&ibuf3, &imsg)) == -1)
 			fatal("imsgbuf_get");
@@ -137,32 +115,17 @@ 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() does not NUL-terminate */
 	init.cred_file[sizeof(init.cred_file) - 1] = '\0';
 	imsg_free(&imsg);
 
-	/* auth's own daemon-user identity; distinct from listener's _imapd. */
 	if ((pw = getpwnam("_imapauth")) == NULL)
 		fatalx("getpwnam _imapauth: no such user "
 		    "(expected, not yet provisioned by an install script)");
 
-	/*
-	 * 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.
-	 */
 	if (init.cred_file[0] != '/')
 		fatalx("cred_file must be an absolute path: %s",
 		    init.cred_file);
@@ -188,16 +151,9 @@ auth_main(void)
 	    setresuid(pw->pw_uid, pw->pw_uid, pw->pw_uid) == -1)
 		fatal("cannot drop privileges to _imapauth");
 
-	/* 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();
@@ -205,7 +161,6 @@ auth_main(void)
 
 	imsgev_init_from_ibuf(&iev_parent, &ibuf3, auth_dispatch_parent, NULL);
 
-	/* unveil() path is relative to the chroot above: "/" + basename. */
 	{
 		char unveil_path[512];
 
@@ -217,13 +172,7 @@ 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 or sendfd: the one peer fd came before this */
 #ifdef __OpenBSD__
 	if (pledge("stdio rpath", NULL) == -1)
 		fatal("pledge");
@@ -233,7 +182,6 @@ auth_main(void)
 	fatalx("auth: exited event loop");
 }
 
-/* EV_WRITE must be handled: imsg_compose() queues, imsgbuf_write() sends */
 static void
 auth_dispatch(int fd, short event, void *arg)
 {
@@ -250,13 +198,6 @@ auth_dispatch(int fd, short event, void *arg)
 		if ((n = imsgbuf_read(&iev->ibuf)) == -1)
 			fatal("imsgbuf_read");
 		if (n == 0) {
-			/*
-			 * 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);
@@ -278,10 +219,7 @@ 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() does not NUL-terminate */
 			req.username[sizeof(req.username) - 1] = '\0';
 			req.password[sizeof(req.password) - 1] = '\0';
 
@@ -317,12 +255,6 @@ 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.
-				 */
 				(void)strlcpy(cred.maildir, res.maildir,
 				    sizeof(cred.maildir));
 				if (imsg_compose(&iev_parent.ibuf,
@@ -343,7 +275,6 @@ auth_dispatch(int fd, short event, void *arg)
 	(void)fd;
 }
 
-/* parent never sends auth anything post-boot; flushes CRED writes, sees EOF */
 static void
 auth_dispatch_parent(int fd, short event, void *arg)
 {
@@ -372,6 +303,14 @@ auth_dispatch_parent(int fd, short event, void *arg)
 		if (n == 0)
 			break;
 
+		if (imsg_get_type(&imsg) == IMSG_AUTH_EXIT) {
+			imsg_free(&imsg);
+			/* the listener must have its answer before we go */
+			if (imsgbuf_flush(&iev_listener.ibuf) == -1)
+				log_warn("imsgbuf_flush IMSG_AUTH_RESULT");
+			log_debug("auth-worker: login granted, exiting");
+			exit(0);
+		}
 		log_debug("auth_dispatch_parent: unhandled %d",
 		    imsg_get_type(&imsg));
 		imsg_free(&imsg);
@@ -380,11 +319,7 @@ 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.
- */
+/* network usernames reach syslog only through here */
 static void
 auth_safe_name(const char *in, char *out, size_t outsize)
 {
@@ -398,7 +333,7 @@ auth_safe_name(const char *in, char *out, size_t outsi
 	out[i] = '\0';
 }
 
-/* calls crypt_checkpass() with hash NULL on unknown user, avoids timing leak */
+/* unknown users still pay for crypt_checkpass(), against timing */
 static void
 auth_verify(struct imsg_auth_request *req, struct imsg_auth_result *res)
 {
@@ -407,11 +342,6 @@ 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.
-	 */
 	if (auth_failures >= AUTH_MAX_TRIES) {
 		char	 overname[AUTH_USERNAME_MAX];
 
@@ -438,13 +368,7 @@ 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.
-	 */
+	/* every outcome is logged, for fail2ban-style tools */
 	auth_safe_name(req->username, safename, sizeof(safename));
 	if (res->ok)
 		log_info("session %u: authentication succeeded for \"%s\" "
@@ -457,14 +381,6 @@ 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.
- */
 static int
 cred_file_secure(const struct stat *st)
 {
@@ -475,7 +391,6 @@ cred_file_secure(const struct stat *st)
 	return (1);
 }
 
-/* linear scan of "username:passwordhash:uid:gid:maildir" lines */
 static int
 cred_lookup(const char *path, const char *username, struct cred_entry *out)
 {
@@ -488,15 +403,6 @@ 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.
-	 */
 	{
 		struct stat	 st;
 
@@ -525,11 +431,7 @@ 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) splits an over-long line */
 		if (strchr(line, '\n') == NULL &&
 		    strlen(line) == sizeof(line) - 1) {
 			log_warnx("%s: over-long line, refusing to parse the "
@@ -556,32 +458,15 @@ cred_lookup(const char *path, const char *username, st
 		if (strcmp(fields[0], username) != 0)
 			continue;
 
-		/*
-		 * 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.
-		 */
+		/* crypt_checkpass(3) passes an empty hash and password */
 		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 "-1", and uid 0 is root */
 		if (fields[2][0] < '0' || fields[2][0] > '9' ||
 		    fields[3][0] < '0' || fields[3][0] > '9')
 			continue;
@@ -604,12 +489,7 @@ 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 credential records */
 	explicit_bzero(line, sizeof(line));
 	fclose(fp);
 	return (found ? 0 : -1);
blob - f40fc88dc9f436f73ddc512da25a07fc77ded283
blob + d12b562475f766678ff91628fbb404cb544a5d9c
--- src/auth_cmd.c
+++ src/auth_cmd.c
@@ -16,7 +16,6 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* auth_cmd.c: CAPABILITY/NOOP/LOGOUT/ID/LOGIN/AUTH/STARTTLS/ENABLE handlers. */
 
 #include <sys/types.h>
 #include <sys/queue.h>
@@ -51,7 +50,6 @@
 int
 cmd_capability(struct session *s, const char *tag, char *args)
 {
-	/* RFC 9051: "Arguments: none", extra args ignored, not rejected */
 	(void)args;
 
 	session_untagged(s, s->tls_active ?
@@ -87,7 +85,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 logged, not parsed; replies NIL */
+	/* RFC 2971 SS3.1: logged, not parsed; the reply is NIL */
 	log_debug("session %u: ID params: %s", s->id,
 	    args != NULL ? args : "(none)");
 	session_untagged(s, "ID NIL");
@@ -96,7 +94,6 @@ cmd_id(struct session *s, const char *tag, char *args)
 }
 
 
-/* LOGIN permanently disabled, matching LOGINDISABLED in CAPABILITY strings. */
 int
 cmd_login(struct session *s, const char *tag, char *args)
 {
@@ -112,7 +109,6 @@ int
 cmd_starttls(struct session *s, const char *tag, char *args)
 {
 	if (args != NULL) {
-		/* RFC 9051 SS6.2.1 Result: "BAD - ... arguments invalid". */
 		session_reply(s, tag, "BAD", "STARTTLS takes no arguments");
 		return (1);
 	}
@@ -122,10 +118,7 @@ cmd_starttls(struct session *s, const char *tag, char 
 		return (1);
 	}
 	if (listener_tls_ctx == NULL) {
-		/*
-		 * RFC 9051 SS6.2.1 NO + RFC 5530 UNAVAILABLE: cert/key load
-		 * failed at boot
-		 */
+		/* RFC 5530 UNAVAILABLE: no certificate or key at boot */
 		session_reply(s, tag, "NO",
 		    "[UNAVAILABLE] TLS negotiation unavailable");
 		return (1);
@@ -134,7 +127,7 @@ cmd_starttls(struct session *s, const char *tag, char 
 	/* precedes TLS */
 	session_reply(s, tag, "OK", "Begin TLS negotiation now");
 
-	/* command-injection mitigation: discard buffered plaintext */
+	/* discard buffered plaintext, against command injection */
 	s->inbuflen = 0;
 
 	session_tls_start(s);
@@ -144,8 +137,7 @@ cmd_starttls(struct session *s, const char *tag, char 
 /* RFC 4616 SS2: authzid/authcid/passwd up to 255 octets + 2 NULs = 767 */
 #define SASL_PLAIN_MAX	768
 
-/* 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 */
+/* as auth.c's auth_safe_name(): '?' for anything unprintable */
 static void
 session_set_user(struct session *s, const unsigned char *in,
     size_t inlen)
@@ -160,7 +152,6 @@ session_set_user(struct session *s, const unsigned cha
 	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)
@@ -213,10 +204,7 @@ sasl_plain_finish(struct session *s, const char *tag, 
 		explicit_bzero(raw, sizeof(raw));
 		return (1);
 	}
-	/*
-	 * too big for imsg_auth_request's fixed fields; generic NO avoids an
-	 * oracle
-	 */
+	/* a generic NO, so no oracle */
 	if (authcidlen >= AUTH_USERNAME_MAX || passwdlen >= AUTH_PASSWORD_MAX) {
 		session_reply(s, tag, "NO",
 		    "[AUTHENTICATIONFAILED] authentication failed");
@@ -236,11 +224,6 @@ sasl_plain_finish(struct session *s, const char *tag, 
 		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
 		return (1);
 	}
-	/*
-	 * 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");
@@ -259,11 +242,9 @@ sasl_plain_finish(struct session *s, const char *tag, 
 	return (1);
 }
 
-/* reply to "+ " continuation after "AUTHENTICATE PLAIN" (cmd_authenticate) */
 int
 session_handle_auth_continuation(struct session *s, const char *line)
 {
-	/* next line back to ordinary tagged command either way */
 	s->auth_cont = 0;
 
 	/* RFC 9051 SS6.2.2: lone "*" cancels exchange */
@@ -276,7 +257,6 @@ session_handle_auth_continuation(struct session *s, co
 	return sasl_plain_finish(s, s->pending_tag, line, 0);
 }
 
-/* reply to our "+ idling" continuation (SS6.3.13); only "DONE" ends IDLE */
 int
 session_handle_idle_continuation(struct session *s, const char *line)
 {
@@ -301,7 +281,6 @@ cmd_authenticate(struct session *s, const char *tag, c
 	const char	*initial;
 
 	if (args == NULL) {
-		/* RFC 9051 SS6.2.2 Result: "BAD - ... arguments invalid". */
 		session_reply(s, tag, "BAD", "Missing SASL mechanism name");
 		return (1);
 	}
@@ -325,24 +304,17 @@ cmd_authenticate(struct session *s, const char *tag, c
 		return (1);
 	}
 
-	/* only implements PLAIN, matching CAPABILITY_POST_TLS */
 	if (strcasecmp(mech, "PLAIN") != 0) {
 		session_reply(s, tag, "NO",
 		    "authentication mechanism not available");
 		return (1);
 	}
 
-	/* 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 */
 	if (strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
 	    sizeof(s->pending_tag)) {
 		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
@@ -353,7 +325,6 @@ cmd_authenticate(struct session *s, const char *tag, c
 	return (1);
 }
 
-/* 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)
 {
@@ -363,7 +334,7 @@ stub_not_implemented(struct session *s, const char *ta
 	return (1);
 }
 
-/* RFC 9051 SS6.3.1 ENABLE: unknown exts ignored; ENABLED lists only new ones */
+/* RFC 9051 SS6.3.1 ENABLE */
 int
 cmd_enable(struct session *s, const char *tag, char *args)
 {
@@ -390,7 +361,6 @@ cmd_enable(struct session *s, const char *tag, char *a
 			if (!s->condstore_enabled)
 				newly_condstore = 1;
 		}
-		/* anything else: unadvertised extension, SS6.3.1 says ignore */
 	}
 
 	if (newly_condstore || newly_qresync)
blob - 37d8cbb635a00549cdc76e86d82a9256c9d165a3
blob + 724a37ec1dd67f91057de94d728c98a17534e5f5
--- src/envelope.c
+++ src/envelope.c
@@ -16,7 +16,6 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* envelope.c, the ENVELOPE and BODYSTRUCTURE FETCH response builders. */
 
 #include <sys/types.h>
 #include <sys/file.h>
@@ -57,7 +56,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 NULL, else quoted+escaped. */
+/* RFC 9051 nstring */
 int
 envbuf_append_nstring(char *buf, size_t bufsize, size_t *outlen,
     const char *val, size_t vallen)
@@ -73,10 +72,7 @@ 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 field.
-		 */
+		/* RFC 9051 SS4.3: substituted, not dropped */
 		if (c == '\0' || c == '\r' || c == '\n')
 			c = ' ';
 		if ((c == '"' || c == '\\') &&
@@ -90,21 +86,16 @@ envbuf_append_nstring(char *buf, size_t bufsize, size_
 	return (0);
 
 fail:
-	/*
-	 * All-or-nothing: a partial append leaves an unterminated quoted
-	 * string.
-	 */
+	/* all or nothing */
 	*outlen = save;
 	return (-1);
 }
 
-/* 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)
+address_split(const char *tok, size_t toklen, const char **name_out,
+    size_t *namelen_out, const char **mailbox_out, size_t *mailboxlen_out,
+    const char **host_out, size_t *hostlen_out)
 {
-	char		 addrbuf[1024];
-	size_t		 addrlen = 0;
 	const char	*name = NULL;
 	size_t		 namelen = 0;
 	const char	*spec;
@@ -172,10 +163,6 @@ envbuf_append_one_address(char *buf, size_t bufsize, s
 
 			if (displen >= 2 && disp[0] == '"' &&
 			    disp[displen - 1] == '"') {
-				/*
-				 * emission loop below re-escapes for the wire;
-				 * no unescape pass needed
-				 */
 				disp++;
 				displen -= 2;
 			}
@@ -223,16 +210,36 @@ envbuf_append_one_address(char *buf, size_t bufsize, s
 	host = spec + at + 1;
 	hostlen = speclen - at - 1;
 
-	/*
-	 * strip quotes from a quoted local-part (unescaped "@" inside not
-	 * handled)
-	 */
+	/* unquote a quoted local-part */
 	if (mailboxlen >= 2 && mailbox[0] == '"' &&
 	    mailbox[mailboxlen - 1] == '"') {
 		mailbox++;
 		mailboxlen -= 2;
 	}
 
+	*name_out = name;
+	*namelen_out = namelen;
+	*mailbox_out = mailbox;
+	*mailboxlen_out = mailboxlen;
+	*host_out = host;
+	*hostlen_out = hostlen;
+	return (0);
+}
+
+/* RFC 9051 SS9 address; no groups */
+int
+envbuf_append_one_address(char *buf, size_t bufsize, size_t *outlen,
+    const char *tok, size_t toklen)
+{
+	char		 addrbuf[1024];
+	size_t		 addrlen = 0;
+	const char	*name, *mailbox, *host;
+	size_t		 namelen, mailboxlen, hostlen;
+
+	if (address_split(tok, toklen, &name, &namelen, &mailbox, &mailboxlen,
+	    &host, &hostlen) == -1)
+		return (-1);
+
 	if (envbuf_append(addrbuf, sizeof(addrbuf), &addrlen, "(", 1) == -1)
 		return (-1);
 
@@ -249,6 +256,9 @@ envbuf_append_one_address(char *buf, size_t bufsize, s
 				j++;
 				c = name[j];
 			}
+			/* substituted as in envbuf_append_nstring() */
+			if (c == '\0' || c == '\r' || c == '\n')
+				c = ' ';
 			if ((c == '"' || c == '\\') &&
 			    envbuf_append(addrbuf, sizeof(addrbuf), &addrlen,
 			    "\\", 1) == -1)
@@ -280,39 +290,15 @@ 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.
-	 */
 	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.
- */
 int
-envbuf_append_address_list(char *buf, size_t bufsize, size_t *outlen,
-    const char *val, size_t vallen)
+address_list_next(const char *val, size_t vallen, size_t *pos,
+    const char **tok_out, size_t *toklen_out)
 {
-	size_t	 save = *outlen;
-	size_t	 i = 0;
-	int	 any = 0;
+	size_t	 i = *pos;
 
-	while (vallen > 0 && (val[0] == ' ' || val[0] == '\t')) {
-		val++;
-		vallen--;
-	}
-	while (vallen > 0 && (val[vallen - 1] == ' ' ||
-	    val[vallen - 1] == '\t'))
-		vallen--;
-
-	if (vallen == 0)
-		return (envbuf_append_str(buf, bufsize, outlen, "NIL"));
-
-	if (envbuf_append(buf, bufsize, outlen, "(", 1) == -1)
-		return (-1);
-
 	while (i < vallen) {
 		size_t	 tok_start;
 		size_t	 tok_len;
@@ -341,18 +327,46 @@ 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.
-			 */
-			if (envbuf_append_one_address(buf, bufsize, outlen,
-			    val + tok_start, tok_len) == 0)
-				any = 1;
+			*tok_out = val + tok_start;
+			*toklen_out = tok_len;
+			*pos = i;
+			return (1);
 		}
 	}
+	*pos = i;
+	return (0);
+}
 
+/* RFC 9051 SS7.5.2 address list, or NIL */
+int
+envbuf_append_address_list(char *buf, size_t bufsize, size_t *outlen,
+    const char *val, size_t vallen)
+{
+	const char	*tok;
+	size_t		 save = *outlen;
+	size_t		 i = 0, toklen;
+	int		 any = 0;
+
+	while (vallen > 0 && (val[0] == ' ' || val[0] == '\t')) {
+		val++;
+		vallen--;
+	}
+	while (vallen > 0 && (val[vallen - 1] == ' ' ||
+	    val[vallen - 1] == '\t'))
+		vallen--;
+
+	if (vallen == 0)
+		return (envbuf_append_str(buf, bufsize, outlen, "NIL"));
+
+	if (envbuf_append(buf, bufsize, outlen, "(", 1) == -1)
+		return (-1);
+
+	while (address_list_next(val, vallen, &i, &tok, &toklen) == 1) {
+		if (envbuf_append_one_address(buf, bufsize, outlen, tok,
+		    toklen) == 0)
+			any = 1;
+	}
+
 	if (!any) {
 		*outlen = save;
 		return (envbuf_append_str(buf, bufsize, outlen, "NIL"));
@@ -360,7 +374,6 @@ envbuf_append_address_list(char *buf, size_t bufsize, 
 	return (envbuf_append(buf, bufsize, outlen, ")", 1));
 }
 
-/* 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)
@@ -378,12 +391,9 @@ 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.
- */
+/* RFC 9051 SS7.5.2 ENVELOPE; Sender and Reply-To default to From */
 int
-build_envelope(int dfd, const char *basename, char **buf_out,
+build_envelope(int fd, const char *basename, char **buf_out,
     uint32_t *len_out)
 {
 	char		*hdrbuf = NULL;
@@ -396,7 +406,7 @@ build_envelope(int dfd, const char *basename, char **b
 	*buf_out = NULL;
 	*len_out = 0;
 
-	if (read_message_header(dfd, basename, &hdrbuf, &hdrlen) == -1)
+	if (read_header_from_fd(fd, basename, &hdrbuf, &hdrlen) == -1)
 		return (-1);
 
 	if (envbuf_append(out, sizeof(out), &outlen, "(", 1) == -1)
@@ -528,11 +538,7 @@ 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.
- */
+/* RFC 9051 SS7.5.2 BODYSTRUCTURE; no extension data */
 int
 build_body_structure(int depth, int *nparts_used, const char *hdr,
     size_t hdrlen, const char *body, size_t bodylen, char *out,
@@ -572,10 +578,7 @@ 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 0/0
-			 */
+			/* RFC 2046 SS5.1.1: an empty part is legal */
 			if (plen == 0)
 				phdrend = 0;
 			else if (find_header_body_split(pbuf, plen,
@@ -597,7 +600,7 @@ build_body_structure(int depth, int *nparts_used, cons
 	if (strcasecmp(type, "MESSAGE") == 0 &&
 	    (strcasecmp(subtype, "RFC822") == 0 ||
 	    strcasecmp(subtype, "GLOBAL") == 0))
-		/* scoped out, see BODYSTRUCTURE comment above */
+		/* MESSAGE/RFC822 and MESSAGE/GLOBAL are not supported */
 		return (-1);
 
 	{
@@ -708,12 +711,8 @@ 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.
- */
 int
-build_bodystructure(int dfd, const char *basename, char **buf_out,
+build_bodystructure(int fd, const char *basename, char **buf_out,
     uint32_t *len_out)
 {
 	char		*wholebuf = NULL;
@@ -726,7 +725,7 @@ build_bodystructure(int dfd, const char *basename, cha
 	*buf_out = NULL;
 	*len_out = 0;
 
-	if (read_message_body(dfd, basename, 0, bodystructure_read_max,
+	if (read_body_from_fd(fd, basename, 0, bodystructure_read_max,
 	    "BODYSTRUCTURE", &wholebuf, &wholelen) == -1)
 		return (-1);
 	if (wholelen == 0 ||
blob - b3a8877617850b49d567d824e44dfc14c5f6f6ca
blob + 024c85828cc7b50984c280f0be02c9abad1a68af
--- src/fetch_cmd.c
+++ src/fetch_cmd.c
@@ -16,8 +16,6 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* FETCH: attribute/section-spec parsing and response building. */
-
 #include <sys/types.h>
 #include <sys/queue.h>
 #include <sys/socket.h>
@@ -59,11 +57,6 @@ 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.
- */
 static int
 parse_one_seq_range(const char *tok, struct seq_range *r)
 {
@@ -108,13 +101,7 @@ 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.
- */
+/* RFC 9051 SS9 sequence-set */
 int
 parse_sequence_set(const char *text, struct seq_range ranges[SEQSET_MAX_RANGES],
     uint32_t *nranges, const char **errmsg)
@@ -162,7 +149,7 @@ parse_sequence_set(const char *text, struct seq_range 
 	return (0);
 }
 
-/* like strtok_r(); space isn't a delim inside an unclosed '[' or '(' (SS9) */
+/* a space inside an unclosed "[" or "(" is not a delimiter */
 static char *
 fetch_att_tok(char *str, char **savep)
 {
@@ -196,7 +183,7 @@ fetch_att_tok(char *str, char **savep)
 	return (start);
 }
 
-/* parses HEADER.FIELDS[.NOT] body (SS9); -1 is BAD, not a silent drop */
+/* RFC 9051 SS9 HEADER.FIELDS[.NOT]; -1 is BAD */
 int
 parse_header_fields_att(const char *inner, int *not_out, char *fields_out,
     size_t fields_outsize)
@@ -253,7 +240,7 @@ parse_header_fields_att(const char *inner, int *not_ou
 	return (0);
 }
 
-/* SS6.4.5.1 section-part check; string still reaches parse_section_part() */
+/* RFC 9051 SS6.4.5.1 section-part */
 int
 section_part_valid(const char *s)
 {
@@ -281,7 +268,7 @@ section_part_valid(const char *s)
 	return (1);
 }
 
-/* SS6.4.5 "<start.count>"; count 0 handled by partial_range() */
+/* RFC 9051 SS6.4.5 <start.count> */
 int
 parse_partial_suffix(const char *s, int *has_partial_out,
     uint32_t *start_out, uint32_t *count_out)
@@ -322,13 +309,6 @@ parse_partial_suffix(const char *s, int *has_partial_o
 	return (0);
 }
 
-/*
- * generic BODY.PEEK[...] tok (already known not to be HEADER.FIELDS): [],
- * [TEXT], or [<section-part>], optional <<start.count>> (SS6.4.5); updates
- * *attrs_inout/section_part_out/partial-range out-params. Returns 1 on success,
- * 0 if silently degraded (*degraded_out set, same lenient skip as other
- * unsupported forms), -1 on a hard parse error (*errmsg set).
- */
 static int
 parse_body_peek_section_tok(const char *tok, uint32_t *attrs_inout,
     char *section_part_out, size_t section_part_outsize,
@@ -381,13 +361,6 @@ parse_body_peek_section_tok(const char *tok, uint32_t 
 	return (1);
 }
 
-/*
- * BODY.PEEK[HEADER.FIELDS...] tok: extracts the bracket body, dedupes a second
- * HEADER.FIELDS item, delegates to parse_header_fields_att(). Returns 1 on
- * success (*attrs_inout and the header_fields_*_out params updated), 0 if this
- * token should be silently ignored (a duplicate), -1 on a hard parse error
- * (*errmsg set).
- */
 static int
 parse_body_peek_header_fields_tok(const char *tok, uint32_t *attrs_inout,
     int *header_fields_not_out, char *header_fields_out,
@@ -427,7 +400,7 @@ parse_body_peek_header_fields_tok(const char *tok, uin
 	return (1);
 }
 
-/* SS6.4.5: ALL/FULL/FAST; unsupported skipped (*degraded_out=1), else -2/NO */
+/* RFC 9051 SS6.4.5 fetch-att; unsupported items are skipped */
 int
 parse_fetch_atts(char *spec, uint32_t *attrs_out, int *degraded_out,
     int *header_fields_not_out, char *header_fields_out,
@@ -475,18 +448,15 @@ parse_fetch_atts(char *spec, uint32_t *attrs_out, int 
 	for (tok = fetch_att_tok(p, &save); tok != NULL;
 	    tok = fetch_att_tok(NULL, &save)) {
 		if (strcasecmp(tok, "FAST") == 0) {
-			/* SS6.4.5 macro: FLAGS INTERNALDATE RFC822.SIZE. */
+			/* RFC 9051 SS6.4.5 FAST macro */
 			attrs |= MBOX_FETCH_FLAGS | MBOX_FETCH_INTERNALDATE |
 			    MBOX_FETCH_RFC822_SIZE;
 		} else if (strcasecmp(tok, "ALL") == 0) {
-			/* SS6.4.5 macro: FAST + ENVELOPE. */
+			/* FAST + ENVELOPE */
 			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)
-			 */
+			/* ALL + bare BODY */
 			attrs |= MBOX_FETCH_FLAGS | MBOX_FETCH_INTERNALDATE |
 			    MBOX_FETCH_RFC822_SIZE | MBOX_FETCH_ENVELOPE |
 			    MBOX_FETCH_BODYSTRUCTURE;
@@ -499,7 +469,7 @@ 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) {
-			/* SS3.1.4.2, CONDSTORE; cmd_fetch() checks */
+			/* RFC 7162 SS3.1.4.2 */
 			attrs |= MBOX_FETCH_MODSEQ;
 		} else if (strcasecmp(tok, "BODY.PEEK[HEADER]") == 0) {
 			/* exact BODY[...]; \Seen unimplemented */
@@ -525,10 +495,6 @@ parse_fetch_atts(char *spec, uint32_t *attrs_out, int 
 			attrs |= MBOX_FETCH_ENVELOPE;
 		} else if (strcasecmp(tok, "BODY") == 0 ||
 		    strcasecmp(tok, "BODYSTRUCTURE") == 0) {
-			/*
-			 * both produce identical output, exact-matched ahead of
-			 * "BODY" catch-all
-			 */
 			attrs |= MBOX_FETCH_BODYSTRUCTURE;
 			*bodystructure_full_out =
 			    (strcasecmp(tok, "BODYSTRUCTURE") == 0);
@@ -536,10 +502,7 @@ 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 BODY[...] and RFC822(.HEADER/.TEXT)
-			 * shorthands unimplemented
-			 */
+			/* other BODY forms and RFC822 items: unimplemented */
 			degraded = 1;
 		} else {
 			*errmsg = "unknown message data item";
@@ -548,30 +511,19 @@ parse_fetch_atts(char *spec, uint32_t *attrs_out, int 
 	}
 
 	if (attrs == 0) {
-		/*
-		 * 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/HEADER.FIELDS requested (legal, SS6.4.5): HEADER wins
-	 * here
-	 */
+	/* RFC 9051 SS6.4.5: HEADER wins over HEADER.FIELDS */
 	if ((attrs & MBOX_FETCH_BODY_HEADER) &&
 	    (attrs & MBOX_FETCH_HEADER_FIELDS))
 		attrs &= ~MBOX_FETCH_HEADER_FIELDS;
 
 	*attrs_out = attrs;
 	*degraded_out = degraded;
-	/*
-	 * 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;
@@ -583,7 +535,7 @@ const char *fetch_month_names[12] = {
 	"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
 };
 
-/* SS9 date-time; always UTC "+0000": ts has no tz, chroot child lacks tzdata */
+/* RFC 9051 SS9 date-time, always "+0000": no tzdata in the chroot */
 void
 format_internaldate(int64_t ts, char *out, size_t outsize)
 {
@@ -603,7 +555,6 @@ format_internaldate(int64_t ts, char *out, size_t outs
 	    tm.tm_hour, tm.tm_min, tm.tm_sec);
 }
 
-/* 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, ...)
 {
@@ -625,11 +576,7 @@ fetch_append(char *buf, size_t bufsize, size_t *len, c
 		*len = bufsize;
 }
 
-/*
- * 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.
- */
+/* a short read cannot be repaired: the length is on the wire */
 static void
 session_write_file_range(struct session *s, int fd, uint64_t off,
     uint64_t len)
@@ -660,7 +607,7 @@ session_write_file_range(struct session *s, int fd, ui
 	}
 }
 
-/* sends untagged FETCH response (SS7.5.2); literals flush buf, write raw */
+/* RFC 9051 SS7.5.2 */
 void
 session_send_fetch_response(struct session *s,
     struct imsg_mbox_fetch_meta *meta)
@@ -745,10 +692,7 @@ session_send_fetch_response(struct session *s,
 			need_sp = 1;
 		}
 		if (have_body) {
-			/*
-			 * SS6.4.5: echo origin octet only if client sent one,
-			 * not store.c's count
-			 */
+			/* echo the origin only if the client sent one */
 			len = 0;
 			if (s->pending_body_has_partial)
 				fetch_append(buf, sizeof(buf), &len,
@@ -777,12 +721,6 @@ session_send_fetch_response(struct session *s,
 		session_untagged(s, buf);
 	}
 
-	/*
-	 * 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;
@@ -832,7 +770,7 @@ session_send_fetch_response(struct session *s,
 	}
 }
 
-/* STORE's FETCH (SS6.4.6) shows FLAGS; MODSEQ if CONDSTORE-aware (SS3.1.3) */
+/* RFC 9051 SS6.4.6 STORE echo */
 void
 session_send_store_fetch_response(struct session *s,
     const struct imsg_mbox_fetch_meta *meta)
@@ -840,10 +778,7 @@ 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: UID STORE echoes UID 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))
@@ -858,7 +793,7 @@ session_send_store_fetch_response(struct session *s,
 	session_untagged(s, buf);
 }
 
-/* splits trailing RFC4466 modifiers off spec; NUL-terminates spec in place */
+/* RFC 4466 modifiers */
 char *
 split_trailing_modifiers(char *spec)
 {
@@ -877,10 +812,6 @@ split_trailing_modifiers(char *spec)
 					break;
 				}
 			} else if (*p == '\0')
-				/*
-				 * unterminated; caller's parser produces the
-				 * BAD for this
-				 */
 				return (NULL);
 			p++;
 		}
@@ -899,10 +830,7 @@ 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.
- */
+/* RFC 4466, RFC 7162 SS3.1.4.1 and SS3.2.6 modifiers */
 int
 parse_fetch_modifiers(char *modspec, struct imsg_mbox_fetch *req,
     const struct session *s, int by_uid, int *want_vanished,
@@ -932,13 +860,7 @@ 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).
-			 */
+			/* strtoull(3) accepts a sign */
 			if (*valtok < '0' || *valtok > '9') {
 				*errmsg = "invalid CHANGEDSINCE mod-sequence";
 				return (-1);
@@ -955,10 +877,7 @@ 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 with plain FETCH
-				 * MUST return tagged BAD
-				 */
+				/* RFC 7162 SS3.2.6: BAD */
 				*errmsg = "VANISHED is only valid as a UID "
 				    "FETCH modifier (RFC 7162 SS3.2.6)";
 				return (-1);
@@ -978,14 +897,13 @@ parse_fetch_modifiers(char *modspec, struct imsg_mbox_
 	return (0);
 }
 
-/* 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 cmd_fetch/cmd_uid FETCH (uid 0/1); forces MBOX_FETCH_UID (SS6.4.9) */
+/* RFC 9051 SS6.4.9: UID FETCH forces UID */
 int
 fetch_dispatch(struct session *s, const char *tag, char *args, int by_uid)
 {
@@ -1056,7 +974,6 @@ fetch_dispatch(struct session *s, const char *tag, cha
 	req.by_uid = by_uid;
 	req.header_fields_not = header_fields_not;
 
-	/* 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))
@@ -1068,7 +985,6 @@ fetch_dispatch(struct session *s, const char *tag, cha
 	req.partial_start = partial_start;
 	req.partial_count = partial_count;
 
-	/* 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)) >=
@@ -1087,10 +1003,6 @@ fetch_dispatch(struct session *s, const char *tag, cha
 		}
 	}
 
-	/*
-	 * 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) {
@@ -1113,10 +1025,6 @@ fetch_dispatch(struct session *s, const char *tag, cha
 	s->pending_body_has_partial = has_partial;
 	s->pending_body_partial_origin = partial_start;
 
-	/*
-	 * same, BODYSTRUCTURE: echoes "BODY"/"BODYSTRUCTURE" bare token client
-	 * used
-	 */
 	if (attrs & MBOX_FETCH_BODYSTRUCTURE) {
 		if (strlcpy(s->pending_bodystructure_label,
 		    bodystructure_full ? "BODYSTRUCTURE" : "BODY",
@@ -1137,10 +1045,7 @@ fetch_dispatch(struct session *s, const char *tag, cha
 	}
 
 	if (want_vanished && !req.has_changedsince) {
-		/*
-		 * RFC 7162 SS3.2.6: VANISHED MUST pair with CHANGEDSINCE, else
-		 * tagged BAD
-		 */
+		/* RFC 7162 SS3.2.6: VANISHED needs CHANGEDSINCE */
 		session_reply(s, tag, "BAD",
 		    "VANISHED requires CHANGEDSINCE also be specified "
 		    "(RFC 7162 SS3.2.6)");
@@ -1152,10 +1057,6 @@ fetch_dispatch(struct session *s, const char *tag, cha
 		req.attrs |= MBOX_FETCH_UID;
 
 	if (s->store_iev == NULL) {
-		/*
-		 * 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");
@@ -1164,10 +1065,7 @@ fetch_dispatch(struct session *s, const char *tag, cha
 
 	req.nranges = nranges;
 
-	/*
-	 * RFC 7162 SS3.1: MODSEQ fetch-att and CHANGEDSINCE both
-	 * CONDSTORE-enabling
-	 */
+	/* RFC 7162 SS3.1: both enable CONDSTORE */
 	if (req.attrs & MBOX_FETCH_MODSEQ)
 		session_condstore_enable(s);
 
blob - f3d26fd552c675eab5d75707a86c008bcd83c659
blob + fe08b5b977abd9e830855e87f2de1c8f2f51072f
--- 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 18 2026 $
+.Dd $Mdocdate: September 25 2026 $
 .Dt IMAPD 8
 .Os
 .Sh NAME
@@ -32,11 +32,12 @@ and
 .Em auth
 children over
 .Xr imsg_init 3
-control channels; a
+control channels.
+One
 .Em store
-child is forked per authenticated session, chroots into the mail
-spool, and drops privileges to that session's own user before ever
-touching mailbox data.
+child serves each logged-in account, shared by all of that account's
+sessions; it chroots into the mail spool and drops privileges to the
+account's own user before ever touching mailbox data.
 The
 .Fl x
 flag referenced internally by these re-executed children is not
@@ -219,6 +220,8 @@ and reloads the
 .Ic spool ,
 .Ic append max ,
 .Ic attachment max ,
+.Ic account sessions ,
+.Ic connections max ,
 .Ic idle poll ,
 .Ic lock timeout ,
 .Ic login grace ,
@@ -437,6 +440,9 @@ At or above
 every new connection is refused.
 A connection stops counting the moment it authenticates, so the limit
 bounds unfinished logins rather than established sessions.
+A refused connection is answered as one past
+.Ic connections max
+is.
 .Pp
 Both counts must be between 0 and 1000000 inclusive,
 .Ar percent
@@ -462,14 +468,43 @@ Defaults to
 100, matching
 .Xr sshd_config 5 Ns 's
 own default of 10:30:100.
+.It Ic startups per-source Ar count | Ic none
+The most connections that have not yet authenticated
+.Nm
+accepts from any one address, as
+.Xr sshd_config 5 Ns 's
+PerSourceMaxStartups does.
+It stops one address holding every connection the
+.Ic startups
+limit allows, which would refuse everyone else.
+A connection stops counting the moment it authenticates, and a refused
+one is answered as one past
+.Ic connections max
+is.
+.Pp
+Each address counts on its own, IPv6 addresses included, so a host able
+to use many IPv6 addresses can open this many from each of them; only
+.Ic startups
+and
+.Ic connections max
+bound that.
+Users behind one NAT address share it, and after a restart may briefly
+exceed it and have to retry.
+Raise it for them, or set
+.Ic none
+to count no address separately.
+.Pp
+Must be between 1 and 1000000 inclusive, or
+.Ic none .
+Defaults to 5.
 .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
+Every accepted connection costs two processes until it logs in, a
+listener-worker and an auth-worker, and a connection that completes the
+TCP handshake and then sends nothing would otherwise hold both
 indefinitely.
 Enough such connections reach the
 .Ic startups full
@@ -493,18 +528,25 @@ Must be between 1 and 3600 seconds inclusive, or 0 to 
 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
+How long a command waits for a mailbox's index lock held by another
+process 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.
+All of one account's sessions are served by its one
+.Em store
+child, one command at a time, so they never wait for each other's lock.
+A command that changes a mailbox holds the lock for the whole of the
+change, and marking a large mailbox read can take the better part of a
+minute; the account's other sessions are answered only once it is done,
+and this directive does not bound that.
+Another process holds the lock when a
+.Em store
+child whose sessions have all ended is still finishing a command as a new
+login for the same account starts another, and this directive bounds how
+long the new one waits.
 .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.
@@ -518,6 +560,71 @@ a single command can legitimately run longer than the 
 Must be between 1 and 3600 seconds inclusive, or 0 to disable the bound,
 which restores an unbounded wait.
 Defaults to 120.
+.It Ic account sessions Ar count
+The most sessions one account may have open at once.
+An account is one uid, gid and maildir from the credentials file,
+so entries that share all three are one account.
+Every session counts, whichever client opened it, and a mail client
+that opens several connections for one account uses several.
+A client that vanishes without closing its connection keeps its session
+until TCP keepalive finds it gone: after the
+.Va net.inet.tcp.keepidle
+.Xr sysctl 8
+plus eight
+.Va net.inet.tcp.keepintvl
+intervals, 2 hours 10 minutes by default.
+.Pp
+A login past the limit is answered
+.Li NO
+with the RFC 9051, section 7.1
+.Li LIMIT
+response code, and the connection is closed.
+The password was accepted, so the client may log in again on a new
+connection once one of the account's sessions has ended.
+.Pp
+Must be between 1 and 4096 inclusive.
+Defaults to 8.
+.It Ic connections max Ar count
+The most connections
+.Nm
+holds open at once, logged in or not, from all clients together.
+Past it, a new connection on the cleartext port is answered with the
+RFC 9051, section 7.1.5 rejected-connection greeting, a
+.Li BYE
+with the
+.Li UNAVAILABLE
+response code, and closed.
+On the implicit TLS port that greeting would have to travel inside TLS,
+so the connection is closed with nothing sent.
+Connections already open are not affected.
+.Pp
+Each connection costs one process, and a second until it logs in.
+Each account with a session logged in costs one more, and a second
+while a message is being parsed for it.
+So C connections, L of them not yet logged in, for A accounts need up
+to C + L + 2A processes, plus two for
+.Nm
+itself, and the parent keeps a descriptor to each of them.
+The processes count against the
+.Va kern.maxproc
+.Xr sysctl 8 ,
+shared with the rest of the system, and the descriptors against the
+.Cm openfiles
+limit of the
+.Xr login.conf 5
+class
+.Nm
+runs in; raise those before raising this.
+The default fits
+.Ox Ns 's
+default
+.Va kern.maxproc
+and
+.Cm daemon
+class even if every connection is a different account.
+.Pp
+Must be between 1 and 4096 inclusive.
+Defaults to 256.
 .It Ic append max Ar bytes
 Largest message a client may upload with
 .Li APPEND .
@@ -707,11 +814,13 @@ directive under FILES above.
 .Xr imsg_init 3 ,
 .Xr tls_init 3 ,
 .Xr httpd.conf 5 ,
+.Xr login.conf 5 ,
 .Xr sshd_config 5 ,
 .Xr syslog.conf 5 ,
 .Xr httpd 8 ,
 .Xr imapduser 8 ,
 .Xr smtpd 8 ,
+.Xr sysctl 8 ,
 .Xr syslogd 8
 .Sh STANDARDS
 .Rs
blob - 5b86996154c9b3069bea23f9c2a7cf037c026690
blob + ac8f63423c2f281213f976240a94b8fa2e18ebd3
--- src/imapd.conf.example
+++ src/imapd.conf.example
@@ -77,8 +77,8 @@ attachment max 41943040
 #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
+# closes it. Every accepted connection costs two processes (a
+# listener-worker and an auth-worker) until it logs in, 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
@@ -105,6 +105,23 @@ attachment max 41943040
 # disable the bound (an unbounded wait). Defaults to 120.
 #lock timeout 120
 
+# The most sessions one account (one uid, gid and maildir in the
+# credentials file) may have open at once, from all its clients
+# together. A login past it is answered NO [LIMIT] (RFC 9051 section
+# 7.1) and the connection is closed; the client may log in again on a
+# new connection once one of the account's sessions has ended. Must be
+# 1-4096. Defaults to 8.
+#account sessions 8
+
+# The most connections open at once, logged in or not. A new connection
+# past it gets a BYE [UNAVAILABLE] greeting on port 143 and is closed;
+# on port 993 it is closed with nothing sent. C connections, L of them
+# not yet logged in, for A accounts need up to C + L + 2A processes and
+# as many descriptors in the parent, so raise kern.maxproc and the
+# login class's openfiles before raising this. The default fits
+# OpenBSD's defaults. Must be 1-4096. Defaults to 256.
+#connections max 256
+
 # 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).
@@ -112,6 +129,15 @@ attachment max 41943040
 # accepted normally. Between "begin" and "full", new connections
 # are refused with linearly increasing probability, starting at
 # "rate" percent at "begin" and reaching 100% at "full". At or
-# above "full", every new connection is refused outright.
+# above "full", every new connection is refused outright, and answered
+# as for "connections max".
 # Defaults to sshd_config(5)'s own default, 10:30:100.
 #startups begin 10 rate 30 full 100
+
+# The most not-yet-authenticated connections from any one address, as
+# sshd_config(5)'s PerSourceMaxStartups, so one address cannot hold
+# every "startups" slot. Each IPv6 address counts on its own. Users
+# behind one NAT address share it; raise it for them, or set "none"
+# to turn it off. A refused connection is answered as for
+# "connections max". Must be 1-1000000 or none. Defaults to 5.
+#startups per-source 5
blob - 4b279ff164f1c69ee2048b601d4709ae2c096844
blob + 36a55e455f441bce94458123f671b274b6e98577
--- src/imapd.h
+++ src/imapd.h
@@ -16,34 +16,29 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* Shared definitions for every imapd(8) process role. */
-
 #ifndef IMAPD_H
 #define IMAPD_H
 
 #include <sys/types.h>
-#include <sys/cdefs.h>		/* __dead */
+#include <sys/cdefs.h>
 #include <sys/queue.h>
-#include <sys/socket.h>	/* sockaddr_storage, socklen_t */
+#include <sys/socket.h>
 
 #include <event.h>
 #include <imsg.h>
 #include <stdint.h>
 
-#define IMAPD_VERSION	"0.1.5"
+#define IMAPD_VERSION	"0.1.6"
 
-/* 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		/* per-connection SEARCH-grammar
-				 * parsing oracle */
+	PROC_PARSER
 };
 
-/* imsg message catalog. */
 enum imsg_type {
 	IMSG_NONE,
 
@@ -51,69 +46,61 @@ enum imsg_type {
 	IMSG_SETUP_PEER,
 	IMSG_SETUP_DONE,
 
-	/* 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 -> listener-worker at fork: one already-accepted */
-	/* connection, the client fd riding as this imsg's fd-pass. */
+	/* parent -> listener-worker: an accepted connection, fd-passed */
 	IMSG_LISTENER_SESSION_INIT,
 
-	/* parent -> auth, at boot */
 	IMSG_AUTH_INIT,
 
 	/* listener <-> auth */
 	IMSG_AUTH_REQUEST,
 	IMSG_AUTH_RESULT,
 
-	/* 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: 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: the real TLS private key */
+	/* parent -> keymgr, at boot and on SIGHUP: the TLS private key */
 	IMSG_KEYMGR_INIT,
 
-	/* 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. */
+	/* listener <-> keymgr: the reply reuses the request's type and id */
 	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,
+	IMSG_AUTH_EXIT,
 
-	/* per-session store spawn */
 	IMSG_STORE_FORK,
 	IMSG_STORE_INIT,
 	IMSG_STORE_SHUTDOWN,
+	IMSG_STORE_EXIT,
 
-	/* listener <-> store, once a session's store child is wired up. */
-	/* SELECT carries EXAMINE too, as a request with "readonly" set. */
+	/* parent -> parser: the uid to drop to; the parser opens nothing */
+	IMSG_PARSER_INIT,
+	/* store -> parent: start a parser; it arrives as IMSG_SETUP_PEER */
+	IMSG_PARSER_WANT,
+	IMSG_PARSER_NONE,
+
+	/* store <-> parser: the reply reuses the request's type and id */
+	IMSG_PARSER_ENVELOPE,
+	IMSG_PARSER_BODYSTRUCTURE,
+	IMSG_PARSER_HEADER_FIELDS,
+	IMSG_PARSER_PART,
+	IMSG_PARSER_SEARCH,
+
+	/* listener <-> store; SELECT carries EXAMINE as readonly */
 	IMSG_MBOX_SELECT,
 	IMSG_MBOX_SELECTED,
 	IMSG_MBOX_FETCH,
 	IMSG_MBOX_FETCH_META,
-	/* 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_FETCH_HEADER,
+	IMSG_MBOX_FETCH_BODY,
+	IMSG_MBOX_FETCH_ENVELOPE,
+	IMSG_MBOX_FETCH_BODYSTRUCTURE,
 	IMSG_MBOX_STORE,
-	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_APPEND,
+	IMSG_MBOX_APPEND_DATA,
+	IMSG_MBOX_APPEND_END,
 	IMSG_MBOX_APPENDED,
 	IMSG_MBOX_COPY,
 	IMSG_MBOX_MOVE,
@@ -130,28 +117,24 @@ enum imsg_type {
 	IMSG_MBOX_RENAME,
 	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 7162 CONDSTORE/QRESYNC */
+	IMSG_MBOX_SELECT_VANISHED,
+	IMSG_MBOX_STORE_MODIFIED,
 
 	/* 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 */
+	IMSG_MBOX_IDLE_REFRESH,
+	IMSG_MBOX_IDLE_EXPUNGE,
+	IMSG_MBOX_IDLE_FETCH,
+	IMSG_MBOX_IDLE_REFRESHED,
 
-	/* RFC 9051 SS6.3.9 (LIST): one mailbox name, store -> listener, */
-	/* before the terminal IMSG_MBOX_RESULT. */
+	/* RFC 9051 SS6.3.9 (LIST) */
 	IMSG_MBOX_LIST_ITEM,
 
-	/* RFC 9051 SS6.3.7/SS6.3.8: both carry struct imsg_mbox_subscribe */
-	/* and reply with IMSG_MBOX_RESULT. */
+	/* RFC 9051 SS6.3.7/SS6.3.8 (SUBSCRIBE/UNSUBSCRIBE) */
 	IMSG_MBOX_SUBSCRIBE,
 	IMSG_MBOX_UNSUBSCRIBE
 };
 
-/* privsep imsg-over-event(3) wrapper. */
 struct imsgev {
 	struct imsgbuf	 ibuf;
 	void		(*handler)(int, short, void *);
@@ -163,61 +146,37 @@ struct imsgev {
 #define LISTENER_MAX_ADDRS	2
 
 struct openimap_config {
-	char	 listen_addr[64];	/* "0.0.0.0" (default), "::", a literal
-					 * 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. */
+	char	 listen_addr[64];
 	uint16_t port_cleartext;	/* 143, STARTTLS; 0 = not configured */
 	uint16_t port_implicit_tls;	/* 993, RFC 8314; 0 = not configured */
-	char	 spool_root[1024];	/* mail spool root, store's chroot */
-	char	 cred_file[1024];	/* auth's credential file, one
-					 * line per user, format
-					 * username:passwordhash:uid:gid:
-					 * maildir */
+	char	 spool_root[1024];
+	char	 cred_file[1024];
 	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 */
+	uint32_t bodystructure_read_max;
+	uint64_t append_max;
 
-	/* 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;
+	uint32_t max_startups_per_source;
 
-	/* "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;
+	uint32_t account_sessions;
+	uint32_t connections_max;
 };
 
-/* 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. 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, 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;
@@ -227,39 +186,20 @@ struct imsg_auth_init {
 	char		cred_file[1024];
 };
 
-/*
- * 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); 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.
- */
+/* keymgr request: fixed header, then fromlen bytes */
 #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];
-	/* RSA padding mode; ignored for ECDSA */
+	/* ignored for ECDSA */
 	uint32_t	padding;
-	/* trailing input bytes, <= KEYMGR_DATA_MAX */
 	uint32_t	fromlen;
 };
 
 struct imsg_keymgr_sign_reply {
-	/*
-	 * 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()
-	 */
+	/* a failure fails one TLS operation, not the listener */
 	int		ok;
-	/* trailing output bytes, meaningful only if ok */
 	uint32_t	tolen;
 };
 
@@ -288,48 +228,37 @@ struct imsg_auth_cred {
 
 struct imsg_store_fork {
 	uint32_t	session_id;
+	int		limit;	/* refused by "account sessions" */
 };
 
+struct imsg_parser_init {
+	uint32_t	session_id;
+	uid_t		uid;
+	gid_t		gid;
+	uint32_t	bodystructure_read_max;
+};
+
 struct imsg_store_init {
 	uint32_t	session_id;
 	uid_t		uid;
 	gid_t		gid;
-	/* store needs this to chroot() */
 	char		spool_root[1024];
-	/*
-	 * THIS session's own mailbox subdirectory, relative to spool_root above
-	 */
+	/* relative to spool_root */
 	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;
 };
 
-/*
- * IMSG_MBOX_SELECT (listener -> store) / IMSG_MBOX_SELECTED (store ->
- * listener).
- */
 #define MBOX_NAME_MAX	256
 
-/*
- * Shared specific-error type for the store-process operation-result imsg
- * structs (imsg_mbox_selected, imsg_mbox_status_result, imsg_mbox_result,
- * imsg_mbox_appended).
- *
- * 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, and MBOX_OP_ERR_BUSY via RFC 9051 SS7.1's INUSE.
- */
+/* RFC 5530 NONEXISTENT/ALREADYEXISTS, RFC 9051 SS7.1 INUSE */
 enum mbox_op_error {
 	MBOX_ERR_UNSET = 0,
 	MBOX_OP_OK,
 	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,
 };
 
@@ -338,49 +267,24 @@ struct imsg_mbox_select {
 	char		mailbox[MBOX_NAME_MAX];
 	int		readonly;	/* 1 = EXAMINE, 0 = SELECT */
 
-	int		qresync;	/* 1 if QRESYNC was requested and
-					 * enabled (RFC 7162 SS3.2.5) */
-	uint32_t	qresync_uidvalidity; /* client's last-known
-					 * UIDVALIDITY; a mismatch means the
-					 * rest of qresync_* is ignored */
-	uint64_t	qresync_modseq;	/* client's last-known mailbox
-					 * mod-sequence */
-	int		qresync_has_uids; /* 0 = client omitted known-uids;
-					 * store.c then defaults to the full
-					 * UID range (SS3.2.5.1) */
-	uint32_t	qresync_nranges; /* count of the trailing struct
-					 * seq_range array (RFC 9051 SS9
-					 * sequence-set; "*" is forbidden in
-					 * known-uids per SS3.2.5.1, already
-					 * rejected by parse_qresync_group());
-					 * ignored (and 0) if
-					 * !qresync_has_uids */
+	int		qresync;
+	uint32_t	qresync_uidvalidity;
+	uint64_t	qresync_modseq;
+	int		qresync_has_uids;
+	uint32_t	qresync_nranges;
 };
 
 struct imsg_mbox_selected {
-	enum mbox_op_error error;	/* MBOX_OP_ERR_GENERIC covers "no
-					 * such mailbox" and I/O failure alike
-					 *, always reported as
-					 * [NONEXISTENT] */
-	uint32_t	exists;		/* RFC 9051 SS7.4.1 EXISTS */
-	uint32_t	uidvalidity;	/* RFC 9051 SS2.3.1.1 */
-	uint32_t	uidnext;	/* RFC 9051 SS2.3.1.1 */
-
-	/* RFC 7162 SS3.1.2.1: mailbox's highest mod-sequence, always
-	 * populated; listener.c decides whether to surface it via the
-	 * HIGHESTMODSEQ response code. */
+	enum mbox_op_error error;
+	uint32_t	exists;
+	uint32_t	uidvalidity;
+	uint32_t	uidnext;
 	uint64_t	highestmodseq;
 
-	/* store.c streams, before this struct: zero or more
-	 * IMSG_MBOX_SELECT_VANISHED, then zero or more IMSG_MBOX_FETCH_META
-	 * (modseq set), per RFC 7162 SS3.2.6's VANISHED-before-FETCH
-	 * ordering, only when qresync was requested and UIDVALIDITY
-	 * matched. */
+	/* preceded by VANISHED, then FETCH_META (RFC 7162 SS3.2.6) */
 };
 
-/* RFC 9051 SS6.3.11 status-att-val bits, plus RFC 7162 SS3.1.7's
- * HIGHESTMODSEQ. Request-parsing order only, listener.c's response uses
- * its own fixed order, not this bitmask's. */
+/* RFC 9051 SS6.3.11, plus RFC 7162 SS3.1.7 HIGHESTMODSEQ */
 #define STATUS_ATT_MESSAGES		(1U << 0)
 #define STATUS_ATT_UIDNEXT		(1U << 1)
 #define STATUS_ATT_UIDVALIDITY		(1U << 2)
@@ -388,264 +292,163 @@ struct imsg_mbox_selected {
 #define STATUS_ATT_DELETED		(1U << 4)
 #define STATUS_ATT_SIZE			(1U << 5)
 #define STATUS_ATT_HIGHESTMODSEQ	(1U << 6)
-#define STATUS_ATT_RECENT		(1U << 7)	/* IMAP4rev2 dropped
-							 * \Recent/RECENT, but
-							 * some clients still
-							 * ask, always
-							 * answered "0" rather
-							 * than BAD */
+#define STATUS_ATT_RECENT		(1U << 7)	/* answered "0" */
 
-/*
- * IMSG_MBOX_STATUS (listener -> store) / IMSG_MBOX_STATUS_RESULT (store ->
- * listener): RFC 9051 SS6.3.11 STATUS, one combined reply, no per-message
- * streaming.
- *
- * mailbox targets any named mailbox independent of what's selected
- * (SS6.3.11); store.c opens it with mailbox_open_dir() and closes it
- * afterward, leaving the selection alone.
- */
+/* RFC 9051 SS6.3.11 STATUS; the selection is left alone */
 struct imsg_mbox_status {
 	char		mailbox[MBOX_NAME_MAX];
-	uint32_t	attrs;	/* STATUS_ATT_* bitmask. MESSAGES/UIDNEXT/
-				 * UIDVALIDITY/HIGHESTMODSEQ are always
-				 * computed (free reads); UNSEEN/DELETED/SIZE
-				 * only trigger a per-message scan if
-				 * requested (RFC 9051 SS6.3.11 warns SIZE can
-				 * be slow) */
+	uint32_t	attrs;
 };
 
 struct imsg_mbox_status_result {
-	enum mbox_op_error error;	/* MBOX_OP_ERR_GENERIC only --
-					 * always reported as
-					 * [NONEXISTENT] */
-	uint32_t	messages;	/* STATUS_ATT_MESSAGES */
-	uint32_t	uidnext;	/* STATUS_ATT_UIDNEXT */
-	uint32_t	uidvalidity;	/* STATUS_ATT_UIDVALIDITY */
-	uint64_t	highestmodseq;	/* STATUS_ATT_HIGHESTMODSEQ, RFC 7162
-					 * SS3.1.7 */
-	uint32_t	unseen;		/* STATUS_ATT_UNSEEN, 0 if not
-					 * requested, see imsg_mbox_status.attrs
-					 * comment */
-	uint32_t	deleted;	/* STATUS_ATT_DELETED, same as above */
-	uint64_t	size;		/* STATUS_ATT_SIZE, same as above */
+	enum mbox_op_error error;
+	uint32_t	messages;
+	uint32_t	uidnext;
+	uint32_t	uidvalidity;
+	uint64_t	highestmodseq;
+	uint32_t	unseen;
+	uint32_t	deleted;
+	uint64_t	size;
 };
 
-/*
- * IMSG_MBOX_SELECT_VANISHED (store -> listener, zero or more, before the
- * terminal IMSG_MBOX_SELECTED or IMSG_MBOX_RESULT): one range [uid_lo,
- * uid_hi] of UIDs no longer present. Used both for QRESYNC SELECT resync
- * and RFC 7162 SS3.2.6's VANISHED UID FETCH modifier; listener.c tells
- * them apart by s->state.
- *
- * Computed in O(mailbox size) by walking the present-message list once
- * and reporting gaps, not by iterating the (client-controlled) requested
- * range. Ignores qresync_modseq entirely. This implementation uses RFC
- * 7162 SS5.1's minimal-state model rather than persisting expunge
- * history (SS5.3).
- */
+/* one gap [uid_lo, uid_hi]; RFC 7162 SS5.1 minimal state */
 struct imsg_mbox_select_vanished {
 	uint32_t	uid_lo;
 	uint32_t	uid_hi;
 };
 
-/*
- * IMSG_MBOX_FETCH (listener -> store) / IMSG_MBOX_FETCH_META (store ->
- * listener, one per matching message, ascending sequence order) /
- * IMSG_MBOX_RESULT (store -> listener, once, after the last META).
- */
-#define MBOX_FLAGS_MAX	256	/* generous; truncated (not rejected) if
-				 * exceeded */
+#define MBOX_FLAGS_MAX	256	/* truncated if exceeded */
 
 #define MBOX_FETCH_FLAGS		(1U << 0)
 #define MBOX_FETCH_UID			(1U << 1)
 #define MBOX_FETCH_INTERNALDATE	(1U << 2)
 #define MBOX_FETCH_RFC822_SIZE		(1U << 3)
-#define MBOX_FETCH_MODSEQ		(1U << 4) /* RFC 7162 SS3.1.4.2 --
-					 * set if MODSEQ was named,
-					 * CHANGEDSINCE was used, or
-					 * CONDSTORE is already enabled */
-#define MBOX_FETCH_BODY_HEADER		(1U << 5) /* BODY.PEEK[HEADER] only
-					 * (SS6.4.5): raw header block, not
-					 * ENVELOPE. Plain BODY[HEADER] is
-					 * unimplemented (\Seen side effect). */
-#define MBOX_FETCH_BODY_WHOLE		(1U << 6) /* BODY.PEEK[] only
-					 * (SS6.4.5): raw header+body, no MIME
-					 * parsing. */
-#define MBOX_FETCH_BODY_TEXT		(1U << 7) /* BODY.PEEK[TEXT] only
-					 * (SS6.4.5.1): body after the
-					 * header/body blank line. If both
-					 * WHOLE and TEXT are requested,
-					 * store.c answers WHOLE only. */
-#define MBOX_FETCH_HEADER_FIELDS	(1U << 8) /* BODY.PEEK[HEADER.FIELDS
-					 * [.NOT] (names)] (SS6.4.5.1); reuses
-					 * IMSG_MBOX_FETCH_HEADER. req->
-					 * header_fields_not/header_fields
-					 * carry the NOT flag and field list;
-					 * listener.c echoes the client's own
-					 * label text back verbatim. Plain
-					 * BODY.PEEK[HEADER] wins if both are
-					 * requested. */
-#define MBOX_FETCH_ENVELOPE		(1U << 9) /* RFC 9051 SS7.5.2 --
-						 * parsed response via store.c's
-						 * build_envelope(). No .PEEK
-						 * variant, no \Seen side
-						 * effect. */
-#define MBOX_FETCH_BODYSTRUCTURE	(1U << 10) /* RFC 9051 SS7.5.2
-						 * BODYSTRUCTURE and its synonym
-						 * bare "BODY", extension data
-						 * (MD5/disposition/language/
-						 * location) is never emitted,
-						 * so both produce identical
-						 * output. Recursive MIME
-						 * parsing via store.c's
-						 * build_bodystructure(). */
-#define MBOX_FETCH_BODY_PART		(1U << 11) /* BODY.PEEK[<section-part>]
-						 * only (SS6.4.5.1, numeric
-						 * section-part). Leaf parts
-						 * only, a MULTIPART container
-						 * or nested MESSAGE/RFC822|
-						 * GLOBAL numbering is "not
-						 * found", matching
-						 * BODYSTRUCTURE's own scope
-						 * cut. Needs req->section_part,
-						 * hence a separate bit from
-						 * WHOLE/TEXT. */
+#define MBOX_FETCH_MODSEQ		(1U << 4) /* RFC 7162 SS3.1.4.2 */
+#define MBOX_FETCH_BODY_HEADER		(1U << 5)
+#define MBOX_FETCH_BODY_WHOLE		(1U << 6)
+#define MBOX_FETCH_BODY_TEXT		(1U << 7)
+#define MBOX_FETCH_HEADER_FIELDS	(1U << 8)
+#define MBOX_FETCH_ENVELOPE		(1U << 9)
+#define MBOX_FETCH_BODYSTRUCTURE	(1U << 10) /* and bare BODY */
+#define MBOX_FETCH_BODY_PART		(1U << 11)
 
-/* 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 one IMSG_MBOX_FETCH_HEADER carries. An */
-/* oversized header is "not found", not truncated. */
 #define FETCH_HEADER_MAX	8192
 
-/* "append max" default and ceiling. The default matches smtpd.conf(5)'s */
-/* max-message-size default of 35M. */
+/* smtpd.conf(5)'s max-message-size default */
 #define APPEND_MAX_DEFAULT	(35 * 1024 * 1024)
 #define APPEND_MAX_MAX		1073741824
 
-/* 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)
 
-/* 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. */
+/* each fd holds a kern.maxfiles slot until 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 formatted ENVELOPE text; an oversized one is "not found". */
 #define ENVELOPE_MAX	8192
 
-/* 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. */
+/* relayd's RELAY_TLS_PRIV_TIMEOUT bounds a similar wait */
+#define PARSER_REPLY_TIMEOUT_SEC	10
+
+/* SIGXCPU at soft, SIGKILL at hard (sys/kern/kern_resource.c) */
+#define PARSER_CPU_SOFT_SEC	8
+#define PARSER_CPU_HARD_SEC	10
+
+/* half the soft limit, so honest work retires before SIGXCPU */
+#define PARSER_CPU_RETIRE_SEC	(PARSER_CPU_SOFT_SEC / 2)
+
+#define PARSER_IDLE_SEC		60
+
+#define PARSER_STRIKES		3
+
+/* a message's own headers claim these, so neither is trusted */
 #define MIME_MAX_DEPTH	10
 #define MIME_MAX_PARTS	64
 
-/* 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 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_DEFAULT	5
 #define IDLE_POLL_MAX		300	/* seconds; 0 disables polling */
 
-/* "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 */
+/* RFC 9051 SS5.4 permits a short pre-authentication timer */
+#define LOGIN_GRACE_DEFAULT	60
 #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 */
+/* lock wait, then NO [INUSE] (RFC 9051 SS7.1) */
+#define LOCK_TIMEOUT_DEFAULT	120
 #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". */
+/* "account sessions" bounds; the maximum is parent.c's OPEN_SESSION_MAX */
+#define ACCOUNT_SESSIONS_DEFAULT	8
+#define ACCOUNT_SESSIONS_MAX		4096
+
+/* "connections max" bounds; the maximum is parent.c's OPEN_SESSION_MAX */
+#define CONNECTIONS_MAX_DEFAULT		256
+#define CONNECTIONS_MAX_MAX		4096
+
+#define STARTUPS_PER_SOURCE_DEFAULT	5
+
+/* "attachment max"; mail from an MTA is not bound by "append max" */
 #define BODYSTRUCTURE_READ_DEFAULT	41943040
 
-/*
- * 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.
- */
+/* each request carries an O_RDONLY descriptor on the message */
+struct imsg_parser_req {
+	char		basename[512];
+	char		fields[HEADER_FIELDS_MAX];
+	int		fields_not;
+	int		path[MIME_MAX_DEPTH];
+	int		pathlen;
+	uint32_t	nleaves;
+	uint32_t	poollen;
+};
+
+/* RFC 9051 SS6.4.4 content key; strings are offsets into the pool */
+struct imsg_parser_leaf {
+	int32_t		op;
+	uint32_t	str_off;
+	uint32_t	str_len;
+	uint32_t	name_off;
+	uint32_t	name_len;
+	int64_t		num;		/* SENT*: that day's UTC midnight */
+};
+
+struct imsg_parser_rep {
+	int		found;
+	uint32_t	len;
+	uint64_t	part_off;
+	uint64_t	part_len;
+	uint32_t	retiring;
+};
+
+/* RFC 9051 SS9 sequence-set range; the store resolves "*" */
 struct seq_range {
-	uint32_t	lo;	/* 1-based, inclusive; ignored if lo_is_star */
-	uint32_t	hi;	/* 1-based, inclusive; ignored if hi_is_star */
+	uint32_t	lo;
+	uint32_t	hi;
 	int		lo_is_star;
 	int		hi_is_star;
 };
 
-/* Bounds a sequence-set's range count, so the trailing seq_range array */
-/* cannot grow an imsg past MAX_IMSGSIZE. */
+/* keeps the trailing array under MAX_IMSGSIZE */
 #define SEQSET_MAX_RANGES	500
 
 struct imsg_mbox_fetch {
-	uint32_t	nranges;	/* count of the trailing struct
-					 * seq_range array (RFC 9051 SS9
-					 * sequence-set); "*" resolves
-					 * against store's live message
-					 * count, not listener's possibly-
-					 * stale SELECT-time count */
-	uint32_t	attrs;		/* bitmask of MBOX_FETCH_* above */
+	uint32_t	nranges;
+	uint32_t	attrs;
 
-	/* RFC 7162 SS3.1.4.1 CHANGEDSINCE; has_changedsince distinguishes
-	 * "not specified" from a legal value of 0. */
+	/* RFC 7162 SS3.1.4.1 CHANGEDSINCE */
 	int		has_changedsince;
 	uint64_t	changedsince;
 
-	/* by_uid: RFC 9051 SS6.4.9 UID FETCH, resolve the trailing
-	 * sequence-set ranges against UID space rather than sequence-
-	 * number space. listener.c separately forces MBOX_FETCH_UID
-	 * into attrs whenever this is set.
-	 *
-	 * want_vanished: RFC 7162 SS3.2.6 VANISHED modifier (only legal
-	 * with CHANGEDSINCE, only on UID FETCH; listener.c enforces both).
-	 * Per this implementation's minimal-state QRESYNC model, store.c
-	 * reports every UID in range that isn't present, unconditionally
-	 *, explicitly sanctioned by SS3.2.6.
-	 */
+	/* RFC 9051 SS6.4.9 UID FETCH; RFC 7162 SS3.2.6 VANISHED */
 	int		by_uid;
 	int		want_vanished;
 
-	/* BODY.PEEK[HEADER.FIELDS[.NOT] (...)] (MBOX_FETCH_HEADER_FIELDS):
-	 * header_fields_not is 0 for FIELDS (include), 1 for FIELDS.NOT
-	 * (exclude). header_fields is the space-joined field-name list,
-	 * exactly as typed (matching is ASCII case-insensitive, done by
-	 * store.c). listener.c has already validated the grammar before
-	 * either field is populated.
-	 */
 	int		header_fields_not;
 	char		header_fields[HEADER_FIELDS_MAX];
 
-	/* BODY[<section-part>]/BODY.PEEK[<section-part>]
-	 * (MBOX_FETCH_BODY_PART): section_part is the client-typed
-	 * dotted-numeric path (e.g. "3.1"), pre-validated by listener.c's
-	 * tokenizer. Single shared field, a client requesting two
-	 * section-parts in one FETCH only gets the first honored.
-	 *
-	 * has_partial/partial_start/partial_count carry a <<partial>>
-	 * 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
-	 * 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.
-	 */
+	/* a second section-part in one FETCH is ignored */
 	char		section_part[SECTION_PART_MAX];
 	int		has_partial;
 	uint32_t	partial_start;
@@ -653,368 +456,137 @@ struct imsg_mbox_fetch {
 };
 
 struct imsg_mbox_fetch_meta {
-	uint32_t	seqno;		/* 1-based */
+	uint32_t	seqno;
 	uint32_t	uid;
-	uint64_t	size;		/* on-disk file size, octets --
-					 * RFC822.SIZE (RFC 9051 SS2.3.4).
-					 * 64-bit so a message over 4 GiB
-					 * reports correctly (F13 fix). */
-	int64_t		internaldate;	/* Unix timestamp, parsed from the
-					 * maildir basename's delivery-time
-					 * field, not the file's mtime */
-	char		flags[MBOX_FLAGS_MAX]; /* space-separated IMAP flag
-					 * names, e.g. "\Seen \Flagged foo",
-					 * pre-formatted by store.c */
-	uint64_t	modseq;		/* RFC 7162 per-message mod-sequence,
-					 * always populated; shared by FETCH,
-					 * STORE's FETCH echo, and QRESYNC
-					 * resync's FETCH-with-UID responses */
+	uint64_t	size;
+	int64_t		internaldate;	/* from the basename, not mtime */
+	char		flags[MBOX_FLAGS_MAX];
+	uint64_t	modseq;
 };
 
-/*
- * IMSG_MBOX_FETCH_HEADER: sent by store.c immediately before the
- * IMSG_MBOX_FETCH_META for the same message, iff req->attrs &
- * MBOX_FETCH_BODY_HEADER, listener.c relies on this exact ordering to
- * fold the header bytes into the same "* N FETCH (...)" response line.
- * Same "fixed struct + trailing variable-length bytes on one imsg" shape
- * as imsg_mbox_append.
- */
+/* sent just before the same message's IMSG_MBOX_FETCH_META */
 struct imsg_mbox_fetch_header {
-	uint32_t	seqno;		/* 1-based, matches the following
-					 * IMSG_MBOX_FETCH_META */
+	uint32_t	seqno;
 	uint32_t	uid;
-	int		found;		/* 0 if the message file couldn't be
-					 * found or its header exceeded
-					 * FETCH_HEADER_MAX; listener.c omits
-					 * BODY[HEADER] from this message's
-					 * response rather than failing the
-					 * whole FETCH. hdrlen and the trailing
-					 * bytes are only meaningful if 1. */
-	uint32_t	hdrlen;		/* length of the trailing raw header
-					 * bytes on this imsg, capped at
-					 * FETCH_HEADER_MAX */
+	int		found;
+	uint32_t	hdrlen;
 };
 
-/*
- * 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 | 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.
- */
+/* the octets travel as a passed descriptor, not in the imsg */
 struct imsg_mbox_fetch_body {
-	uint32_t	seqno;		/* 1-based, matches the following
-					 * IMSG_MBOX_FETCH_META */
+	uint32_t	seqno;
 	uint32_t	uid;
-	int		found;		/* 0 if the message file couldn't be
-					 * 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 */
+	int		found;
+	uint64_t	offset;
 	uint64_t	length;		/* octets; no descriptor when 0 */
 };
 
-/*
- * IMSG_MBOX_FETCH_ENVELOPE: sent by store.c immediately before the
- * IMSG_MBOX_FETCH_META for the same message, iff req->attrs &
- * MBOX_FETCH_ENVELOPE. Unlike fetch_header/fetch_body, the trailing bytes
- * are the complete, already-formatted RFC 9051 SS7.5.2 envelope
- * parenthesized-list text, ready to splice verbatim after "ENVELOPE " in
- * the FETCH response. store.c's build_envelope() does all RFC 5322
- * parsing and address-list decomposition; listener.c does pure wire
- * framing. No literal-block wrapping needed, envelope fields are short
- * quoted strings, never raw message bytes, so never CRLF-bearing.
- */
+/* CRLF-free is enforced by parser_reply_safe() in store.c */
 struct imsg_mbox_fetch_envelope {
-	uint32_t	seqno;		/* 1-based, matches the following
-					 * IMSG_MBOX_FETCH_META */
+	uint32_t	seqno;
 	uint32_t	uid;
-	int		found;		/* 0 if the message's header couldn't
-					 * be found/read, or the formatted
-					 * envelope exceeded ENVELOPE_MAX.
-					 * envlen and the trailing bytes are
-					 * only meaningful if 1. */
-	uint32_t	envlen;		/* length of the trailing formatted
-					 * envelope text, capped at
-					 * ENVELOPE_MAX */
+	int		found;
+	uint32_t	envlen;
 };
 
-/*
- * IMSG_MBOX_FETCH_BODYSTRUCTURE: sent by store.c immediately before the
- * IMSG_MBOX_FETCH_META for the same message, iff req->attrs &
- * MBOX_FETCH_BODYSTRUCTURE. Same "already-formatted response text"
- * shape as imsg_mbox_fetch_envelope. The trailing bytes are the complete
- * RFC 9051 SS7.5.2 BODYSTRUCTURE parenthesized-list text, built by
- * store.c's build_bodystructure()/build_body_structure() (Content-Type/
- * CTE/Content-ID/Content-Description extraction, multipart boundary
- * splitting, recursion bounded by MIME_MAX_DEPTH/MIME_MAX_PARTS).
- * Extension data (MD5/disposition/language/location) is never emitted,
- * so BODYSTRUCTURE and bare BODY produce identical text. No
- * literal-block wrapping needed, same reasoning as envelope.
- */
 struct imsg_mbox_fetch_bodystructure {
-	uint32_t	seqno;		/* 1-based, matches the following
-					 * IMSG_MBOX_FETCH_META */
+	uint32_t	seqno;
 	uint32_t	uid;
-	int		found;		/* 0 if the message's raw bytes
-					 * couldn't be read, MIME_MAX_DEPTH/
-					 * MIME_MAX_PARTS was exceeded, the
-					 * message contains a message/rfc822
-					 * or message/global part, or the
-					 * formatted text exceeded
-					 * BODYSTRUCTURE_MAX. bslen and the
-					 * trailing bytes are only meaningful
-					 * if 1. */
-	uint32_t	bslen;		/* length of the trailing formatted
-					 * BODYSTRUCTURE text, capped at
-					 * BODYSTRUCTURE_MAX */
+	int		found;
+	uint32_t	bslen;
 };
 
-/* enum mbox_op_error is defined earlier in this file, above
- * imsg_mbox_select, since several structs before this point need the
- * complete type in scope. */
-
-/* Generic per-operation completion signal; FETCH is the first user, but
- * intended for STORE/APPEND/etc. too. */
 struct imsg_mbox_result {
 	enum mbox_op_error error;
-	uint32_t	count;	/* number of IMSG_MBOX_FETCH_META (or
-				 * equivalent, for a future op) messages that
-				 * preceded this one */
-
-	/* RFC 7162: mailbox HIGHESTMODSEQ after this operation. Always
-	 * populated for STORE/EXPUNGE; 0 for plain FETCH. listener.c
-	 * decides whether to surface it: EXPUNGE's tagged OK includes it
-	 * whenever CONDSTORE is enabled (SS3.2.7); STORE's tagged OK only
-	 * on the first CONDSTORE-enabling command (SS3.1.3); CLOSE's
-	 * tagged OK MUST NOT include it (SS3.2.8).
-	 */
+	uint32_t	count;
 	uint64_t	highestmodseq;
 
-	/* RFC 9051 SS7.1 COPYUID response code: destination mailbox's
-	 * UIDVALIDITY. Populated only for COPY/MOVE; 0 for
-	 * FETCH/STORE/EXPUNGE/SEARCH.
-	 */
+	/* RFC 9051 SS7.1 COPYUID; COPY and MOVE only */
 	uint32_t	uidvalidity;
 };
 
-/*
- * IMSG_MBOX_STORE (listener -> store): RFC 9051 SS6.4.6 STORE. Replies
- * reuse IMSG_MBOX_FETCH_META (one per modified message, only if
- * !silent) and the terminal IMSG_MBOX_RESULT, SS6.4.6's only response
- * is an untagged FETCH, the same shape FETCH itself produces.
- *
- * System flags are the fixed 5-bit RFC 9051 SS2.3.2 set (\Answered
- * \Flagged \Deleted \Seen \Draft; \Recent and any flag-extension are
- * rejected by listener.c before this struct is built). Keywords are
- * carried as a comma-separated list matching the index's own
- * delimiter; listener.c rejects a keyword containing ':' or ',' (legal
- * IMAP atoms, but the index format has no escaping for its own
- * delimiters).
- */
+/* keywords may not hold ':' or ','; the index cannot escape them */
 #define MBOX_FLAG_ANSWERED	(1U << 0)
 #define MBOX_FLAG_FLAGGED	(1U << 1)
 #define MBOX_FLAG_DELETED	(1U << 2)
 #define MBOX_FLAG_SEEN		(1U << 3)
 #define MBOX_FLAG_DRAFT		(1U << 4)
 
-#define MBOX_STORE_SET		0	/* FLAGS, replace outright */
-#define MBOX_STORE_ADD		1	/* +FLAGS, union in */
-#define MBOX_STORE_REMOVE	2	/* -FLAGS, subtract out */
+#define MBOX_STORE_SET		0	/* FLAGS */
+#define MBOX_STORE_ADD		1	/* +FLAGS */
+#define MBOX_STORE_REMOVE	2	/* -FLAGS */
 
 struct imsg_mbox_store {
-	uint32_t	nranges;	/* count of the trailing struct
-					 * seq_range array (RFC 9051 SS9
-					 * sequence-set), same header-plus-
-					 * trailing-array shape as
-					 * imsg_mbox_fetch above; always >= 1,
-					 * STORE always requires a
-					 * sequence-set */
-	int		mode;		/* MBOX_STORE_* above */
-	int		silent;		/* 1 = ".SILENT", suppress the
-					 * untagged FETCH per message */
-	uint32_t	sysflags;	/* MBOX_FLAG_* bitmask named in this
-					 * STORE (flags being set/added/
-					 * removed, not the resulting flags) */
-	char		keywords[MBOX_FLAGS_MAX]; /* comma-separated keyword
-					 * atoms named in this STORE, "" if
-					 * none */
+	uint32_t	nranges;
+	int		mode;
+	int		silent;
+	uint32_t	sysflags;
+	char		keywords[MBOX_FLAGS_MAX];
 
-	/* RFC 7162 SS3.1.3 UNCHANGEDSINCE; has_unchangedsince distinguishes
-	 * "not specified" from the legal (always-fails) value 0.
-	 */
+	/* RFC 7162 SS3.1.3 UNCHANGEDSINCE */
 	int		has_unchangedsince;
 	uint64_t	unchangedsince;
 
-	/* RFC 9051 SS6.4.9: UID-vs-sequence-number switch for UID STORE,
-	 * same as imsg_mbox_fetch's by_uid.
-	 */
 	int		by_uid;
 };
 
-/*
- * IMSG_MBOX_STORE_MODIFIED (store -> listener, zero or more, only when
- * req->has_unchangedsince, before the terminal IMSG_MBOX_RESULT): one
- * message whose mod-sequence exceeded UNCHANGEDSINCE, so the STORE was
- * not performed for it (RFC 7162 SS3.1.3). listener.c range-compacts
- * these into the tagged response's MODIFIED response code.
- */
+/* RFC 7162 SS3.1.3 MODIFIED */
 struct imsg_mbox_store_modified {
 	uint32_t	seqno;
 	uint32_t	uid;
 };
 
-/*
- * IMSG_MBOX_EXPUNGE (listener -> store) / IMSG_MBOX_EXPUNGED (store ->
- * listener, one per removed message, in removal order) / IMSG_MBOX_RESULT
- * (store -> listener, terminal).
- *
- * RFC 9051 SS6.4.3 EXPUNGE removes all \Deleted messages, one untagged
- * EXPUNGE per removal before the tagged OK. Per SS7.5.1's "sequence
- * number immediately decremented by 1" rule, this server removes
- * lowest-numbered first (a "lower to higher" server), matching SS6.4.3's
- * own worked example; see store.c's handle_mbox_expunge() for the
- * compaction algorithm.
- *
- * silent lets CLOSE (SS6.4.1: no untagged EXPUNGE responses) reuse this
- * request/reply pair, same ".SILENT" pattern as STORE.
- */
+/* RFC 9051 SS6.4.3; lowest-numbered first (SS7.5.1) */
 struct imsg_mbox_expunge {
 	int		silent;	/* 1 for CLOSE, 0 for a real EXPUNGE command */
 
-	/* RFC 9051 SS6.4.9's UID EXPUNGE form: only \Deleted messages in
-	 * the trailing struct seq_range array (nranges entries, same
-	 * header-plus-trailing-array shape as imsg_mbox_fetch above) are
-	 * removed. Never set together with silent=1 (no "UID CLOSE").
-	 * Meaningless when !by_uid (plain EXPUNGE/CLOSE take no
-	 * arguments) -- nranges is 0 and there's no trailing data in
-	 * that case, unlike every other sequence-set-bearing imsg here,
-	 * which always require nranges >= 1.
-	 */
+	/* UID EXPUNGE; nranges is 0 unless by_uid */
 	int		by_uid;
 	uint32_t	nranges;
 };
 
 struct imsg_mbox_expunged {
-	uint32_t	seqno;	/* sequence number at the moment of removal
-				 * (SS7.5.1's "immediately decremented"
-				 * rule), not the UID, not the
-				 * pre-EXPUNGE sequence number */
-	uint32_t	uid;	/* RFC 7162: same message's UID, needed once
-				 * QRESYNC is enabled (SS3.2.10.2 replaces
-				 * EXPUNGE with VANISHED in that case); the
-				 * index line is already gone by the time
-				 * this is sent, so there's no other way to
-				 * learn it */
+	uint32_t	seqno;	/* after earlier EXPUNGEs (RFC 9051 SS7.5.1) */
+	uint32_t	uid;	/* for VANISHED (RFC 7162 SS3.2.10.2) */
 };
 
-/*
- * IMSG_MBOX_COPY (RFC 9051 SS6.4.7) / IMSG_MBOX_MOVE (SS6.4.8) share this
- * request shape, distinguished by which IMSG_MBOX_* type arrived (same
- * pattern as EXPUNGE/CLOSE's .silent).
- *
- * destname is validated by listener.c's copy_move_dispatch() the same
- * way cmd_rename()'s oldname/newname are. store.c's handle_mbox_copy()/
- * handle_mbox_move() fast-path destname == the already-selected mailbox
- * as a single-index operation, and only take the cross-mailbox two-index
- * path (see those functions' own comments, including lock ordering) when
- * it genuinely differs.
- */
+/* RFC 9051 SS6.4.7 COPY and SS6.4.8 MOVE */
 struct imsg_mbox_copy {
 	int		by_uid;
-	uint32_t	nranges;	/* count of the trailing struct
-					 * seq_range array, same header-plus-
-					 * trailing-array shape as
-					 * imsg_mbox_fetch above; always >= 1,
-					 * COPY/MOVE always require a
-					 * sequence-set */
+	uint32_t	nranges;
 	char		destname[MBOX_NAME_MAX];
 };
 
-/*
- * IMSG_MBOX_COPY_MAPPING (store -> listener, zero or more, before the
- * terminal IMSG_MBOX_RESULT): one message's COPYUID mapping (RFC 9051
- * SS7.1). Streamed one pair per message, ascending; listener.c
- * accumulates src_uid/dest_uid into parallel arrays and range-compacts
- * each once the terminal reply arrives, same "store streams raw values,
- * listener compacts" split as ESEARCH/MODIFIED.
- *
- * Used for both COPY and MOVE. For MOVE, listener.c also buffers each
- * IMSG_MBOX_EXPUNGED from the same round trip and flushes both buffers
- * in order. COPYUID first, then EXPUNGE/VANISHED, per SS6.4.8's
- * requirement to send COPYUID before EXPUNGE. Mirrors session_handle_
- * mbox_selected()'s dual-buffer-then-flush pattern for QRESYNC resync.
- */
+/* COPYUID pairs, sent before EXPUNGE for MOVE (RFC 9051 SS6.4.8) */
 struct imsg_mbox_copy_mapping {
 	uint32_t	src_uid;
 	uint32_t	dest_uid;
 };
 
-/*
- * IMSG_MBOX_APPEND (listener -> store) / IMSG_MBOX_APPENDED (store ->
- * listener, exactly once).
- *
- * 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.
- */
+/* the literal follows as APPEND_DATA pieces, then APPEND_END */
 struct imsg_mbox_append {
 	char		mailbox[MBOX_NAME_MAX];
-	uint32_t	sysflags;	/* MBOX_FLAG_* bitmask; an omitted or
-					 * empty flag-list means 0 (SS6.3.12) */
-	char		keywords[MBOX_FLAGS_MAX]; /* comma-separated, same
-					 * convention as imsg_mbox_store's */
-	int		has_date;	/* 0 = use current time at delivery
-					 * (SS6.3.12) */
-	int64_t		date;		/* Unix timestamp; meaningful only if
-					 * has_date */
-	uint64_t	msglen;		/* announced literal length; the
-					 * DATA pieces must add up to it */
+	uint32_t	sysflags;
+	char		keywords[MBOX_FLAGS_MAX];
+	int		has_date;
+	int64_t		date;
+	uint64_t	msglen;
 };
 
 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,
-					 * plain NO */
+	enum mbox_op_error error;	/* NO_SUCH_MAILBOX gets [TRYCREATE] */
 	uint32_t	uidvalidity;
-	uint32_t	uid;		/* appended message's UID; with
-					 * uidvalidity, this is SS7.1's
-					 * APPENDUID response code */
-	uint32_t	exists;		/* mailbox's new total message count,
-					 * for SS6.3.12's untagged EXISTS
-					 * notification, sent only if this
-					 * session has the mailbox selected */
+	uint32_t	uid;		/* APPENDUID, with uidvalidity */
+	uint32_t	exists;
 };
 
-/*
- * IMSG_MBOX_SEARCH (listener -> store) / IMSG_MBOX_SEARCH_MATCH (store ->
- * listener, one per match, ascending sequence order) / IMSG_MBOX_RESULT
- * (store -> listener, terminal; count is the number of matches, used
- * directly as COUNT if requested).
- *
- * RFC 9051 SS6.4.4 SEARCH's search-key grammar nests arbitrarily, so it
- * can't be a single fixed-size struct. search_cmd.c's parse_search_key()/
- * parse_search_key_list() compile the whole search-program into a flat
- * postfix array of struct search_node, sent as variable-length trailing
- * data after a small fixed header, same imsg_get_buf()/imsg_get_len()
- * technique as IMSG_MBOX_APPEND. SEARCH_PROGRAM_MAX_NODES bounds both
- * wire size and evaluation stack depth; an oversized query gets a plain
- * BAD.
- */
+/* RFC 9051 SS6.4.4 search-program, as a postfix array of nodes */
 #define SEARCH_PROGRAM_MAX_NODES	100
-#define SEARCH_KEYWORD_MAX		64	/* one flag-keyword atom, not
-						 * a list, MBOX_FLAGS_MAX is
-						 * sized for STORE's comma-
-						 * joined keyword *list* and
-						 * would be the wrong constant
-						 * to reuse here */
+#define SEARCH_KEYWORD_MAX		64	/* one keyword, not a list */
+/* operand pool; RFC 9051 SS4.3's non-synchronizing literal cap */
+#define SEARCH_OPERANDS_MAX		4096
 
-#define SEARCH_OP_ALL		0	/* leaf: matches every message */
+#define SEARCH_OP_ALL		0
 #define SEARCH_OP_ANSWERED	1
 #define SEARCH_OP_UNANSWERED	2
 #define SEARCH_OP_DELETED	3
@@ -1025,170 +597,89 @@ struct imsg_mbox_appended {
 #define SEARCH_OP_UNFLAGGED	8
 #define SEARCH_OP_SEEN		9
 #define SEARCH_OP_UNSEEN	10
-#define SEARCH_OP_KEYWORD	11	/* operand: keyword */
-#define SEARCH_OP_UNKEYWORD	12	/* operand: keyword */
-#define SEARCH_OP_BEFORE	13	/* operand: num (UTC day-start epoch) */
-#define SEARCH_OP_ON		14	/* operand: num (UTC day-start epoch) */
-#define SEARCH_OP_SINCE		15	/* operand: num (UTC day-start epoch) */
-#define SEARCH_OP_LARGER	16	/* operand: num (octets) */
-#define SEARCH_OP_SMALLER	17	/* operand: num (octets) */
-#define SEARCH_OP_SEQSET	18	/* operand: seq_lo/seq_hi/lo_is_star/
-					 * hi_is_star, matched against sequence
-					 * number */
-#define SEARCH_OP_UIDSET	19	/* operand: seq_lo/seq_hi/lo_is_star/
-					 * hi_is_star, matched against UID */
-#define SEARCH_OP_AND		20	/* postfix binary combinator */
-#define SEARCH_OP_OR		21	/* postfix binary combinator */
-#define SEARCH_OP_NOT		22	/* postfix unary combinator */
-#define SEARCH_OP_MODSEQ	23	/* RFC 7162 SS3.1.5, operand: num,
-					 * matches if the message's own
-					 * mod-sequence is >= this. The
-					 * optional <entry-name>/<entry-type-
-					 * req> prefix is parsed by
-					 * listener.c for syntax only and
-					 * never reaches this struct */
+#define SEARCH_OP_KEYWORD	11
+#define SEARCH_OP_UNKEYWORD	12
+#define SEARCH_OP_BEFORE	13
+#define SEARCH_OP_ON		14
+#define SEARCH_OP_SINCE		15
+#define SEARCH_OP_LARGER	16
+#define SEARCH_OP_SMALLER	17
+#define SEARCH_OP_SEQSET	18
+#define SEARCH_OP_UIDSET	19
+#define SEARCH_OP_AND		20
+#define SEARCH_OP_OR		21
+#define SEARCH_OP_NOT		22
+#define SEARCH_OP_MODSEQ	23	/* RFC 7162 SS3.1.5 */
+/* RFC 9051 SS6.4.4 content keys, answered by the parser-worker */
+#define SEARCH_OP_SUBJECT	24
+#define SEARCH_OP_HEADER	25
+#define SEARCH_OP_SENTBEFORE	26
+#define SEARCH_OP_SENTON	27
+#define SEARCH_OP_SENTSINCE	28
+#define SEARCH_OP_FROM		29
+#define SEARCH_OP_TO		30
+#define SEARCH_OP_CC		31
+#define SEARCH_OP_BCC		32
 
 struct search_node {
-	int		op;		/* SEARCH_OP_* above */
-	int64_t		num;		/* BEFORE/ON/SINCE/LARGER/SMALLER/MODSEQ
-					 * operand */
-	uint32_t	seq_lo;		/* SEQSET/UIDSET operand, same "*"
-					 * convention as imsg_mbox_fetch;
-					 * store.c resolves it against live
-					 * idx.nlines (SEQSET) or highest
-					 * in-use UID (UIDSET) up front */
+	int		op;
+	int64_t		num;
+	uint32_t	seq_lo;
 	uint32_t	seq_hi;
 	int		lo_is_star;
 	int		hi_is_star;
-	char		keyword[SEARCH_KEYWORD_MAX]; /* KEYWORD/UNKEYWORD
-					 * operand */
+	char		keyword[SEARCH_KEYWORD_MAX];
+	uint32_t	str_off;	/* into the pool */
+	uint32_t	str_len;
+	uint32_t	name_off;
+	uint32_t	name_len;
 };
 
 struct imsg_mbox_search {
-	uint32_t	nnodes;		/* number of struct search_node entries
-					 * in this imsg's trailing data */
+	uint32_t	nnodes;
+	uint32_t	poollen;
+	char		pool[SEARCH_OPERANDS_MAX];
 };
 
-/*
- * IMSG_SEARCH_PARSE_REQUEST's raw trailing bytes (already-
- * buffered SEARCH argument text, post RETURN/CHARSET) are sized
- * against this rather than left unbounded: generously bigger
- * than any single argument list could legitimately be, since
- * the whole command line it was sliced from is already capped
- * at listener.h's 8192-byte SESSION_INBUF_MAX.
- */
-#define SEARCH_ORACLE_ARGS_MAX		8192
+#define SEARCH_ARGS_MAX			8192
 
-/*
- * IMSG_SEARCH_PARSE_RESULT (search-oracle -> listener): reply to
- * IMSG_SEARCH_PARSE_REQUEST, echoing parse_search_key_list()'s own
- * (rc, errmsg) contract (search_cmd.c) -- rc == 0: nnodes valid,
- * struct search_node[nnodes] trails, same wire shape
- * imsg_mbox_search above already uses; errmsg unused. rc == -1:
- * BAD, errmsg set, nnodes/trailing data unused. rc == -2: NO,
- * errmsg set, same. Every errmsg parse_search_key_list() and its
- * helpers produce is a static string literal (search_cmd.c has no
- * runtime-formatted SEARCH parse error), so
- * SEARCH_ORACLE_ERRMSG_MAX only needs to cover the longest one,
- * with headroom. uses_modseq mirrors struct search_parse_ctx's
- * own field of the same name (search_cmd.c, opaque to every
- * caller outside that file) -- RFC 7162 SS3.1: a SEARCH
- * including the MODSEQ data item is a CONDSTORE-enabling
- * command. Valid only when rc == 0, same as nnodes; a
- * rejected parse never reaches search_dispatch()'s CONDSTORE
- * check.
- */
-#define SEARCH_ORACLE_ERRMSG_MAX	128
-struct imsg_search_parse_result {
-	int		rc;		/* 0 ok, -1 BAD, -2 NO */
-	/*
-	 * valid when rc == 0; struct search_node[nnodes] trails, same technique
-	 * as imsg_mbox_search above
-	 */
+/* rc: 0 ok, -1 BAD, -2 NO; errmsg is a static string */
+#define SEARCH_ERRMSG_MAX		128
+struct search_parse_result {
+	int		rc;
 	uint32_t	nnodes;
-	int		uses_modseq;	/* valid when rc == 0, see this
-					 * struct's own comment */
-	char		errmsg[SEARCH_ORACLE_ERRMSG_MAX]; /* valid when
-					 * rc != 0 */
+	int		uses_modseq;
+	char		errmsg[SEARCH_ERRMSG_MAX];
+	uint32_t	poollen;
+	char		pool[SEARCH_OPERANDS_MAX];
 };
 
 struct imsg_mbox_search_match {
 	uint32_t	seqno;
 	uint32_t	uid;
-	uint64_t	modseq;		/* RFC 7162 SS3.1.6: highest
-					 * mod-sequence among returned
-					 * matches, required whenever the
-					 * client used a MODSEQ criterion.
-					 * Always populated (cheap);
-					 * listener.c tracks the running max
-					 * only when SEARCH_OP_MODSEQ was
-					 * actually used. */
+	uint64_t	modseq;		/* RFC 7162 SS3.1.6 */
 };
 
-/*
- * 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).
- *
- * 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.
- *
- * 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().
- *
- * 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.
- */
+/* RFC 9051 SS6.3.13 IDLE: seeded once, then a diff per poll */
 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.
-	 */
+	/* adopt the current state as baseline, report nothing */
 	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) */
+	uint32_t	seqno;
 };
 
 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_EXPUNGE messages preceded this one and every field
-	 * below is left zero and is meaningless.
-	 */
+	/* the probe saw no change; the fields below are zero */
 	int		unchanged;
-	int		exists_changed;	/* print exists as "* n EXISTS" */
+	int		exists_changed;
 	uint32_t	exists;
 	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,
- * flat, non-nested mailboxes as sibling subdirectories of the
- * session's own per-user maildir root; CREATE/DELETE/RENAME all reply with
- * the existing struct imsg_mbox_result, only "ok" meaningful). listener.c
- * has already validated the name syntactically (non-empty, not "INBOX",
- * no hierarchy-delimiter character, within MBOX_NAME_MAX) before either of
- * these is ever sent, store.c re-validates independently rather than
- * trusting that, the same defense-in-depth every other mailbox-name-
- * carrying imsg in this file already gets across the privsep boundary.
- */
+/* RFC 9051 SS6.3.4/SS6.3.5; store.c re-validates the name */
 struct imsg_mbox_create {
 	char		mailbox[MBOX_NAME_MAX];
 };
@@ -1197,50 +688,24 @@ struct imsg_mbox_delete {
 	char		mailbox[MBOX_NAME_MAX];
 };
 
-/*
- * RFC 9051 SS6.3.6 (RENAME). oldname/newname, not "mailbox"/"destination",
- * to avoid confusion with imsg_mbox_copy's destination field, RENAME's
- * two names are peers in the same flat namespace, not a source-range-
- * plus-destination pairing like COPY/MOVE.
- */
+/* RFC 9051 SS6.3.6 RENAME */
 struct imsg_mbox_rename {
 	char		oldname[MBOX_NAME_MAX];
 	char		newname[MBOX_NAME_MAX];
 };
 
-/*
- * RFC 9051 SS6.3.9 (LIST). One IMSG_MBOX_LIST_ITEM per 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.
- */
+/* RFC 9051 SS6.3.9 LIST; unordered, INBOX excluded */
 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.
- */
+/* exists 0: subscribed, no mailbox (RFC 9051 SS6.3.9.6) */
 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.
- */
+/* RFC 9051 SS6.3.7 SUBSCRIBE and SS6.3.8 UNSUBSCRIBE */
 struct imsg_mbox_subscribe {
 	char		mailbox[MBOX_NAME_MAX];
 };
@@ -1253,66 +718,29 @@ int		 config_load(const char *, struct openimap_config
 int		 cmdline_symset(char *);
 
 /* parent.c */
-/* Config file path, threaded from main.c's conffile local, so
- * sighup_handler() can re-run config_load() against the same path.
- */
 __dead void	 parent_main(const char *, int, char *[],
 		    struct openimap_config *);
 
-/* listener.c / auth.c / store.c take no struct openimap_config *, each
- * gets exactly the config it needs over its fd-3 channel instead:
- * IMSG_LISTENER_SESSION_INIT, IMSG_AUTH_INIT, IMSG_STORE_INIT respectively.
- * search_oracle.c needs no config at all -- see its own comment.
- */
+/* each role has its own file; its config arrives over fd 3 */
 __dead void	 listener_main(void);
 __dead void	 auth_main(void);
 __dead void	 store_main(void);
 __dead void	 keymgr_main(void);
-__dead void	 search_oracle_main(void);
+__dead void	 parser_main(void);
 
-/*
- * search_oracle.c's one entry point into search_cmd.c's otherwise-
- * private struct search_parse_ctx, see search_oracle_parse()'s own
- * comment (search_cmd.c). Declared here, not in listener.h, on
- * purpose: search_oracle.c is a separate role, not part of listener.h's
- * listener.c/auth_cmd.c/mailbox_cmd.c/append_cmd.c/fetch_cmd.c/
- * search_cmd.c/store_cmd.c/store_ipc.c family, and pulling that whole
- * header in for one prototype is what caused search_oracle.c's own
- * file-scope `static struct imsgev iev_parent` to collide with
- * listener.h's unrelated `extern struct imsgev iev_parent` (listener's
- * own long-lived fd-3 channel) -- same identifier, incompatible
- * linkage, a real build failure on premio. search_cmd.c already
- * includes this header too, so its own view of the prototype is
- * unchanged by the move.
- */
-int	 search_oracle_parse(char *, struct search_node *, uint32_t *,
-	    int *, char *, size_t);
-
-/* imsg helpers shared by all roles. The "handler" passed to
- * imsgev_init() is the libevent callback, expected to run its own
- * imsgbuf_read()/imsgbuf_get() loop (parent_dispatch_child() is the
- * reference shape), and to end with imsgev_rearm_read().
- */
+/* imsgev.c */
 void		 imsgev_ibuf_init(struct imsgbuf *, int);
 void		 imsgev_init(struct imsgev *, int,
 		    void (*)(int, short, void *), void *);
 void		 imsgev_init_from_ibuf(struct imsgev *, const struct imsgbuf *,
 		    void (*)(int, short, void *), void *);
-/* You do NOT need to call this after an imsg_compose(). imsgev_init() installs
- * imsgev_on_compose() as the channel's imsg close callback, so libutil arms
- * EV_WRITE from inside imsg_close() on every queueing path. This is now only
- * for the rare caller that must arm a channel it did not just compose on. */
 void		 imsgev_add(struct imsgev *);
 
-/* Same work as imsgev_add(), deliberately under a different name: this is
- * the one a dispatch handler MUST call before returning. See its definition
- * in imsgev.c for why the two are spelled apart. */
+/* a dispatch handler must call this before returning */
 void		 imsgev_rearm_read(struct imsgev *);
 
-/* Boot-time setup-loop helpers: a freshly exec'd child blocks reading
- * fd 3 for zero or more IMSG_SETUP_PEER messages, then IMSG_SETUP_DONE,
- * and acks. */
 int		 setup_recv_one_peer(struct imsgbuf *);
+int		 setup_recv_one_peer_id(struct imsgbuf *, uint32_t *);
 void		 setup_recv_done_and_ack(struct imsgbuf *);
 
 #endif /* IMAPD_H */
blob - 52f95b54c764213581617143650d8138ecb2e94b
blob + 4abc10cf7e54c09814b8651cf7a107c6d0ec0c43
--- src/imsgev.c
+++ src/imsgev.c
@@ -37,12 +37,6 @@
  * 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.
- */
-
 #include <sys/types.h>
 
 #include <event.h>
@@ -52,12 +46,6 @@
 #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.
- */
 void
 imsgev_ibuf_init(struct imsgbuf *ibuf, int fd)
 {
@@ -68,11 +56,6 @@ 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.
- */
 static void
 imsgev_on_compose(struct imsgbuf *ibuf, void *arg)
 {
@@ -98,17 +81,11 @@ 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.
-	 */
+	/* after event_set() and imsgbuf_init() */
 	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 (no re-init) */
 void
 imsgev_init_from_ibuf(struct imsgev *iev, const struct imsgbuf *ibuf,
     void (*handler)(int, short, void *), void *data)
@@ -128,7 +105,6 @@ 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 */
 void
 imsgev_add(struct imsgev *iev)
 {
@@ -142,23 +118,16 @@ 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.
- */
+/* EV_READ lacks EV_PERSIST: every handler must re-arm it */
 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
- */
+/* imsgbuf_get() before imsgbuf_read(): replies may coalesce */
 int
-setup_recv_one_peer(struct imsgbuf *ibuf3)
+setup_recv_one_peer_id(struct imsgbuf *ibuf3, uint32_t *id_out)
 {
 	struct imsg	 imsg;
 	ssize_t		 n;
@@ -182,14 +151,19 @@ setup_recv_one_peer(struct imsgbuf *ibuf3)
 	if ((fd = imsg_get_fd(&imsg)) == -1)
 		fatalx("setup_recv_one_peer: IMSG_SETUP_PEER carried no fd");
 
+	if (id_out != NULL)
+		*id_out = imsg_get_id(&imsg);
+
 	imsg_free(&imsg);
 	return (fd);
 }
 
-/*
- * blocks for IMSG_SETUP_DONE, then sends one back as an ack (see
- * setup_recv_one_peer() re: imsgbuf_get() ordering)
- */
+int
+setup_recv_one_peer(struct imsgbuf *ibuf3)
+{
+	return (setup_recv_one_peer_id(ibuf3, NULL));
+}
+
 void
 setup_recv_done_and_ack(struct imsgbuf *ibuf3)
 {
blob - 294bf25f0d4dfad1fb3c5ff997f48046af9e8abe
blob + cf71d95cf2cc0880c154fda71df483e955db9cae
--- src/index.c
+++ src/index.c
@@ -16,7 +16,6 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* index.c: maildir index format -- load/save/append, QRESYNC, vanished-UID. */
 
 #include <sys/types.h>
 #include <sys/file.h>
@@ -38,16 +37,8 @@
 #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.
- */
+/* no ':', CR or LF in any index field */
+/* RFC 7162 SS7 */
 #define INDEX_MODSEQ_MAX	INT64_MAX
 
 int
@@ -56,20 +47,12 @@ 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.
- */
+/* unveil(2) only keeps a path inside the maildir */
 int
 index_basename_valid(const char *basename)
 {
 	const unsigned char	*p;
 
-	/*
-	 * Strictly stronger than index_field_valid(): whatever is unsafe to
-	 * write into a line is also unsafe to paste into a path.
-	 */
 	if (!index_field_valid(basename))
 		return (0);
 	/* excludes "", ".", "..", and dotfiles in one test */
@@ -82,12 +65,6 @@ 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).
- */
 static int
 index_lines_grow(struct mbox_index *idx)
 {
@@ -133,11 +110,7 @@ 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) splits an over-long line */
 		if (strchr(line, '\n') == NULL &&
 		    strlen(line) == sizeof(line) - 1) {
 			int	c = fgetc(fp);
@@ -164,18 +137,7 @@ 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. 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.
-			 */
+			/* strtoul(3) accepts leading space and a sign */
 			if (line[0] < '0' || line[0] > '9') {
 				log_warnx("session %u: malformed "
 				    "UIDVALIDITY: %s", session_id, line);
@@ -191,10 +153,7 @@ index_load(int fd, struct mbox_index *idx)
 			}
 			idx->uidvalidity = (uint32_t)parsed;
 
-			/*
-			 * RFC 7162: optional third field HIGHESTMODSEQ; NULL
-			 * means older two-field header, defaults to 1
-			 */
+			/* RFC 7162: optional HIGHESTMODSEQ; absent means 1 */
 			if ((colon2 = strchr(colon + 1, ':')) != NULL)
 				*colon2 = '\0';
 
@@ -259,17 +218,11 @@ 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().
-	 */
 	index_free(idx);
 	fclose(fp);
 	return (-1);
 }
 
-/* Parses "UID:basename:keywords[:MODSEQ]"; -1 (logged) on corrupt line. */
 int
 index_parse_line(const char *line, struct index_rec *rec)
 {
@@ -279,21 +232,11 @@ index_parse_line(const char *line, struct index_rec *r
 
 	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.
-	 */
 	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;
 	parsed = strtoul(line, &ep, 10);
 	if (*ep != ':' || errno != 0 || parsed > UINT32_MAX) {
@@ -315,12 +258,6 @@ 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.
-	 */
 	if (!index_basename_valid(rec->basename)) {
 		log_warnx("session %u: refusing index line with unsafe "
 		    "basename (UID %u)", session_id, rec->uid);
@@ -338,13 +275,6 @@ 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.
-		 */
 		if (r[1] < '0' || r[1] > '9') {
 			log_warnx("session %u: malformed per-message MODSEQ "
 			    "in index line: %s", session_id, line);
@@ -359,10 +289,7 @@ index_parse_line(const char *line, struct index_rec *r
 			return (-1);
 		}
 	} else {
-		/*
-		 * no MODSEQ field: pre-CONDSTORE line (index_rec
-		 * backward-compat)
-		 */
+		/* a pre-CONDSTORE line */
 		if (strlcpy(rec->keywords, p, sizeof(rec->keywords)) >=
 		    sizeof(rec->keywords)) {
 			log_warnx("session %u: keywords too long in index "
@@ -375,10 +302,7 @@ 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.
- */
+/* "*" is the highest present UID, not uidnext-1 */
 uint32_t
 index_max_uid(struct mbox_index *idx)
 {
@@ -387,26 +311,11 @@ index_max_uid(struct mbox_index *idx)
 	if (idx->nlines == 0)
 		return (0);
 
-	/*
-	 * 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.
- */
 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])
@@ -434,7 +343,6 @@ seqset_resolve(const struct seq_range *ranges, uint32_
 	return (n);
 }
 
-/* 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)
@@ -448,11 +356,6 @@ 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.
- */
 uint32_t
 seqset_max_hi(const struct seq_range *resolved, uint32_t nresolved)
 {
@@ -465,16 +368,7 @@ 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.
- */
+/* RFC 9051 SS6.4.9: a UID command's set is in UID space */
 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)
@@ -488,7 +382,7 @@ seqset_position(const struct seq_range *resolved, uint
 	return (SEQSET_MATCH);
 }
 
-/* Reports UID in [lo, hi] absent from idx as VANISHED; RFC 7162 SS3.2.6. */
+/* RFC 7162 SS3.2.6 */
 void
 send_vanished_range(const struct mbox_index *idx, uint32_t lo, uint32_t hi,
     struct imsgev *iev)
@@ -521,11 +415,7 @@ 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.
-		 */
+		/* UINT32_MAX would wrap to 0 */
 		if (rec.uid == UINT32_MAX)
 			return;
 		want = rec.uid + 1;
@@ -544,7 +434,6 @@ send_vanished_range(const struct mbox_index *idx, uint
 	}
 }
 
-/* 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)
 {
@@ -568,31 +457,19 @@ index_has_basename(struct mbox_index *idx, const char 
 	return (0);
 }
 
-/* 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; refuse rather than wrap */
 	if (uid == 0) {
 		log_warnx("session %u: refusing index entry with UID 0 "
 		    "(uidnext exhausted or index header corrupt)", session_id);
 		return (-1);
 	}
 
-	/*
-	 * defense in depth: refuse a basename containing ':' or newline
-	 * (refresh_index() already pre-skips these)
-	 */
 	if (strpbrk(basename, ":\r\n") != NULL) {
 		log_warnx("session %u: refusing index entry with unsafe "
 		    "basename: %s", session_id, basename);
@@ -619,10 +496,7 @@ 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.
- */
+/* rename(2), so a reader never sees a torn file */
 int
 index_save(int dfd, const struct mbox_index *idx)
 {
@@ -630,10 +504,7 @@ index_save(int dfd, const struct mbox_index *idx)
 	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
-	 */
+	/* O_EXCL: never follow a planted symlink */
 	if (unlinkat(dfd, STORE_INDEX_TMP_NAME, 0) == -1 &&
 	    errno != ENOENT) {
 		log_warn("session %u: unlink %s", session_id,
@@ -712,33 +583,21 @@ 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 */
 void
 qresync_send_resync(const struct imsg_mbox_select *req,
     const struct seq_range *ranges, uint32_t nranges,
-    struct mbox_index *idx, struct imsgev *iev)
+    struct mbox_index *idx, struct store_session *ss)
 {
+	struct imsgev		*iev = &ss->iev;
 	struct seq_range	resolved[SEQSET_MAX_RANGES];
 	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.
-		 */
 		nresolved = seqset_resolve(ranges, nranges,
 		    index_max_uid(idx), 0, resolved);
 	} else {
-		/*
-		 * SS3.2.5.1: no known-uids means "1:<uidnext-1>", empty if
-		 * uidnext == 1
-		 */
+		/* RFC 7162 SS3.2.5.1: no known-uids means 1:uidnext-1 */
 		if (idx->uidnext <= 1)
 			return;
 		resolved[0].lo = 1;
@@ -747,12 +606,6 @@ 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.
-	 */
 	for (i = 0; i < nresolved; i++)
 		send_vanished_range(idx, resolved[i].lo, resolved[i].hi, iev);
 
@@ -761,9 +614,7 @@ qresync_send_resync(const struct imsg_mbox_select *req
 		struct index_rec	rec;
 
 		if (index_parse_line(idx->lines[i], &rec) == -1)
-			/*
-			 * corrupt line, already logged, not reported either way
-			 */
+			/* corrupt line, already logged */
 			continue;
 		if (rec.uid > max_hi)
 			break;
@@ -779,8 +630,9 @@ 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(mailbox_dir_fd, rec.basename,
-			    &size, suffix, sizeof(suffix)) == 0) {
+			if (locate_message_file(&ss->cur_snap,
+			    ss->mailbox_dir_fd, rec.basename, &size, suffix,
+			    sizeof(suffix)) == 0) {
 				build_flags_string(suffix, rec.keywords,
 				    meta.flags, sizeof(meta.flags));
 			}
@@ -793,18 +645,7 @@ 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().
- *
- * 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).
- */
+/* lock first, then open, so the fd names the current inode */
 int
 index_lock_acquire(int dfd, struct index_lock *il, int op)
 {
@@ -839,10 +680,6 @@ index_lock_acquire(int dfd, struct index_lock *il, int
 	return (0);
 }
 
-/*
- * Drops whatever index_lock_acquire() took; safe to call twice, and safe on an
- * INDEX_LOCK_INIT struct that was never acquired.
- */
 void
 index_lock_release(struct index_lock *il)
 {
@@ -858,15 +695,7 @@ 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.
- */
+/* RFC 9051 SS2.3.1.1: it must increase, so time is only a floor */
 uint32_t
 uidvalidity_next(void)
 {
@@ -880,7 +709,6 @@ uidvalidity_next(void)
 	ssize_t		 n;
 	int		 fd, writeback = 1;
 
-	/* one file per account, at the maildir root */
 	path = STORE_UIDVALIDITY_NAME;
 
 	now = time(NULL);
@@ -899,11 +727,6 @@ 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.
-	 */
 	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)",
@@ -912,11 +735,6 @@ 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
-			 */
 			errno = 0;
 			parsed = strtoul(buf, &ep, 10);
 			if (buf[0] < '0' || buf[0] > '9' || *ep != '\0' ||
@@ -934,10 +752,6 @@ uidvalidity_next(void)
 	if (writeback) {
 		if (val <= floor) {
 			if (floor == UINT32_MAX) {
-				/*
-				 * 4 billion issued, or clock past 2106: nothing
-				 * greater representable.
-				 */
 				log_warnx("session %u: UIDVALIDITY floor is "
 				    "exhausted (%u); reusing it", session_id,
 				    floor);
@@ -960,11 +774,6 @@ 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.
-	 */
 	log_debug("session %u: UIDVALIDITY: floor %u in %s -> issued %u%s",
 	    session_id, floor, path, val,
 	    writeback ? "" : " (floor NOT updated)");
@@ -973,14 +782,6 @@ 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.
- */
 static int
 index_scan_new(int dfd, struct mbox_index *idx, int mutate)
 {
@@ -1007,22 +808,10 @@ index_scan_new(int dfd, struct mbox_index *idx, int mu
 	}
 	while ((de = readdir(dp)) != NULL) {
 		if (de->d_name[0] == '.')
-			/*
-			 * ".", "..", and dotfiles -- maildir delivery never
-			 * creates the latter
-			 */
+			/* ".", "..", and dotfiles */
 			continue;
-		/*
-		 * never index a filename with ':' or newline, corrupts index
-		 * line format
-		 */
+		/* ':' or a newline would corrupt the index line */
 		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.
-			 */
 			if (mutate)
 				log_warnx("session %u: skipping new/ file "
 				    "with unsafe name (contains ':' or "
@@ -1033,7 +822,6 @@ index_scan_new(int dfd, struct mbox_index *idx, int mu
 			continue;
 		if (!mutate) {
 			closedir(dp);
-			/* one is enough to answer the question */
 			return (1);
 		}
 		if (index_append(idx, idx->uidnext, de->d_name) == -1) {
@@ -1048,10 +836,6 @@ index_scan_new(int dfd, struct mbox_index *idx, int mu
 	return (added);
 }
 
-/*
- * Loads the index (fd already flock(2)'d LOCK_EX) and indexes any new/ files
- * not yet known; on failure idx is already index_free()'d.
- */
 int
 refresh_index(int dfd, struct mbox_index *idx, int fd)
 {
@@ -1063,11 +847,7 @@ refresh_index(int dfd, struct mbox_index *idx, int fd)
 	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.
-	 */
+	/* a fresh UIDVALIDITY must still be written down */
 	if (!added && !idx->fresh)
 		return (0);
 
@@ -1078,60 +858,25 @@ refresh_index(int dfd, struct mbox_index *idx, int fd)
 	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.
- */
-static struct {
-	int		 valid;
-	ino_t		 dir_ino;
-	ino_t		 new_ino;
-	struct timespec	 dir_mtim;
-	struct timespec	 new_mtim;
-} idle_probe;
-
-/* Forces next probe to report a change; call whenever cwd changes mailbox. */
 void
-idle_probe_reset(void)
+idle_probe_reset(struct store_session *ss)
 {
-	idle_probe.valid = 0;
+	ss->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)
+idle_baseline_reset(struct store_session *ss)
 {
-	free(idle_baseline.uids);
-	idle_baseline.uids = NULL;
-	idle_baseline.n = 0;
-	idle_baseline.modseq = 0;
-	idle_baseline.valid = 0;
+	struct store_idle_baseline	*base = &ss->idle_baseline;
+
+	free(base->uids);
+	base->uids = NULL;
+	base->n = 0;
+	base->modseq = 0;
+	base->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.
- */
+/* RFC 9051 SS7.5.1: each EXPUNGE renumbers those above it */
 static size_t
 idle_send_expunges(const uint32_t *old, size_t oldn, const uint32_t *cur,
     size_t curn, struct imsgev *iev)
@@ -1159,20 +904,12 @@ idle_send_expunges(const uint32_t *old, size_t oldn, c
 	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.
- */
+/* RFC 9051 SS6.3.13: flag changes, as FETCH with UID */
 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)
+    size_t oldn, uint64_t since, struct store_session *ss)
 {
+	struct imsgev			*iev = &ss->iev;
 	struct imsg_mbox_fetch_meta	 meta;
 	struct index_rec		 rec;
 	char				 suffix[64];
@@ -1189,8 +926,8 @@ idle_send_flag_fetches(const struct mbox_index *idx, c
 			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) {
+		if (locate_message_file(&ss->cur_snap, ss->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);
@@ -1217,44 +954,36 @@ tspec_eq(const struct timespec *a, const struct timesp
 	return (a->tv_sec == b->tv_sec && a->tv_nsec == b->tv_nsec);
 }
 
-/* 1 = nothing can have changed since the last call; 0 = look properly. */
 static int
-idle_probe_unchanged(void)
+idle_probe_unchanged(struct store_session *ss)
 {
-	struct stat	 dst, nst;
-	int		 same;
+	struct store_idle_probe	*probe = &ss->idle_probe;
+	struct stat		 dst, nst;
+	int			 same;
 
-	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;
+	if (fstatat(ss->mailbox_dir_fd, ".", &dst, 0) == -1) {
+		probe->valid = 0;
 		return (0);
 	}
-	if (fstatat(mailbox_dir_fd, "new", &nst, 0) == -1)
+	if (fstatat(ss->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 &&
-	    nst.st_ino == idle_probe.new_ino &&
-	    tspec_eq(&dst.st_mtim, &idle_probe.dir_mtim) &&
-	    tspec_eq(&nst.st_mtim, &idle_probe.new_mtim);
+	same = probe->valid &&
+	    dst.st_ino == probe->dir_ino &&
+	    nst.st_ino == probe->new_ino &&
+	    tspec_eq(&dst.st_mtim, &probe->dir_mtim) &&
+	    tspec_eq(&nst.st_mtim, &probe->new_mtim);
 
-	idle_probe.valid = 1;
-	idle_probe.dir_ino = dst.st_ino;
-	idle_probe.new_ino = nst.st_ino;
-	idle_probe.dir_mtim = dst.st_mtim;
-	idle_probe.new_mtim = nst.st_mtim;
+	probe->valid = 1;
+	probe->dir_ino = dst.st_ino;
+	probe->new_ino = nst.st_ino;
+	probe->dir_mtim = dst.st_mtim;
+	probe->new_mtim = nst.st_mtim;
 
 	return (same);
 }
 
-/*
- * 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)
@@ -1264,11 +993,6 @@ idle_uid_list(const struct mbox_index *idx, uint32_t *
 	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);
@@ -1276,21 +1000,12 @@ idle_uid_list(const struct mbox_index *idx, uint32_t *
 	}
 	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;
 
@@ -1300,23 +1015,20 @@ idle_uid_list(const struct mbox_index *idx, uint32_t *
 	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.
- */
+/* lockless: index_save() only ever replaces the index by rename(2) */
 static int
-idle_seed_unlocked(struct imsg_mbox_idle_refreshed *reply)
+idle_seed_unlocked(struct store_session *ss,
+    struct imsg_mbox_idle_refreshed *reply)
 {
-	struct mbox_index	 idx;
-	uint32_t		*list;
-	uint64_t		 modseq;
-	size_t			 n;
-	int			 fd;
+	struct store_idle_baseline	*base = &ss->idle_baseline;
+	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 ((fd = openat(ss->mailbox_dir_fd, STORE_INDEX_NAME,
+	    O_RDONLY)) == -1) {
 		if (errno != ENOENT)
 			log_warn("session %u: open %s", session_id,
 			    STORE_INDEX_NAME);
@@ -1333,20 +1045,21 @@ idle_seed_unlocked(struct imsg_mbox_idle_refreshed *re
 	}
 	index_free(&idx);
 
-	free(idle_baseline.uids);
-	idle_baseline.uids = list;
-	idle_baseline.n = n;
-	idle_baseline.modseq = modseq;
-	idle_baseline.valid = 1;
+	free(base->uids);
+	base->uids = list;
+	base->n = n;
+	base->modseq = modseq;
+	base->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(const struct imsg_mbox_idle_refresh *req,
-    struct imsgev *iev)
+    struct store_session *ss)
 {
+	struct store_idle_baseline	*base = &ss->idle_baseline;
+	struct imsgev			*iev = &ss->iev;
 	struct mbox_index		 idx;
 	struct imsg_mbox_idle_refreshed reply;
 	struct index_lock		 il = INDEX_LOCK_INIT;
@@ -1357,33 +1070,17 @@ handle_mbox_idle_refresh(const struct imsg_mbox_idle_r
 
 	memset(&reply, 0, sizeof(reply));
 	/* a seed adopts what it finds rather than reporting it */
-	seeded = req->seed || !idle_baseline.valid;
+	seeded = req->seed || !base->valid;
 
-	/*
-	 * 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()) {
+	if (idle_probe_unchanged(ss)) {
 		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.
-		 */
 		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.
-	 */
-	locked = index_lock_acquire(mailbox_dir_fd, &il, LOCK_SH | LOCK_NB);
+	locked = index_lock_acquire(ss->mailbox_dir_fd, &il, LOCK_SH | LOCK_NB);
 	if (locked == 1)
 		goto busy;
 	if (locked == -1)
@@ -1392,60 +1089,45 @@ handle_mbox_idle_refresh(const struct imsg_mbox_idle_r
 		index_lock_release(&il);
 		goto send;
 	}
-	if ((pending = index_scan_new(mailbox_dir_fd, &idx, 0)) == -1) {
+	if ((pending = index_scan_new(ss->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.
-	 */
 	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.
-		 */
+		/* relock LOCK_EX from scratch rather than upgrade */
 		index_free(&idx);
 		index_lock_release(&il);
-		locked = index_lock_acquire(mailbox_dir_fd, &il,
+		locked = index_lock_acquire(ss->mailbox_dir_fd, &il,
 		    LOCK_EX | LOCK_NB);
 		if (locked == 1)
 			goto busy;
 		if (locked == -1)
 			goto send;
-		if (refresh_index(mailbox_dir_fd, &idx, il.fd) == -1) {
+		if (refresh_index(ss->mailbox_dir_fd, &idx, il.fd) == -1) {
 			index_lock_release(&il);
 			goto send;
 		}
 	}
 
 	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;
+		gone = idle_send_expunges(base->uids, base->n, newlist, newn,
+		    iev);
+		changed = idle_send_flag_fetches(&idx, base->uids, base->n,
+		    base->modseq, ss);
+		reply.exists_changed = newn != base->n;
 	}
-	free(idle_baseline.uids);
-	idle_baseline.uids = newlist;
-	idle_baseline.n = newn;
-	idle_baseline.modseq = seen_modseq;
-	idle_baseline.valid = 1;
+	free(base->uids);
+	base->uids = newlist;
+	base->n = newn;
+	base->modseq = seen_modseq;
+	base->valid = 1;
 
 	reply.ok = 1;
 	reply.exists = (uint32_t)newn;
@@ -1461,35 +1143,23 @@ handle_mbox_idle_refresh(const struct imsg_mbox_idle_r
 	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.
-	 */
+	/* RFC 9051 SS7.5.1: a seed cannot skip, or EXPUNGEs repeat */
 	reply.busy = 1;
-	if (seeded && idle_seed_unlocked(&reply) == 0) {
+	if (seeded && idle_seed_unlocked(ss, &reply) == 0) {
 		reply.ok = 1;
 		/* so the next poll reads the index, not the probe */
-		idle_probe_reset();
+		idle_probe_reset(ss);
 	} else if (seeded) {
-		idle_baseline_reset();
+		idle_baseline_reset(ss);
 	}
 	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.
-	 */
+	/* the probe has already consumed this change */
 	if (!reply.ok)
-		idle_probe_reset();
+		idle_probe_reset(ss);
 
 	if (imsg_compose(&iev->ibuf, IMSG_MBOX_IDLE_REFRESHED, 0, 0, -1,
 	    &reply, sizeof(reply)) == -1)
blob - e97c389fb7ab36d090a992f9435cd362e94a1bd5
blob + 2c1312d3accc2cbb04903f56af503731f6550e94
--- src/keymgr.c
+++ src/keymgr.c
@@ -42,13 +42,6 @@
  * 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; 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>
 
@@ -72,19 +65,9 @@
 #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.
- */
 #define KEYMGR_CERT_MAX	8192
 #define KEYMGR_KEY_MAX	8192
 
-/*
- * 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;
@@ -94,34 +77,17 @@ TAILQ_HEAD(keymgr_peer_list, keymgr_peer);
 static struct keymgr_peer_list	 keymgr_peers =
 	    TAILQ_HEAD_INITIALIZER(keymgr_peers);
 
-/* 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];
 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.
- */
 static EVP_PKEY	*keymgr_pkey;
 static char	 keymgr_hash[KEYMGR_HASH_MAX];
 
-/*
- * 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.
- */
+/* set once the boot pair was processed, even if rejected */
 static int	 keymgr_got_init;
 
 static void	 keymgr_key_free(void);
@@ -151,19 +117,9 @@ 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
-	 */
 	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.
-	 */
+	/* the key is final before any peer can ask for a signature */
 	while (!got_cert || !got_key) {
 		if ((n = imsgbuf_get(&ibuf3, &imsg)) == -1)
 			fatal("imsgbuf_get");
@@ -211,7 +167,7 @@ keymgr_main(void)
 		imsg_free(&imsg);
 	}
 
-	/* Content failure here is not fatal, see this file's header comment. */
+	/* a bad key is not fatal: its operations fail instead */
 	if (cert_len == 0 || key_len == 0)
 		log_warnx("no usable TLS cert/key at boot, TLS "
 		    "private-key operations will fail until the next "
@@ -223,19 +179,11 @@ keymgr_main(void)
 	explicit_bzero(key_buf, sizeof(key_buf));
 	keymgr_got_init = 1;
 
-	/*
-	 * 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.
-	 */
+	/* the key arrives over imsg; nothing is read from disk */
 	if (chroot("/var/empty") == -1)
 		fatal("chroot /var/empty");
 	if (chdir("/") == -1)
@@ -248,12 +196,6 @@ keymgr_main(void)
 
 	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();
@@ -262,12 +204,7 @@ 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.
-	 */
+	/* recvfd only: peers arrive, nothing is sent */
 	if (pledge("stdio recvfd", NULL) == -1)
 		fatal("pledge");
 #endif
@@ -276,21 +213,11 @@ 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.
- */
+/* as smtpd's ssl.c hash_x509() */
 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.
-	 */
 	unsigned char		digest[EVP_MAX_MD_SIZE];
 	size_t			off;
 	unsigned int		dlen, i;
@@ -309,8 +236,6 @@ keymgr_pubkey_hash(X509 *cert, char *hash, size_t hash
 	return (0);
 }
 
-/* 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)
 {
@@ -320,12 +245,7 @@ keymgr_key_free(void)
 	}
 }
 
-/*
- * 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.
- */
+/* a bad reload leaves the last good key in place */
 static int
 keymgr_load(const char *cert_buf, size_t cert_len, const char *key_buf,
     size_t key_len)
@@ -363,12 +283,7 @@ 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 may each parse yet not match */
 	if (X509_check_private_key(cert, pkey) != 1) {
 		log_warnx("certificate and private key do not match, not "
 		    "(re)loading");
@@ -379,11 +294,7 @@ keymgr_load(const char *cert_buf, size_t cert_len, con
 	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: the hash does not fill the buffer */
 	(void)strlcpy(keymgr_hash, hash, sizeof(keymgr_hash));
 
 	log_info("TLS key loaded (%s)", keymgr_hash);
@@ -405,13 +316,6 @@ 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.
- */
 static void
 keymgr_try_reload(void)
 {
@@ -425,14 +329,6 @@ 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
- * (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)
 {
@@ -448,15 +344,6 @@ 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().
-			 */
 			log_warnx("parent closed channel, exiting");
 			keymgr_key_free();
 			exit(0);
@@ -546,11 +433,6 @@ keymgr_dispatch_parent(int fd, short event, void *arg)
 	(void)fd;
 }
 
-/*
- * 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)
 {
@@ -559,14 +441,7 @@ 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 drops this peer only */
 	if (event & EV_WRITE) {
 		if (imsgbuf_write(&iev->ibuf) == -1) {
 			log_warnx("session %u: write error on listener "
@@ -616,12 +491,6 @@ 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.
- */
 static void
 keymgr_peer_teardown(struct keymgr_peer *kp)
 {
@@ -632,13 +501,6 @@ 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.
- */
 static int
 keymgr_recv_trailing(struct imsg *imsg, uint32_t len, unsigned char *buf,
     size_t bufsize)
@@ -660,12 +522,7 @@ 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.
- */
+/* the reply reuses the request's type, correlated by id */
 static void
 keymgr_reply(struct imsgev *iev, uint32_t type, uint32_t id, int ok,
     const void *to, size_t tolen)
@@ -674,13 +531,6 @@ keymgr_reply(struct imsgev *iev, uint32_t type, uint32
 	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.
-	 */
 	if (ok && tolen > KEYMGR_DATA_MAX) {
 		log_warnx("keymgr_reply: %zu-byte result exceeds "
 		    "KEYMGR_DATA_MAX (%d), refusing", tolen,
@@ -700,19 +550,11 @@ 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.
-	 */
+	/* may hold a decrypted premaster secret */
 	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 rather than ca.c's hash-keyed dict.
- */
+/* as smtpd's ca.c ca_imsg() */
 static void
 keymgr_handle_rsa(struct imsgev *iev, struct imsg *imsg, uint32_t type,
     uint32_t id)
@@ -730,10 +572,7 @@ 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() does not NUL-terminate */
 	req.hash[sizeof(req.hash) - 1] = '\0';
 
 	if (!keymgr_recv_trailing(imsg, req.fromlen, from, sizeof(from))) {
@@ -782,14 +621,11 @@ 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.
-	 */
+	/* the decrypted premaster secret */
 	explicit_bzero(to, sizeof(to));
 }
 
-/* IMSG_KEYMGR_ECDSA_SIGN: mirrors ca_imsg()'s ECDSA_sign() (ca.c:255-279). */
+/* as smtpd's ca.c ca_imsg() */
 static void
 keymgr_handle_ecdsa(struct imsgev *iev, struct imsg *imsg, uint32_t id)
 {
blob - 1b39f7831b03ae04c5fdd26c4b5eeee105463eb1
blob + a44a4d1a1971a77be423a9b5bb97ba7fe47a958c
--- src/listener.c
+++ src/listener.c
@@ -31,11 +31,6 @@
  * 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.
- */
 
 #include <sys/types.h>
 #include <sys/queue.h>
@@ -74,32 +69,16 @@
 
 struct session_list	 sessions = TAILQ_HEAD_INITIALIZER(sessions);
 
-/*
- * 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 */
+struct imsgev	 iev_parent;
 
-/*
- * 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 int	 auth_granted;
+
+/* keymgr: read synchronously from OpenSSL callbacks, never dispatched */
 static struct imsgbuf	 keymgr_ibuf;
 
 static struct tls_config	*listener_tls_config;
-/* 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;
@@ -109,7 +88,7 @@ uint64_t		 listener_append_max = APPEND_MAX_DEFAULT;
 
 struct imap_cmd_entry {
 	const char	*name;
-	unsigned int	 states;	/* bitmask of 1U << SESSION_* */
+	unsigned int	 states;
 	int		(*handler)(struct session *, const char *, char *);
 };
 
@@ -119,7 +98,7 @@ struct imap_cmd_entry {
 	 (1U << SESSION_SELECTING) | (1U << SESSION_SELECTED) | \
 	 (1U << SESSION_FETCHING) | (1U << SESSION_STORING) | \
 	 (1U << SESSION_EXPUNGING) | (1U << SESSION_APPENDING) | \
-	 (1U << SESSION_SEARCH_PARSING) | (1U << SESSION_SEARCHING) | \
+	 (1U << SESSION_SEARCHING) | \
 	 (1U << SESSION_STATUSING) | \
 	 (1U << SESSION_COPYING) | (1U << SESSION_CREATING) | \
 	 (1U << SESSION_DELETING) | (1U << SESSION_RENAMING) | \
@@ -169,19 +148,9 @@ 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. Being
- * an internal symbol, it can change or vanish without notice.
- */
+/* internal to libtls, declared as smtpd/smtp.c does */
 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.
- */
 static int
 keymgr_set_fake_keypair(struct tls_config *config, const char *cert_buf,
     size_t cert_len)
@@ -191,24 +160,13 @@ 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.
- */
+/* private-key operations go to keymgr, after smtpd's ca.c */
 
 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.
- */
 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)
@@ -275,12 +233,7 @@ 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.
-		 */
+		/* bounded by OpenSSL's buffer, tosize */
 		if (rep.ok && rep.tolen <= tosize &&
 		    imsg_get_len(&imsg) == rep.tolen) {
 			if (imsg_get_buf(&imsg, to, rep.tolen) == -1)
@@ -380,45 +333,24 @@ keymgr_forward_ecdsa(const char *hash, const unsigned 
 	return (sig);
 }
 
-/*
- * 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.
- */
+/* a failure means privilege separation is not 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: the placeholder key has no private part */
 	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: one keypair, so a missing tag is a regression */
 	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: NUL-terminated within KEYMGR_HASH_MAX */
 	for (i = 0; i < KEYMGR_HASH_MAX; i++)
 		if (hash[i] == '\0')
 			return;
@@ -454,10 +386,6 @@ keymgr_ecdsa_do_sign(const unsigned char *dgst, int dg
 {
 	const char	*hash = EC_KEY_get_ex_data(eckey, 0);
 
-	/*
-	 * inv/rp: ECDSA_sign_setup() precomputation, unused; keymgr does the
-	 * op.
-	 */
 	(void)inv;
 	(void)rp;
 
@@ -508,12 +436,6 @@ 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).
- */
 static void
 keymgr_engine_init(void)
 {
@@ -521,7 +443,6 @@ keymgr_engine_init(void)
 	keymgr_ecdsa_engine_init();
 }
 
-/* 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);
 
@@ -532,7 +453,6 @@ listener_main(void)
 	struct passwd				*pw;
 	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;
@@ -544,19 +464,8 @@ listener_main(void)
 
 	memset(&sinit, 0, sizeof(sinit));
 
-	/*
-	 * fd-passing allowed here: receives fd-passed peer/session
-	 * messages below; see imsgev_ibuf_init().
-	 */
 	imsgev_ibuf_init(&ibuf3, 3);
 
-	/*
-	 * 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");
@@ -578,13 +487,6 @@ 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.
-			 */
 			if (id == 0) {
 				if (auth_peer_fd != -1) {
 					log_warnx("listener: duplicate auth "
@@ -605,26 +507,6 @@ listener_main(void)
 			}
 			break;
 		}
-		case IMSG_SETUP_SEARCH_PEER: {
-			int	peer_fd = imsg_get_fd(&imsg);
-
-			/*
-			 * Optional, like the auth peer above -- not
-			 * gated by the while() condition, spawn_connection()
-			 * may not have wired one at all
-			 */
-			if (peer_fd == -1)
-				log_warnx("listener: IMSG_SETUP_SEARCH_PEER "
-				    "carried no fd");
-			else if (search_peer_fd != -1) {
-				/* already claimed above, so close it here */
-				log_warnx("listener: duplicate "
-				    "IMSG_SETUP_SEARCH_PEER, ignoring");
-				close(peer_fd);
-			} else
-				search_peer_fd = peer_fd;
-			break;
-		}
 		case IMSG_TLS_CERT:
 			cert_len = imsg_get_len(&imsg);
 			if (cert_len > sizeof(cert_buf)) {
@@ -645,12 +527,6 @@ 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.
-			 */
 			if (client_fd != -1) {
 				log_warnx("listener: duplicate "
 				    "IMSG_LISTENER_SESSION_INIT, ignoring");
@@ -686,16 +562,9 @@ listener_main(void)
 	    setresuid(pw->pw_uid, pw->pw_uid, pw->pw_uid) == -1)
 		fatal("cannot drop privileges to _imapd");
 
-	/*
-	 * Installs RSA_METHOD/EC_KEY_METHOD override before tls_config touches
-	 * key.
-	 */
 	keymgr_engine_init();
 
-	/*
-	 * Failure isn't fatal; degrades to no-TLS, checked via
-	 * listener_tls_ctx.
-	 */
+	/* no TLS rather than no daemon */
 	if (cert_len == 0) {
 		log_warnx("listener: no TLS cert received, TLS "
 		    "disabled for this session");
@@ -737,12 +606,6 @@ 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.
-	 */
 	if (auth_peer_fd != -1)
 		imsgev_init(&iev_auth, auth_peer_fd, listener_dispatch_auth,
 		    NULL);
@@ -753,41 +616,14 @@ listener_main(void)
 		    "connection gets one", sinit.session_id);
 	}
 
-	/*
-	 * 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,
-		    NULL);
-	else {
-		iev_search.ibuf.fd = -1;
-		log_warnx("session %u: no search-oracle was spawned for this "
-		    "connection, SEARCH will fail until a new connection gets "
-		    "one", sinit.session_id);
-	}
-
 	if (imsgbuf_init(&keymgr_ibuf, keymgr_peer_fd) == -1)
 		fatal("imsgbuf_init keymgr");
 	imsgbuf_set_maxsize(&keymgr_ibuf, MAX_IMSGSIZE);
 
-	/*
-	 * Reuses fd 3's populated ibuf; imsgbuf_init() would drop buffered
-	 * bytes.
-	 */
+	/* keeps fd 3's 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.
-	 */
 	listener_idle_poll_secs = sinit.idle_poll_secs;
 	listener_login_grace_secs = sinit.login_grace_secs;
 	listener_append_max = sinit.append_max;
@@ -795,14 +631,7 @@ listener_main(void)
 	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 (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.
-	 */
+	/* no "inet": getnameinfo(3) only formats numeric bytes */
 #ifdef __OpenBSD__
 	if (pledge("stdio recvfd", NULL) == -1)
 		fatal("pledge");
@@ -812,16 +641,6 @@ listener_main(void)
 	fatalx("listener: exited event loop");
 }
 
-/*
- * 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)
 {
@@ -835,11 +654,6 @@ session_login_grace_expired(int fd, short event, void 
 	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)
 {
@@ -863,13 +677,6 @@ 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)
@@ -880,12 +687,6 @@ 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(1);
 	}
 	s->pending_body_fd = -1;	/* calloc(3)'s 0 is a real descriptor */
@@ -895,7 +696,6 @@ listener_start_session(uint32_t session_id, int client
 	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);
@@ -903,12 +703,7 @@ listener_start_session(uint32_t session_id, int client
 	{
 		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.
-		 */
+		/* numeric: no resolver or network I/O */
 		if (getnameinfo((const struct sockaddr *)ss, sslen, hbuf,
 		    sizeof(hbuf), sbuf, sizeof(sbuf),
 		    NI_NUMERICHOST | NI_NUMERICSERV) == 0)
@@ -944,7 +739,6 @@ listener_start_session(uint32_t session_id, int client
 	session_send_greeting(s);
 }
 
-/* (Re-)registers client_ev for steady-state reads; guards re-registration. */
 void
 session_arm_client_read(struct session *s)
 {
@@ -956,7 +750,6 @@ session_arm_client_read(struct session *s)
 	s->client_ev_added = 1;
 }
 
-/* Creates per-conn struct tls (non-blocking), arms client_ev to drive it. */
 void
 session_tls_start(struct session *s)
 {
@@ -976,7 +769,6 @@ session_tls_start(struct session *s)
 	s->client_ev_added = 1;
 }
 
-/* Drives non-blocking TLS handshake, re-arms client_ev for wanted direction. */
 void
 session_tls_handshake(int fd, short event, void *arg)
 {
@@ -1028,19 +820,12 @@ session_send_greeting(struct session *s)
 	session_write(s, greeting, sizeof(greeting) - 1);
 }
 
-/* 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.
- */
 static int
 line_nonsync_literal(const char *line, uint64_t *lenp)
 {
@@ -1053,7 +838,7 @@ line_nonsync_literal(const char *line, uint64_t *lenp)
 	len = strlen(line);
 	if (len < 4 || line[len - 1] != '}' || line[len - 2] != '+')
 		return (0);
-	stop = &line[len - 2];		/* one past the last digit */
+	stop = &line[len - 2];
 	if ((open = memrchr(line, '{', len)) == NULL || open + 1 >= stop)
 		return (0);
 	open++;
@@ -1072,11 +857,7 @@ 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: a tag may not contain "+" */
 static int
 tag_is_valid(const char *tag)
 {
@@ -1093,24 +874,17 @@ tag_is_valid(const char *tag)
 	return (1);
 }
 
-/* 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;
-	/*
-	 * 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, "io-error");
 		return;
 	}
@@ -1119,10 +893,7 @@ 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
-			 * for what it needs.
-			 */
+			/* tls_read() may want to write */
 			event_del(&s->client_ev);
 			event_set(&s->client_ev, s->client_fd,
 			    (n == TLS_WANT_POLLIN) ? EV_READ : EV_WRITE,
@@ -1136,16 +907,11 @@ session_dispatch_client(int fd, short event, void *arg
 			session_teardown(s, "tls-error");
 			return;
 		}
-		/* 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().
-			 */
 			if (errno == EINTR || errno == EAGAIN ||
 			    errno == EWOULDBLOCK)
 				return;
@@ -1167,12 +933,7 @@ 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.
-		 */
+		/* a refused non-synchronizing literal's octets are in flight */
 		if (s->literal_discard > 0) {
 			uint64_t	take;
 
@@ -1186,13 +947,6 @@ 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.
-			 */
 			if (s->inbuflen >= 2 && s->inbuf[0] == '\r' &&
 			    s->inbuf[1] == '\n') {
 				memmove(s->inbuf, s->inbuf + 2,
@@ -1202,10 +956,7 @@ session_dispatch_client(int fd, short event, void *arg
 			continue;
 		}
 
-		/*
-		 * RFC 9051 SS4.3 literal in flight; checked before CRLF, may
-		 * contain one.
-		 */
+		/* RFC 9051 SS4.3 literal; may contain a CRLF */
 		if (s->literal_pending) {
 			uint64_t	want, take;
 
@@ -1214,7 +965,6 @@ session_dispatch_client(int fd, short event, void *arg
 			    (uint64_t)s->inbuflen : want;
 
 			if (take > 0) {
-				/* 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) {
@@ -1232,12 +982,8 @@ 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 CRLF
-			 * (RFC 9051 SS9).
-			 */
 			if (s->inbuflen < 2)
-				break;	/* trailing CRLF hasn't arrived yet */
+				break;
 			if (s->inbuf[0] != '\r' || s->inbuf[1] != '\n') {
 				/* no reliable resync point, give up */
 				log_warnx("session %u: expected CRLF after "
@@ -1262,13 +1008,7 @@ 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: no CR, LF or NUL inside a line */
 		if (memchr(s->inbuf, '\r', linelen) != NULL ||
 		    memchr(s->inbuf, '\n', linelen) != NULL ||
 		    memchr(s->inbuf, '\0', linelen) != NULL) {
@@ -1284,12 +1024,6 @@ session_dispatch_client(int fd, short event, void *arg
 
 		*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.
-		 */
 		nonsync_len = 0;
 		if (line_nonsync_literal(s->inbuf, &nonsync_len) &&
 		    nonsync_len > IMAP_NONSYNC_LITERAL_MAX) {
@@ -1305,10 +1039,6 @@ session_dispatch_client(int fd, short event, void *arg
 			return;
 		}
 
-		/*
-		 * SASL continuation, IDLE's DONE route around tag/name/args
-		 * parser/queue.
-		 */
 		if (s->auth_cont) {
 			/* the line is the user's base64 password, see below */
 			s->scrub_inbuf = 1;
@@ -1316,14 +1046,7 @@ session_dispatch_client(int fd, short event, void *arg
 		} 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.
-			 */
+			/* RFC 9051 SS5.5: queue while one is in flight */
 			if (nonsync_len > 0) {
 				session_reply(s, "*", "BAD",
 				    "non-synchronizing literal not accepted on "
@@ -1348,24 +1071,14 @@ 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 (literal_pending) is
-		 * swallowed here.
-		 */
 		if (nonsync_len > 0 && !s->literal_pending)
 			s->literal_discard = nonsync_len;
 
-		/*
-		 * cmd_starttls() zeroes inbuflen to discard plaintext; clamps
-		 * underflow.
-		 */
+		/* cmd_starttls() zeroes inbuflen */
 		if (consumed > s->inbuflen)
 			consumed = s->inbuflen;
 
-		/*
-		 * Don't leave a SASL response (base64 password) in a long-lived
-		 * buffer.
-		 */
+		/* don't leave a base64 password in inbuf */
 		if (s->scrub_inbuf) {
 			explicit_bzero(s->inbuf, consumed);
 			s->scrub_inbuf = 0;
@@ -1376,41 +1089,22 @@ session_dispatch_client(int fd, short event, void *arg
 	}
 
 	if (s->inbuflen == sizeof(s->inbuf)) {
-		/*
-		 * Buffer full, no CRLF; matches RFC 9051 SS7.1.3's example
-		 * text.
-		 */
+		/* 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
-		 */
 		session_write(s, bad, sizeof(bad) - 1);
 		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.
-	 */
+	/* libtls may hold bytes that EV_READ will not report */
 	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().
- */
 #define SESSION_WRITE_POLL_TIMEOUT_MS	5000
 
 void
@@ -1421,13 +1115,6 @@ 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.
-	 */
 	if (log_getverbose() > 0) {
 		char	dbuf[301];
 		size_t	dlen = len < sizeof(dbuf) - 1 ? len : sizeof(dbuf) - 1;
@@ -1443,7 +1130,7 @@ session_write(struct session *s, const char *buf, size
 	}
 
 	if (!s->tls_active) {
-		while (sent < len) {	/* retry EINTR/EAGAIN via poll */
+		while (sent < len) {
 			ssize_t		 n;
 			struct pollfd	 pfd;
 
@@ -1509,17 +1196,7 @@ 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.
- */
+/* on overflow the line still ends in CRLF */
 static void	 session_writef(struct session *, const char *, ...)
 		    __attribute__((__format__ (printf, 2, 3)));
 
@@ -1558,13 +1235,6 @@ 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.
- */
 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,
@@ -1573,13 +1243,6 @@ 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 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) {
@@ -1597,14 +1260,6 @@ 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.
-	 */
 	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);
@@ -1615,7 +1270,6 @@ send_mbox_request(struct session *s, int imsg_type, co
 	return (1);
 }
 
-/* RFC 7162 SS3.1: marks CONDSTORE-aware; emits unsolicited HIGHESTMODSEQ. */
 void
 session_condstore_enable(struct session *s)
 {
@@ -1632,7 +1286,6 @@ session_condstore_enable(struct session *s)
 	}
 }
 
-/* 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)
 {
@@ -1676,11 +1329,6 @@ 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.
- */
 static int
 session_is_busy(const struct session *s)
 {
@@ -1690,7 +1338,6 @@ session_is_busy(const struct session *s)
 	case SESSION_STORING:
 	case SESSION_EXPUNGING:
 	case SESSION_APPENDING:
-	case SESSION_SEARCH_PARSING:
 	case SESSION_SEARCHING:
 	case SESSION_STATUSING:
 	case SESSION_COPYING:
@@ -1704,7 +1351,6 @@ session_is_busy(const struct session *s)
 	}
 }
 
-/* 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)
 {
@@ -1722,7 +1368,6 @@ session_enqueue_cmd(struct session *s, const char *lin
 	return (1);
 }
 
-/* Dispatches queued commands while idle; returns 0 if one tore *s* down. */
 int
 session_dequeue_next(struct session *s)
 {
@@ -1743,7 +1388,7 @@ session_dequeue_next(struct session *s)
 	return (1);
 }
 
-/* Returns 1 if alive, 0 if torn down; caller must not touch *s* if 0. */
+/* returns 0 if s was torn down */
 int
 session_handle_line(struct session *s, char *line)
 {
@@ -1756,12 +1401,7 @@ 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.
-	 */
+	/* RFC 4616 SS2: the initial response holds the password */
 	if (name != NULL && (strcasecmp(name, "AUTHENTICATE") == 0 ||
 	    strcasecmp(name, "LOGIN") == 0))
 		log_debug("session %u: <<< %s %s <redacted>", s->id, tag,
@@ -1771,15 +1411,11 @@ 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: tag must not echo
-	 * truncated.
-	 */
 	if (strlen(tag) >= IMAP_TAG_MAX) {
 		session_reply(s, "*", "BAD", "Tag too long");
 		return (1);
 	}
-	/* Replied to with "*", never with the tag: see tag_is_valid(). */
+	/* answered with "*", never the tag */
 	if (!tag_is_valid(tag)) {
 		session_reply(s, "*", "BAD", "Invalid tag");
 		return (1);
@@ -1798,10 +1434,7 @@ 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 allows BAD or NO */
 		session_reply(s, tag, "BAD",
 		    "Command not permitted in this state");
 		return (1);
@@ -1817,10 +1450,6 @@ listener_dispatch_auth(int fd, short event, void *arg)
 	struct imsg	 imsg;
 	ssize_t		 n;
 
-	/*
-	 * 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");
@@ -1830,15 +1459,11 @@ listener_dispatch_auth(int fd, short event, void *arg)
 		if ((n = imsgbuf_read(&iev->ibuf)) == -1)
 			fatal("imsgbuf_read");
 		if (n == 0) {
-			/*
-			 * 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");
+			/* the auth-worker exits after its grant */
+			if (auth_granted)
+				log_debug("auth-worker exited after the login");
+			else
+				log_warnx("auth-worker closed channel");
 			event_del(&iev->ev);
 			close(iev->ibuf.fd);
 			imsgbuf_clear(&iev->ibuf);
@@ -1877,6 +1502,7 @@ listener_dispatch_auth(int fd, short event, void *arg)
 				break;
 			}
 
+			auth_granted = 1;
 			s->state = SESSION_STORE_PENDING;
 			setproctitle("session %u [authenticated]", s->id);
 			break;
@@ -1892,168 +1518,13 @@ listener_dispatch_auth(int fd, short event, void *arg)
 	(void)fd;
 }
 
-/*
- * 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)
-{
-	struct imsgev	*iev = arg;
-	struct session	*s = TAILQ_FIRST(&sessions);
-	struct imsg	 imsg;
-	ssize_t		 n;
-
-	if (event & EV_WRITE) {
-		if (imsgbuf_write(&iev->ibuf) == -1)
-			fatal("imsgbuf_write");
-	}
-
-	if (event & EV_READ) {
-		if ((n = imsgbuf_read(&iev->ibuf)) == -1)
-			fatal("imsgbuf_read");
-		if (n == 0) {
-			/*
-			 * 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);
-			imsgbuf_clear(&iev->ibuf);
-			iev->ibuf.fd = -1;
-
-			if (s != NULL && s->state == SESSION_SEARCH_PARSING) {
-				s->state = SESSION_SELECTED;
-				session_reply(s, s->pending_tag, "NO",
-				    "[UNAVAILABLE] search temporarily "
-				    "unavailable");
-				if (!session_dequeue_next(s))
-					/* s torn down by a queued LOGOUT */
-					return;
-			}
-			return;
-		}
-	}
-
-	for (;;) {
-		if ((n = imsgbuf_get(&iev->ibuf, &imsg)) == -1)
-			fatal("imsgbuf_get");
-		if (n == 0)
-			break;
-
-		switch (imsg_get_type(&imsg)) {
-		case IMSG_SEARCH_PARSE_RESULT: {
-			struct imsg_search_parse_result	 res;
-			struct search_node			*nodes = NULL;
-			size_t					 bodylen;
-
-			if (s == NULL || s->state != SESSION_SEARCH_PARSING) {
-				log_debug("IMSG_SEARCH_PARSE_RESULT with no "
-				    "SEARCH awaiting one, ignored");
-				break;
-			}
-
-			if (imsg_get_buf(&imsg, &res, sizeof(res)) == -1) {
-				log_warnx("bad IMSG_SEARCH_PARSE_RESULT "
-				    "(header)");
-				s->state = SESSION_SELECTED;
-				session_reply(s, s->pending_tag, "NO",
-				    "[SERVERBUG] internal error");
-				break;
-			}
-			/*
-			 * imsg_get_buf() guarantees size, not NUL termination,
-			 * and 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) {
-				bodylen = imsg_get_len(&imsg);
-				if (res.nnodes > SEARCH_PROGRAM_MAX_NODES ||
-				    bodylen != (size_t)res.nnodes *
-				    sizeof(struct search_node)) {
-					log_warnx("bad "
-					    "IMSG_SEARCH_PARSE_RESULT "
-					    "(nnodes %u, %zu trailing "
-					    "bytes)", res.nnodes, bodylen);
-					s->state = SESSION_SELECTED;
-					session_reply(s, s->pending_tag, "NO",
-					    "[SERVERBUG] internal error");
-					break;
-				}
-				if (bodylen > 0) {
-					if ((nodes = malloc(bodylen)) == NULL) {
-						log_warn("malloc SEARCH nodes");
-						s->state = SESSION_SELECTED;
-						session_reply(s,
-						    s->pending_tag, "NO",
-						    "[SERVERBUG] internal "
-						    "error");
-						break;
-					}
-					if (imsg_get_buf(&imsg, nodes, bodylen)
-					    == -1) {
-						log_warnx("bad "
-						    "IMSG_SEARCH_PARSE_RESULT "
-						    "(nodes)");
-						free(nodes);
-						s->state = SESSION_SELECTED;
-						session_reply(s,
-						    s->pending_tag, "NO",
-						    "[SERVERBUG] internal "
-						    "error");
-						break;
-					}
-				}
-			}
-
-			search_dispatch_finish(s, &res, nodes);
-			free(nodes);
-			break;
-		}
-		default:
-			log_debug("listener_dispatch_search: unhandled %d",
-			    imsg_get_type(&imsg));
-			break;
-		}
-		imsg_free(&imsg);
-	}
-	imsgev_rearm_read(iev);
-	(void)fd;
-
-	if (s != NULL) {
-		if (!session_dequeue_next(s))
-			return; /* s torn down by a queued LOGOUT */
-	}
-}
-
-/*
- * PARENT channel (fd 3): IMSG_STORE_FORK (spawn failure) 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)
 {
 	struct imsgev	*iev = arg;
 	struct imsg	 imsg;
 	ssize_t		 n;
 
-	/* See listener_dispatch_auth()'s comment on this EV_WRITE check. */
 	if (event & EV_WRITE) {
 		if (imsgbuf_write(&iev->ibuf) == -1)
 			fatal("imsgbuf_write");
@@ -2089,11 +1560,18 @@ listener_dispatch_parent(int fd, short event, void *ar
 				log_warnx("bad IMSG_STORE_FORK reply");
 				break;
 			}
-			if ((s = session_find(fail.session_id)) != NULL) {
-				s->state = SESSION_NOT_AUTH;
+			if ((s = session_find(fail.session_id)) == NULL)
+				break;
+			if (fail.limit) {
 				session_reply(s, s->pending_tag, "NO",
+				    "[LIMIT] too many sessions for this "
+				    "account");
+				session_teardown(s, "limit-exceeded");
+			} else {
+				session_reply(s, s->pending_tag, "NO",
 				    "authentication succeeded but mailbox "
 				    "store unavailable");
+				session_teardown(s, "io-error");
 			}
 			break;
 		}
@@ -2122,11 +1600,6 @@ 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. */
@@ -2157,7 +1630,6 @@ session_find(uint32_t id)
 	return (NULL);
 }
 
-/* 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, const char *reason)
@@ -2184,10 +1656,6 @@ session_teardown(struct session *s, const char *reason
 	if (s->tls_ctx != NULL) {
 		int	ret = tls_close(s->tls_ctx);
 
-		/*
-		 * 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 */
@@ -2199,10 +1667,6 @@ session_teardown(struct session *s, const char *reason
 		close(s->client_fd);
 	}
 
-	/*
-	 * 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);
@@ -2213,10 +1677,6 @@ session_teardown(struct session *s, const char *reason
 	free(s->pending_envelope_buf);
 	free(s->pending_bodystructure_buf);
 
-	/*
-	 * 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]);
 
@@ -2240,20 +1700,10 @@ session_teardown(struct session *s, const char *reason
 		    s->tls_active ? "yes" : "no", dur);
 	}
 
-	/*
-	 * inbuf may hold a base64 SASL response; don't hand it to allocator
-	 * intact.
-	 */
+	/* inbuf may hold a base64 SASL response */
 	explicit_bzero(s, sizeof(*s));
 	free(s);
 
-	/*
-	 * 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 - 8755fed3f2e8207aa45e1e8304bd299107a67642
blob + 0ba65946e76d2bf2869f7b2e0ec1b71892492b88
--- src/listener.h
+++ src/listener.h
@@ -16,9 +16,6 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* 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
 
@@ -32,107 +29,51 @@
 
 enum session_state {
 	SESSION_NOT_AUTH,
-	SESSION_AUTHENTICATING,	/* IMSG_AUTH_REQUEST sent, awaiting reply */
-	SESSION_STORE_PENDING,	/* auth succeeded; awaiting parent's
-				 * store-child handshake */
+	SESSION_AUTHENTICATING,
+	SESSION_STORE_PENDING,
 	SESSION_AUTHENTICATED,
-	SESSION_SELECTING,	/* IMSG_MBOX_SELECT sent, awaiting reply */
+	SESSION_SELECTING,
 	SESSION_SELECTED,
-	SESSION_FETCHING,	/* IMSG_MBOX_FETCH sent, awaiting the
-				 * IMSG_MBOX_FETCH_META stream + terminal
-				 * IMSG_MBOX_RESULT */
-	SESSION_STORING,	/* IMSG_MBOX_STORE sent; reuses FETCH's
-				 * reply shape (see cmd_store_cmd()) */
-	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_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, /* IMSG_SEARCH_PARSE_REQUEST sent
-				 * to the per-connection search-oracle,
-				 * awaiting IMSG_SEARCH_PARSE_RESULT --
-				 * precedes SESSION_SEARCHING below, a SEARCH
-				 * is now a two-hop async round trip (oracle
-				 * parse, then store execute), not one.
-				 * Handled by listener_dispatch_search()
-				 * (listener.c), which calls
-				 * search_dispatch_finish() (search_cmd.c)
-				 * once the oracle replies. */
-	SESSION_SEARCHING,	/* IMSG_MBOX_SEARCH sent, awaiting the
-				 * IMSG_MBOX_SEARCH_MATCH stream + terminal
-				 * IMSG_MBOX_RESULT; handled by
-				 * session_handle_mbox_result(), which
-				 * branches to session_finish_search(). */
-	SESSION_STATUSING,	/* IMSG_MBOX_STATUS sent, awaiting the single
-				 * terminal IMSG_MBOX_STATUS_RESULT reply.
-				 * Never changes s->state's SELECTED-ness
-				 * (RFC 9051 SS6.3.11); s->status_prev_state
-				 * records the state to restore. */
-	SESSION_COPYING,	/* IMSG_MBOX_COPY or IMSG_MBOX_MOVE sent
-				 * (s->cmd_is_move says which), awaiting the
-				 * IMSG_MBOX_COPY_MAPPING stream (plus, for a
-				 * MOVE, an interleaved IMSG_MBOX_EXPUNGED
-				 * stream) + terminal IMSG_MBOX_RESULT;
-				 * branches to
-				 * session_finish_copy_or_move(). */
+	SESSION_FETCHING,
+	SESSION_STORING,
+	SESSION_EXPUNGING,
+	SESSION_APPENDING,
+	SESSION_SEARCHING,
+	SESSION_STATUSING,
+	SESSION_COPYING,
 
-	/* 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
-				 * 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 */
+	/* RFC 9051 SS6.3.4-SS6.3.9; mbox_op_prev_state is restored after */
+	SESSION_CREATING,
+	SESSION_DELETING,
+	SESSION_RENAMING,
+	SESSION_LISTING,
+	SESSION_SUBSCRIBING,
+	SESSION_UNSUBSCRIBING
 };
 
-/* 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. */
+/* RFC 9051 sets no limit; this bounds a client that never sends CRLF */
 #define SESSION_INBUF_MAX	8192
 
-/* 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. */
+/* RFC 9051 SS9 sets no limit; a longer tag is truncated */
 #define IMAP_TAG_MAX	64
 
-/* 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. */
+/* pipelined lines (RFC 9051 SS5.5); past this the session is dropped */
 #define SESSION_CMD_QUEUE_MAX	8
 
-/* 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
 
-/* 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. */
+/* two DQUOTEs, a NUL, and a backslash per byte */
 #define MBOX_QUOTED_MAX		((2 * MBOX_NAME_MAX) + 3)
 
-/* 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. */
+/* RFC 7162 SS7: a mod-sequence is a positive 63-bit integer */
 #define MODSEQ_MAX		INT64_MAX
 
-/* RFC 9051 SS6.4.4 SEARCH result options, as ESEARCH return items. */
-/* SAVE ("$") is recognized but rejected with a flagged NO. */
+/* RFC 9051 SS6.4.4 ESEARCH result options; SAVE is refused */
 #define SEARCH_RETURN_MIN	(1U << 0)
 #define SEARCH_RETURN_MAX	(1U << 1)
 #define SEARCH_RETURN_ALL	(1U << 2)
 #define SEARCH_RETURN_COUNT	(1U << 3)
 
-/* One RFC 7162 VANISHED (EARLIER) range. Named so format_range_list()
- * can take it as a parameter type. */
 struct vanished_range {
 	uint32_t	lo;
 	uint32_t	hi;
@@ -141,220 +82,117 @@ struct vanished_range {
 struct session {
 	uint32_t		 id;
 	int			 client_fd;
-	/* numeric "host:port", set once in listener_start_session() with */
-	/* NI_NUMERICHOST|NI_NUMERICSERV, so no resolver pledge is needed */
+	/* numeric, 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 */
-	struct imsgev		*store_iev;	/* NULL until STORE_PENDING */
+	int			 implicit_tls;
+	struct imsgev		*store_iev;
 	/* 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			 tls_active;
+	struct tls		*tls_ctx;
+	/* RFC 8314: the greeting waits for the handshake */
 	int			 pending_greeting;
 	char			 inbuf[SESSION_INBUF_MAX];
-	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 */
+	size_t			 inbuflen;
+	/* inbuf held SASL credentials; explicit_bzero(3) it */
 	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 */
+	/* RFC 9051 SS6.3.13: the next line is DONE */
 	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. */
+	/* pipelined lines (RFC 9051 SS5.5), malloc(3)'d */
 	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. */
+	/* the tag points into inbuf, which the next read overwrites */
 	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];
-	/* 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];
-	/* 1 if the BODY.PEEK[...] token carried <<start.count>> (SS6.4.5); */
-	/* only the origin is echoed back, never the count */
+	/* only the origin is echoed back (RFC 9051 SS6.4.5) */
 	int			 pending_body_has_partial;
 	uint32_t		 pending_body_partial_origin;
-	/* 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];
 
-	/*
-	 * 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.
-	 */
+	/* tagged NO rather than OK (RFC 9051 SS6.4.5) */
 	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. */
+	/* RFC 9051 SS4.3 literal octets, checked before the CRLF search */
 	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. */
+	/* a refused non-synchronizing literal's octets, swallowed */
 	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. */
+	/* RENAME moves the selection, 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 */
+	/* a failed realloc(3) refuses the response */
 	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. */
+	/* RFC 7162 SS3.1/SS3.2.3: sticky; QRESYNC implies CONDSTORE */
 	int			 condstore_enabled;
 	int			 qresync_enabled;
-	/* 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: 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. */
+	/* RFC 9051 SS6.3.3 EXAMINE */
 	int			 mbox_readonly;
-	/* 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, 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.
-	 */
+	/* RFC 9051 SS6.3.13 IDLE; the store child keeps the UID list */
 	int			 idle_refresh_pending;
 	int			 idle_refresh_again;
 	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;
-	/*
-	 * 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 */
+	/* held until IMSG_MBOX_SELECTED (RFC 7162 SS3.2.6 ordering) */
 	struct vanished_range	*vanished_ranges;
 	uint32_t		 vanished_nranges;
 	uint32_t		 vanished_cap;
-	/* 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 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. */
+	/* a dropped entry fails the SELECT: HIGHESTMODSEQ would hide it */
 	int			 qresync_alloc_failed;
 
-	/* 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. */
+	/* COPYUID precedes EXPUNGED (RFC 9051 SS6.4.8) */
 	uint32_t		*copy_src_uids;
 	uint32_t		*copy_dest_uids;
 	uint32_t		 copy_n;
@@ -365,58 +203,35 @@ struct session {
 	uint32_t		 move_expunged_n;
 	uint32_t		 move_expunged_cap;
 
-	/* 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. */
+	/* RFC 7162 SS3.1.3 MODIFIED */
 	uint32_t		*store_modified;
 	uint32_t		 store_modified_n;
 	uint32_t		 store_modified_cap;
-	/* SS3.1.3 requires MODIFIED to list EVERY failure, and a client */
-	/* never retries one it cannot see, so an incomplete set is refused */
+	/* MODIFIED must list every failure, so an incomplete set is refused */
 	int			 store_modified_alloc_failed;
 
-	/* 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. */
+	/* RFC 9051 SS6.4.9 UID <cmd> */
 	int			 cmd_by_uid;
 
 	TAILQ_ENTRY(session)	 entry;
 };
 
-/* Named, not anonymous: an anonymous TAILQ_HEAD would be a redefinition */
-/* error once the declaration and the definition are in separate files. */
+/* named: declared and defined in different files */
 TAILQ_HEAD(session_list, session);
 
-/*
- * Globals shared across the files this process's source is split into
- * (definitions live in listener.c's core).
- */
 extern struct session_list	 sessions;
 extern struct imsgev		 iev_auth;
-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.
- */
+
+/* RFC 9051 SS7.1 INUSE */
 #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 *);
@@ -437,10 +252,7 @@ int	 session_request_expunge(struct session *, const c
 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 *);
@@ -469,9 +281,6 @@ int	 parse_search_key_inner(char **, struct search_par
 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 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 *,
@@ -480,7 +289,7 @@ 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. */
+/* RFC 7162 (CONDSTORE/QRESYNC) */
 void	 session_condstore_enable(struct session *);
 size_t	 format_seq_list(char *, size_t, const uint32_t *, uint32_t,
 		    int *);
@@ -519,21 +328,15 @@ int	 parse_fetch_modifiers(char *, struct imsg_mbox_fe
 int	 parse_store_modifiers(char *, struct imsg_mbox_store *,
 		    const char **);
 
-/* RFC 9051 SS6.4.9 (UID command) helpers, added this pass. */
+/* RFC 9051 SS6.4.9 (UID) */
 int	 fetch_dispatch(struct session *, const char *, char *, int);
 int	 store_do(struct session *, const char *, char *, int);
 int	 search_dispatch(struct session *, const char *, char *, int);
-/* SS8.1: completes search_dispatch() once listener_dispatch_search()
- * (listener.c) gets this session's IMSG_SEARCH_PARSE_RESULT; see
- * search_dispatch_finish()'s own comment (search_cmd.c). */
-void	 search_dispatch_finish(struct session *,
-		    const struct imsg_search_parse_result *,
-		    struct search_node *);
 int	 uid_expunge_dispatch(struct session *, const char *, const char *);
 void	 session_handle_fetch_vanished(struct session *,
 		    const struct imsg_mbox_select_vanished *);
 
-/* RFC 9051 SS6.4.7/SS6.4.8 (COPY/MOVE) helpers, added this pass. */
+/* RFC 9051 SS6.4.7/SS6.4.8 (COPY/MOVE) */
 int	 copy_move_dispatch(struct session *, const char *, char *,
 		    int, int);
 int	 listener_mailbox_name_valid(const char *);
@@ -578,10 +381,7 @@ 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, 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. */
+/* command-auth (RFC 9051 SS6.3) */
 int	 stub_not_implemented(struct session *, const char *,
 		    const char *);
 int	 cmd_enable(struct session *, const char *, char *);
blob - 5fa792a8c473ea2d6ca2f3d1800138abfc52c3a6
blob + b2dad4bfd9b2765481ed2214adabfe90e4b5da87
--- src/log.c
+++ src/log.c
@@ -45,12 +45,7 @@ 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.
- */
+/* escapes control bytes for the -d trace; syslogd(8) escapes its own */
 static char *
 log_escape_ctl(const char *s)
 {
@@ -82,8 +77,7 @@ 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. */
+/* one write(2) per line: every process in a -d run shares stderr */
 static void
 log_write_line(const char *line, size_t len)
 {
@@ -107,7 +101,6 @@ log_init(int foreground, int verbose)
 	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("imapd", LOG_PID | LOG_NDELAY, LOG_MAIL);
 
@@ -117,10 +110,6 @@ log_init(int foreground, int verbose)
 void
 log_procinit(const char *name)
 {
-	/*
-	 * truncation just shortens the prefix; name is argv[0], not attacker
-	 * input
-	 */
 	if (name != NULL)
 		(void)strlcpy(log_procname, name, sizeof(log_procname));
 }
@@ -145,20 +134,12 @@ vlog(int pri, const char *fmt, va_list ap)
 	if (log_foreground) {
 		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.
-		 */
 		if (vasprintf(&msg, fmt, ap) == -1)
 			msg = NULL;
 		if (msg != NULL)
 			safe = log_escape_ctl(msg);
-		/* 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 */
+		/* compose after escaping, or the newline becomes "^J" */
+		/* asprintf(3) leaves *ret undefined on failure */
 		if (safe != NULL && asprintf(&line, "%s: %s\n",
 		    log_procname, safe) != -1) {
 			log_write_line(line, strlen(line));
@@ -180,7 +161,6 @@ vlog(int pri, const char *fmt, va_list 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)
@@ -211,23 +191,11 @@ 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.
-	 */
 	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.
-			 */
 			va_start(ap, emsg);
 			vlog(LOG_ERR, emsg, ap);
 			va_end(ap);
blob - 035822f360ad2cff9af8122f82cf23d637164bc1
blob + f315b680566be25f414789461f75694af4c4c95e
--- src/log.h
+++ src/log.h
@@ -24,11 +24,6 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/*
- * Minimal logging, matching the call signatures actually observed in the
- * uploaded smtpd.c this session.
- */
-
 #ifndef OPENIMAP_LOG_H
 #define OPENIMAP_LOG_H
 
blob - e4b386b44a9a792c718718931b05f8fd535b9263
blob + 1536d4ddcfe4de80dc7e9f9403c651fae9aa611f
--- src/mailbox_cmd.c
+++ src/mailbox_cmd.c
@@ -16,7 +16,6 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* mailbox_cmd.c: mailbox selection/management command handlers. */
 
 #include <sys/types.h>
 #include <sys/queue.h>
@@ -82,11 +81,7 @@ 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: 1..2^63-1; strtoull(3) accepts a sign */
 	if (*tok < '0' || *tok > '9') {
 		*errmsg = "invalid QRESYNC mod-sequence";
 		return (-1);
@@ -123,11 +118,7 @@ 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.
-		 */
+		/* RFC 7162 SS3.2.5.1: "*" is not allowed */
 		if (parse_sequence_set(tok, ranges, nranges, errmsg) == -1)
 			return (-1);
 		for (i = 0; i < *nranges; i++) {
@@ -180,7 +171,7 @@ parse_qresync_group(char *inner, struct imsg_mbox_sele
 	return (0);
 }
 
-/* SELECT/EXAMINE select-params (RFC 4466/7162); p modified in place */
+/* RFC 4466/RFC 7162 select-params */
 int
 parse_select_params(char *p, struct imsg_mbox_select *req,
     const struct session *s, struct seq_range ranges[SEQSET_MAX_RANGES],
@@ -243,7 +234,7 @@ parse_select_params(char *p, struct imsg_mbox_select *
 				return (-1);
 
 			req->qresync = 1;
-			/* SS3.2.3: QRESYNC implies CONDSTORE */
+			/* RFC 7162 SS3.2.3: QRESYNC implies CONDSTORE */
 			*want_condstore = 1;
 			p = end + 1;
 			continue;
@@ -256,7 +247,7 @@ parse_select_params(char *p, struct imsg_mbox_select *
 	return (0);
 }
 
-/* RFC 9051 SS6.3.2/6.3.3; shared by SELECT/EXAMINE, quoted-string form only */
+/* RFC 9051 SS6.3.2/SS6.3.3 */
 static int
 select_or_examine(struct session *s, const char *tag, char *args, int readonly)
 {
@@ -279,11 +270,6 @@ 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.
-	 */
 	p = args;
 	if (parse_mailbox_name(&p, mailbox, sizeof(mailbox), &errmsg) == -1) {
 		session_reply(s, tag, "BAD", errmsg);
@@ -300,10 +286,6 @@ select_or_examine(struct session *s, const char *tag, 
 	}
 
 	if (s->store_iev == NULL) {
-		/*
-		 * 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");
@@ -335,10 +317,7 @@ select_or_examine(struct session *s, const char *tag, 
 		}
 	}
 
-	/*
-	 * RFC 7162 SS3.1.8/3.2.3: not session_condstore_enable(); has
-	 * HIGHESTMODSEQ
-	 */
+	/* RFC 7162 SS3.1.8/SS3.2.3 */
 	if (want_condstore)
 		s->condstore_enabled = 1;
 
@@ -381,26 +360,13 @@ 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 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.
- */
+/* checked here to save a round trip; the store checks again */
 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.
- */
 int
 listener_reject_bad_utf8(struct session *s, const char *tag, const char *name)
 {
@@ -414,7 +380,7 @@ listener_reject_bad_utf8(struct session *s, const char
 	return (1);
 }
 
-/* RFC 9051 SS6.3.4 CREATE; "exists" is store.c's call (mbox_create() EEXIST) */
+/* RFC 9051 SS6.3.4 CREATE */
 int
 cmd_create(struct session *s, const char *tag, char *args)
 {
@@ -446,11 +412,7 @@ cmd_create(struct session *s, const char *tag, char *a
 		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.)
-	 */
+	/* no arguments may follow the name */
 	while (*p == ' ')
 		p++;
 	if (*p != '\0') {
@@ -479,13 +441,7 @@ 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 must not leave the session busy */
 	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;
@@ -496,7 +452,7 @@ cmd_create(struct session *s, const char *tag, char *a
 	return (1);
 }
 
-/* RFC 9051 SS6.3.5 DELETE; existence check is store.c's handle_mbox_delete() */
+/* RFC 9051 SS6.3.5 DELETE */
 int
 cmd_delete(struct session *s, const char *tag, char *args)
 {
@@ -526,11 +482,7 @@ cmd_delete(struct session *s, const char *tag, char *a
 		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.)
-	 */
+	/* no arguments may follow the name */
 	while (*p == ' ')
 		p++;
 	if (*p != '\0') {
@@ -559,7 +511,6 @@ cmd_delete(struct session *s, const char *tag, char *a
 	s->mbox_op_prev_state = s->state;
 	s->state = SESSION_DELETING;
 
-	/* see cmd_create()'s comment on this failure path */
 	if (!send_mbox_request(s, IMSG_MBOX_DELETE, "DELETE",
 	    "IMSG_MBOX_DELETE", &req, sizeof(req), NULL, 0, 0)) {
 		s->state = s->mbox_op_prev_state;
@@ -570,7 +521,7 @@ cmd_delete(struct session *s, const char *tag, char *a
 	return (1);
 }
 
-/* RFC 9051 SS6.3.6 RENAME; *from* INBOX refused client-side, RFC-sanctioned */
+/* RFC 9051 SS6.3.6 RENAME */
 int
 cmd_rename(struct session *s, const char *tag, char *args)
 {
@@ -597,10 +548,7 @@ 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
-		 * closest fit
-		 */
+		/* RFC 9051 SS6.3.6 allows refusing this */
 		session_reply(s, tag, "NO", "[CANNOT] cannot rename INBOX");
 		return (1);
 	}
@@ -617,11 +565,7 @@ cmd_rename(struct session *s, const char *tag, char *a
 		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.)
-	 */
+	/* no arguments may follow the name */
 	while (*p == ' ')
 		p++;
 	if (*p != '\0') {
@@ -654,7 +598,6 @@ cmd_rename(struct session *s, const char *tag, char *a
 	s->mbox_op_prev_state = s->state;
 	s->state = SESSION_RENAMING;
 
-	/* see cmd_create()'s comment on this failure path */
 	if (!send_mbox_request(s, IMSG_MBOX_RENAME, "RENAME",
 	    "IMSG_MBOX_RENAME", &req, sizeof(req), NULL, 0, 0)) {
 		s->state = s->mbox_op_prev_state;
@@ -666,10 +609,7 @@ 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.
- */
+/* RFC 9051 SS6.3.7 SUBSCRIBE and SS6.3.8 UNSUBSCRIBE */
 static int
 subscribe_dispatch(struct session *s, const char *tag, char *args,
     int is_unsub)
@@ -700,15 +640,7 @@ subscribe_dispatch(struct session *s, const char *tag,
 	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.
-	 */
+	/* RFC 9051 SS5.1: INBOX is always subscribed */
 	if (mailbox_name_is_inbox(mailbox)) {
 		if (is_unsub) {
 			session_reply(s, tag, "NO",
@@ -753,7 +685,6 @@ subscribe_dispatch(struct session *s, const char *tag,
 	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)) {
@@ -779,15 +710,13 @@ cmd_unsubscribe(struct session *s, const char *tag, ch
 	return subscribe_dispatch(s, tag, args, 1);
 }
 
-/* RFC 9051 SS6.3.9 wildcards, "zero+ of anything"; flat namespace, no delim */
+/* RFC 9051 SS6.3.9 wildcards; a flat namespace */
 int
 list_pattern_match(const char *pat, const char *name, int ci)
 {
 	const char	*p = pat;
 	const char	*s = name;
-	/* 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) match, O(n*m); no backtrack blowup */
@@ -816,13 +745,7 @@ 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.
- */
+/* RFC 9051 SS4.3 quoted string */
 int
 quote_mailbox(char *out, size_t outsize, const char *name)
 {
@@ -839,11 +762,7 @@ 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.
-		 */
+		/* RFC 9051 SS4.3: no NUL, CR or LF */
 		if (c == '\r' || c == '\n')
 			c = ' ';
 
@@ -863,12 +782,6 @@ 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.
- */
 static int
 atom_char_ok(unsigned char c, int wildcards)
 {
@@ -881,13 +794,7 @@ 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.
- */
+/* RFC 9051 SS9 astring, or list-mailbox with wildcards */
 static int
 parse_mailbox_arg(char **pp, char *out, size_t outsize, int wildcards,
     const char **errmsg)
@@ -922,16 +829,10 @@ parse_mailbox_arg(char **pp, char *out, size_t outsize
 				break;
 			}
 			if (*p == '\\') {
-				/*
-				 * SS9 QUOTED-CHAR escapes exactly the two
-				 * quoted-specials, nothing else
-				 */
+				/* RFC 9051 SS9 QUOTED-CHAR */
 				p++;
 				if (*p == '\0') {
-					/*
-					 * a trailing backslash: the string ran
-					 * out, not a bad escape
-					 */
+					/* a trailing backslash */
 					*errmsg = "unterminated quoted string";
 					return (-1);
 				}
@@ -970,11 +871,6 @@ 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.
- */
 int
 parse_mailbox_name(char **pp, char *out, size_t outsize, const char **errmsg)
 {
@@ -987,7 +883,7 @@ 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-opts; LSUB just varies output */
+/* RFC 9051 SS6.3.9 basic syntax; LSUB varies only the output */
 static int
 list_dispatch(struct session *s, const char *tag, char *args, int is_lsub)
 {
@@ -1014,14 +910,7 @@ list_dispatch(struct session *s, const char *tag, char
 		p++;
 
 	if (*p == '(') {
-		/*
-		 * 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.
-		 */
+		/* RFC 9051 SS6.3.9 condition 1: list-select-opts */
 		if (is_lsub) {
 			session_reply(s, tag, "BAD",
 			    "LSUB takes no selection options");
@@ -1049,10 +938,6 @@ list_dispatch(struct session *s, const char *tag, char
 			p++;
 	}
 
-	/*
-	 * 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);
@@ -1063,10 +948,7 @@ list_dispatch(struct session *s, const char *tag, char
 		p++;
 
 	if (*p == '(') {
-		/*
-		 * SS6.3.9 cond 2: second word starts "(", parenthesized
-		 * `patterns` form
-		 */
+		/* RFC 9051 SS6.3.9 condition 2 */
 		snprintf(text, sizeof(text),
 		    "extended %s mailbox-pattern lists not supported",
 		    cmdname);
@@ -1082,20 +964,14 @@ 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
-		 */
+		/* RFC 9051 SS6.3.9 condition 3 */
 		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 delimiter/root; root is
-	 * empty
-	 */
+	/* RFC 9051 SS6.3.9: an empty pattern asks for the delimiter */
 	if (pattern[0] == '\0') {
 		snprintf(text, sizeof(text), "%s (\\Noselect) \"/\" \"\"", kw);
 		session_untagged(s, text);
@@ -1104,10 +980,6 @@ list_dispatch(struct session *s, const char *tag, char
 		return (1);
 	}
 
-	/*
-	 * canonical LIST pattern: reference + mailbox pattern (RFC 9051
-	 * SS6.3.9)
-	 */
 	{
 		size_t	n;
 
@@ -1121,21 +993,9 @@ list_dispatch(struct session *s, const char *tag, char
 		}
 	}
 
-	/*
-	 * SS6.3.9: unaccepted pattern MUST be ignored; INBOX answered
-	 * synchronously
-	 */
+	/* RFC 9051 SS6.3.9: INBOX is answered here */
 	if (list_pattern_match(canon, "INBOX", 1)) {
-		/*
-		 * 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.
-		 */
+		/* RFC 9051 SS7.3.1: attributes are optional */
 		snprintf(text, sizeof(text), "%s (%s) \"/\" \"INBOX\"", kw,
 		    subscribed_only ? "\\Subscribed" : "");
 		session_untagged(s, text);
@@ -1148,7 +1008,6 @@ list_dispatch(struct session *s, const char *tag, char
 		return (1);
 	}
 
-	/* 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)) >=
@@ -1157,7 +1016,7 @@ 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) */
+	/* LSUB lists subscribed names (RFC 3501 SS6.3.9) */
 	s->list_subscribed_only = subscribed_only || is_lsub;
 	s->mbox_op_prev_state = s->state;
 	s->state = SESSION_LISTING;
@@ -1165,7 +1024,6 @@ list_dispatch(struct session *s, const char *tag, char
 	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",
 	    &req, sizeof(req), NULL, 0, 0)) {
 		s->state = s->mbox_op_prev_state;
@@ -1190,7 +1048,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: Personal NS, "/" delimiter */
+/* RFC 9051 SS6.3.10 NAMESPACE */
 int
 cmd_namespace(struct session *s, const char *tag, char *args)
 {
@@ -1200,7 +1058,7 @@ cmd_namespace(struct session *s, const char *tag, char
 	return (1);
 }
 
-/* RFC 9051 SS6.3.11 STATUS; doesn't change mbox, STATUSING is transient wait */
+/* RFC 9051 SS6.3.11 STATUS */
 int
 cmd_status(struct session *s, const char *tag, char *args)
 {
@@ -1257,12 +1115,7 @@ 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.
-			 */
+			/* RFC 9051 SS2.3.2 dropped it; clients ask */
 			attrs |= STATUS_ATT_RECENT;
 		else {
 			session_reply(s, tag, "BAD", "unknown status-att");
@@ -1270,10 +1123,7 @@ cmd_status(struct session *s, const char *tag, char *a
 		}
 	}
 
-	/*
-	 * RFC 9051 SS9: status-att-list needs >=1 status-att, unlike response
-	 * side
-	 */
+	/* RFC 9051 SS9: at least one status-att */
 	if (attrs == 0) {
 		session_reply(s, tag, "BAD",
 		    "STATUS requires at least one status-att");
@@ -1294,10 +1144,7 @@ cmd_status(struct session *s, const char *tag, char *a
 		return (1);
 	}
 
-	/*
-	 * RFC 7162 SS3.1: STATUS HIGHESTMODSEQ is CONDSTORE-enabling;
-	 * pre-overwrite
-	 */
+	/* RFC 7162 SS3.1: STATUS HIGHESTMODSEQ enables CONDSTORE */
 	if (attrs & STATUS_ATT_HIGHESTMODSEQ)
 		session_condstore_enable(s);
 
@@ -1316,7 +1163,6 @@ cmd_status(struct session *s, const char *tag, char *a
 	s->status_prev_state = s->state;
 	s->state = SESSION_STATUSING;
 
-	/* 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 - ca9384ac7b6f9e505d2c2c19bd7e1ca7ccd0231a
blob + 07161abf280fd522a6ce6a00dc337ee613c5a0b9
--- src/main.c
+++ src/main.c
@@ -16,8 +16,6 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* main.c: imapd(8) entry point, dispatches to the role named by "-x". */
-
 #include <sys/types.h>
 
 #include <err.h>
@@ -40,7 +38,7 @@ static const struct {
 	{ "auth",	PROC_AUTH },
 	{ "store",	PROC_STORE },
 	{ "keymgr",	PROC_KEYMGR },
-	{ "search",	PROC_SEARCH },
+	{ "parser",	PROC_PARSER },
 };
 
 const char *
@@ -57,8 +55,8 @@ log_procname(enum openimap_proc_type type)
 		return ("store");
 	case PROC_KEYMGR:
 		return ("keymgr");
-	case PROC_SEARCH:
-		return ("search");
+	case PROC_PARSER:
+		return ("parser");
 	default:
 		return ("?");
 	}
@@ -67,10 +65,6 @@ log_procname(enum openimap_proc_type type)
 __dead static void
 usage(void)
 {
-	/*
-	 * -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());
@@ -90,27 +84,15 @@ 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.
-	 */
+	/* before any fork(2), so every role inherits SIG_IGN */
 	signal(SIGPIPE, SIG_IGN);
 
 	while ((ch = getopt(argc, argv, "D:df:Vvx:")) != -1) {
 		switch (ch) {
 		case 'V':
-			/*
-			 * 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 symtab
-			 * pre-config_load()
-			 */
 			if (cmdline_symset(optarg) == -1)
 				fatalx("could not parse macro definition %s",
 				    optarg);
@@ -148,14 +130,9 @@ main(int argc, char *argv[])
 	log_procinit(log_procname(role));
 	log_init(debug, verbose);
 
-	/*
-	 * 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());
@@ -170,8 +147,8 @@ main(int argc, char *argv[])
 		store_main();
 	case PROC_KEYMGR:
 		keymgr_main();
-	case PROC_SEARCH:
-		search_oracle_main();
+	case PROC_PARSER:
+		parser_main();
 	}
 
 	fatalx("unhandled role %d", role);
blob - 8eed34ba60988e1609e365ec12afb7c28e189a5c
blob + 03da0e60a5cf8a9436a9f42d69541fb632f44545
--- src/mbox_copy.c
+++ src/mbox_copy.c
@@ -16,8 +16,6 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* mbox_copy.c: COPY and MOVE handling, same-mailbox and cross-mailbox. */
-
 #include <sys/types.h>
 #include <sys/file.h>
 #include <sys/stat.h>
@@ -53,27 +51,16 @@ resolve_mailbox_target(const char *name, char *target,
 	return (-1);
 }
 
-/* Pass 1 of COPY/MOVE: stages read-only; 0 means partial failure (SS6.4.7) */
-
 static int
-stage_copy_messages(int dfd, struct mbox_index *idx,
+stage_copy_messages(struct store_session *ss, 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)
+    uint32_t nranges, struct copy_staged **staged_out, size_t *nstaged_out)
 {
 	struct copy_staged	*staged = NULL;
 	size_t			 nstaged = 0, stagedcap = 0, i;
-	/* 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.
-	 */
 	nresolved = seqset_resolve(ranges, nranges, req->by_uid ?
 	    index_max_uid(idx) : (uint32_t)idx->nlines, !req->by_uid,
 	    resolved);
@@ -86,16 +73,11 @@ stage_copy_messages(int dfd, struct mbox_index *idx,
 		char			 suffix[64];
 		off_t			 size;
 		char			 path[600];
-		int			 srcfd;
 		struct copy_staged	 cs;
 
 		if (index_parse_line(idx->lines[i], &rec) == -1)
 			continue;
 
-		/*
-		 * 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)
@@ -103,8 +85,8 @@ stage_copy_messages(int dfd, struct mbox_index *idx,
 		if (pos == SEQSET_SKIP)
 			continue;
 
-		if (locate_message_file(mailbox_dir_fd, rec.basename,
-		    &size, suffix, sizeof(suffix)) == -1) {
+		if (locate_message_file(&ss->cur_snap, ss->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)",
@@ -112,24 +94,6 @@ stage_copy_messages(int dfd, struct mbox_index *idx,
 			goto fail;
 		}
 
-		/*
-		 * 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",
-			    session_id, rec.basename, (long long)size);
-			goto fail;
-		}
-		if ((uint64_t)size > COPY_STAGE_TOTAL_MAX - staged_total) {
-			log_warnx("session %u: COPY: staged data would "
-			    "exceed the total staging limit, failing "
-			    "COPY", session_id);
-			goto fail;
-		}
-		staged_total += (uint64_t)size;
-
 		memset(&cs, 0, sizeof(cs));
 		cs.src_uid = rec.uid;
 		if (strlcpy(cs.keywords, rec.keywords, sizeof(cs.keywords)) >=
@@ -138,14 +102,7 @@ stage_copy_messages(int dfd, struct mbox_index *idx,
 			    "%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.
-		 */
+		/* refuse before any file is written */
 		if (!index_field_valid(cs.keywords)) {
 			log_warnx("session %u: COPY: unsafe keywords field on "
 			    "%s, failing COPY", session_id, rec.basename);
@@ -163,11 +120,6 @@ stage_copy_messages(int dfd, struct mbox_index *idx,
 			    "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.
-		 */
 		if (!index_basename_valid(cs.basename)) {
 			log_warnx("session %u: COPY: generated basename is "
 			    "unsafe for the index, failing COPY", session_id);
@@ -181,57 +133,13 @@ stage_copy_messages(int dfd, struct mbox_index *idx,
 			    "for %s", session_id, rec.basename);
 			goto fail;
 		}
-		if ((srcfd = openat(dfd, path, O_RDONLY)) == -1) {
-			log_warn("session %u: COPY: open %s", session_id,
-			    path);
+		if ((cs.srcpath = strdup(path)) == NULL) {
+			log_warn("session %u: COPY: strdup source path",
+			    session_id);
 			goto fail;
 		}
-		cs.bodylen = (size_t)size;
-		if (cs.bodylen > 0 && (cs.body = malloc(cs.bodylen)) == NULL) {
-			log_warn("session %u: COPY: malloc %zu bytes",
-			    session_id, cs.bodylen);
-			close(srcfd);
-			goto fail;
-		}
-		{
-			size_t	 rd = 0;
+		cs.srcsize = size;
 
-			while (rd < cs.bodylen) {
-				ssize_t	n = read(srcfd,
-				    (char *)cs.body + rd, cs.bodylen - rd);
-				if (n == -1) {
-					if (errno == EINTR)
-						continue;
-					log_warn("session %u: COPY: read %s",
-					    session_id, path);
-					close(srcfd);
-					free(cs.body);
-					goto fail;
-				}
-				if (n == 0) {
-					/*
-					 * 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,
-					    path, rd, cs.bodylen);
-					close(srcfd);
-					free(cs.body);
-					goto fail;
-				}
-				rd += (size_t)n;
-			}
-		}
-		close(srcfd);
-
 		if (nstaged == stagedcap) {
 			size_t		     newcap = (stagedcap == 0) ? 8 :
 			    stagedcap * 2;
@@ -241,7 +149,7 @@ stage_copy_messages(int dfd, struct mbox_index *idx,
 			if (newstaged == NULL) {
 				log_warn("session %u: COPY: reallocarray "
 				    "staged", session_id);
-				free(cs.body);
+				free(cs.srcpath);
 				goto fail;
 			}
 			staged = newstaged;
@@ -256,77 +164,123 @@ stage_copy_messages(int dfd, struct mbox_index *idx,
 
 fail:
 	for (i = 0; i < nstaged; i++)
-		free(staged[i].body);
+		free(staged[i].srcpath);
 	free(staged);
 	*staged_out = NULL;
 	*nstaged_out = 0;
 	return (0);
 }
 
-/* Pass 2+3: writes tmp/, renames to cur/, index_append(); no rollback. */
+/* linkat(2) failed: copy through tmp/, as APPEND does */
+static int
+copy_message_file(struct store_session *ss, const struct copy_staged *cs,
+    int dfd, const char *curpath)
+{
+	static char	 buf[65536];
+	char		 tmppath[300];
+	off_t		 left = cs->srcsize;
+	int		 srcfd, tmpfd;
 
+	if (snprintf(tmppath, sizeof(tmppath), "tmp/%s", cs->basename) >=
+	    (int)sizeof(tmppath)) {
+		log_warnx("session %u: COPY: tmp path too long", session_id);
+		return (-1);
+	}
+	if ((srcfd = openat(ss->mailbox_dir_fd, cs->srcpath, O_RDONLY)) == -1) {
+		log_warn("session %u: COPY: open %s", session_id, cs->srcpath);
+		return (-1);
+	}
+	if ((tmpfd = openat(dfd, tmppath, O_WRONLY | O_CREAT | O_EXCL,
+	    0600)) == -1) {
+		log_warn("session %u: COPY: open %s", session_id, tmppath);
+		close(srcfd);
+		return (-1);
+	}
+	while (left > 0) {
+		size_t	 want, written = 0;
+		ssize_t	 n, w;
+
+		want = left < (off_t)sizeof(buf) ? (size_t)left : sizeof(buf);
+		if ((n = read(srcfd, buf, want)) == -1) {
+			if (errno == EINTR)
+				continue;
+			log_warn("session %u: COPY: read %s", session_id,
+			    cs->srcpath);
+			goto fail;
+		}
+		/* shrank since its stat(2); a copy now would be partial */
+		if (n == 0) {
+			log_warnx("session %u: COPY: %s shrank during copy, "
+			    "failing COPY", session_id, cs->srcpath);
+			goto fail;
+		}
+		while (written < (size_t)n) {
+			if ((w = write(tmpfd, buf + written,
+			    (size_t)n - written)) == -1) {
+				if (errno == EINTR)
+					continue;
+				log_warn("session %u: COPY: write %s",
+				    session_id, tmppath);
+				goto fail;
+			}
+			written += (size_t)w;
+		}
+		left -= n;
+	}
+	if (fsync(tmpfd) == -1)
+		log_warn("session %u: COPY: fsync %s (continuing)", session_id,
+		    tmppath);
+	close(tmpfd);
+	close(srcfd);
+	if (renameat(dfd, tmppath, dfd, curpath) == -1) {
+		log_warn("session %u: COPY: rename %s -> %s", session_id,
+		    tmppath, curpath);
+		unlinkat(dfd, tmppath, 0);
+		return (-1);
+	}
+	return (0);
+
+fail:
+	close(tmpfd);
+	close(srcfd);
+	unlinkat(dfd, tmppath, 0);
+	return (-1);
+}
+
 static int
-commit_copy_messages(int dfd, struct mbox_index *destidx,
-    struct copy_staged *staged, size_t nstaged, struct imsgev *iev)
+commit_copy_messages(struct store_session *ss, int dfd,
+    struct mbox_index *destidx, struct copy_staged *staged, size_t nstaged)
 {
 	size_t	i;
 	size_t	ncommitted = 0;	/* entries whose file is already in cur/ */
 
 	for (i = 0; i < nstaged; i++) {
-		char	tmppath[300], curpath[320];
+		char	curpath[320];
 		char	letters[8];
-		int	tmpfd;
 
-		if (snprintf(tmppath, sizeof(tmppath), "tmp/%s",
-		    staged[i].basename) >= (int)sizeof(tmppath)) {
-			log_warnx("session %u: COPY: tmp path too long",
-			    session_id);
-			goto rollback;
-		}
-		if ((tmpfd = openat(dfd, tmppath, O_WRONLY | O_CREAT | O_EXCL,
-		    0600)) == -1) {
-			log_warn("session %u: COPY: open %s", session_id,
-			    tmppath);
-			goto rollback;
-		}
-		{
-			size_t	 written = 0;
-
-			while (written < staged[i].bodylen) {
-				ssize_t	n = write(tmpfd,
-				    (char *)staged[i].body + written,
-				    staged[i].bodylen - written);
-				if (n == -1) {
-					if (errno == EINTR)
-						continue;
-					log_warn("session %u: COPY: write %s",
-					    session_id, tmppath);
-					close(tmpfd);
-					unlinkat(dfd, tmppath, 0);
-					goto rollback;
-				}
-				written += (size_t)n;
-			}
-		}
-		if (fsync(tmpfd) == -1)
-			log_warn("session %u: COPY: fsync %s (continuing)",
-			    session_id, tmppath);
-		close(tmpfd);
-
 		sysflags_to_letters(staged[i].sysflags, letters,
 		    sizeof(letters));
 		if (snprintf(curpath, sizeof(curpath), "cur/%s:2,%s",
 		    staged[i].basename, letters) >= (int)sizeof(curpath)) {
 			log_warnx("session %u: COPY: cur path too long",
 			    session_id);
-			unlinkat(dfd, tmppath, 0);
 			goto rollback;
 		}
-		if (renameat(dfd, tmppath, dfd, curpath) == -1) {
-			log_warn("session %u: COPY: rename %s -> %s",
-			    session_id, tmppath, curpath);
-			unlinkat(dfd, tmppath, 0);
-			goto rollback;
+		/* same bytes, new name: a maildir message is never rewritten */
+		if (linkat(ss->mailbox_dir_fd, staged[i].srcpath, dfd, curpath,
+		    AT_SYMLINK_FOLLOW) == -1) {
+			/* not copied: the copy's rename(2) would replace it */
+			if (errno == EEXIST) {
+				log_warn("session %u: COPY: link %s -> %s",
+				    session_id, staged[i].srcpath, curpath);
+				goto rollback;
+			}
+			log_debug("session %u: COPY: link %s: %s, copying "
+			    "instead", session_id, staged[i].srcpath,
+			    strerror(errno));
+			if (copy_message_file(ss, &staged[i], dfd,
+			    curpath) == -1)
+				goto rollback;
 		}
 		ncommitted = i + 1;
 	}
@@ -334,10 +288,7 @@ commit_copy_messages(int dfd, struct mbox_index *desti
 	for (i = 0; i < nstaged; i++) {
 		uint32_t	 dest_uid = destidx->uidnext;
 
-		/*
-		 * stage_copy_messages() already refused these; kept as defence
-		 * in depth
-		 */
+		/* refused already; 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 "
@@ -383,8 +334,8 @@ commit_copy_messages(int dfd, struct mbox_index *desti
 		memset(&mapping, 0, sizeof(mapping));
 		mapping.src_uid = staged[i].src_uid;
 		mapping.dest_uid = staged[i].dest_uid;
-		if (imsg_compose(&iev->ibuf, IMSG_MBOX_COPY_MAPPING, 0, 0, -1,
-		    &mapping, sizeof(mapping)) == -1)
+		if (imsg_compose(&ss->iev.ibuf, IMSG_MBOX_COPY_MAPPING, 0, 0,
+		    -1, &mapping, sizeof(mapping)) == -1)
 			log_warn("session %u: imsg_compose "
 			    "IMSG_MBOX_COPY_MAPPING", session_id);
 	}
@@ -392,13 +343,7 @@ commit_copy_messages(int dfd, struct mbox_index *desti
 	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: no partial copy */
 	while (ncommitted > 0) {
 		char	curpath[320];
 		char	letters[8];
@@ -417,19 +362,12 @@ rollback:
 	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. 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,
-    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,
+lock_copy_move_mailboxes(struct store_session *ss, const char *what,
+    const char *destname, 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)
 {
 	int	firstfd, secondfd, first_is_dest, locked;
@@ -441,10 +379,10 @@ lock_copy_move_mailboxes(const char *what, const char 
 		result->error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
 		return (0);
 	}
-	*cross_mailbox_out = (strcmp(selected_mailbox, desttarget) != 0);
+	*cross_mailbox_out = (strcmp(ss->selected_mailbox, desttarget) != 0);
 
 	if (!*cross_mailbox_out) {
-		locked = index_lock_acquire(mailbox_dir_fd, il_a,
+		locked = index_lock_acquire(ss->mailbox_dir_fd, il_a,
 		    LOCK_EX | LOCK_NB);
 		if (locked == 1)
 			return (2);
@@ -464,14 +402,10 @@ lock_copy_move_mailboxes(const char *what, const char 
 		return (0);
 	}
 
-	/*
-	 * 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;
+	/* both locked at once: take them in name order, against AB-BA */
+	first_is_dest = strcmp(ss->selected_mailbox, desttarget) > 0;
+	firstfd = first_is_dest ? *destfd_out : ss->mailbox_dir_fd;
+	secondfd = first_is_dest ? ss->mailbox_dir_fd : *destfd_out;
 
 	locked = index_lock_acquire(firstfd, il_a, LOCK_EX | LOCK_NB);
 	if (locked != 0)
@@ -501,13 +435,11 @@ fail:
 	return (0);
 }
 
-/* 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 idempotent, safe on an INDEX_LOCK_INIT struct never acquired */
 	index_lock_release(il_a);
 	index_lock_release(il_b);
 
@@ -522,12 +454,13 @@ finish_copy_move(struct mbox_index *idx_a, struct mbox
 		    session_id);
 }
 
-/* RFC 9051 SS6.4.7 COPY; if dest != SELECTed, indexes lock in strcmp() order */
+/* RFC 9051 SS6.4.7 COPY */
 
 int
 handle_mbox_copy(struct imsg_mbox_copy *req, const struct seq_range *ranges,
-    uint32_t nranges, struct imsgev *iev)
+    uint32_t nranges, struct store_session *ss)
 {
+	struct imsgev			*iev = &ss->iev;
 	struct mbox_index		 idx_a, idx_b;
 	struct mbox_index		*srcidx = NULL, *destidx = NULL;
 	struct imsg_mbox_result	 result;
@@ -544,7 +477,7 @@ handle_mbox_copy(struct imsg_mbox_copy *req, const str
 	memset(&idx_b, 0, sizeof(idx_b));
 	memset(&result, 0, sizeof(result));
 
-	got = lock_copy_move_mailboxes("COPY", req->destname, &destfd,
+	got = lock_copy_move_mailboxes(ss, "COPY", req->destname, &destfd,
 	    &idx_a, &idx_b, &srcidx, &destidx, desttarget,
 	    sizeof(desttarget), &cross_mailbox, &il_a, &il_b, &result);
 	if (got == 2)
@@ -553,10 +486,10 @@ handle_mbox_copy(struct imsg_mbox_copy *req, const str
 		ok = 0;
 		goto done;
 	}
-	dfd = cross_mailbox ? destfd : mailbox_dir_fd;
+	dfd = cross_mailbox ? destfd : ss->mailbox_dir_fd;
 
-	if (!stage_copy_messages(mailbox_dir_fd, srcidx, req, ranges,
-	    nranges, &staged, &nstaged)) {
+	if (!stage_copy_messages(ss, srcidx, req, ranges, nranges, &staged,
+	    &nstaged)) {
 		ok = 0;
 		goto close_dest;
 	}
@@ -566,7 +499,7 @@ handle_mbox_copy(struct imsg_mbox_copy *req, const str
 			ok = 0;
 			goto close_dest;
 		}
-		if (!commit_copy_messages(dfd, destidx, staged, nstaged, iev))
+		if (!commit_copy_messages(ss, dfd, destidx, staged, nstaged))
 			ok = 0;
 	}
 
@@ -582,20 +515,13 @@ close_dest:
 
 done:
 	for (i = 0; i < nstaged; i++)
-		free(staged[i].body);
+		free(staged[i].srcpath);
 	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.
- */
 static size_t
 compaction_bail(struct mbox_index *idx, size_t dropped, size_t out)
 {
@@ -607,15 +533,12 @@ 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.
- */
+/* test the read position, not the write index */
 
 static int
 move_same_mailbox(struct imsg_mbox_copy *req, const struct seq_range *ranges,
     uint32_t nranges, struct mbox_index *idx, uint32_t *nmoved_out,
-    struct imsgev *iev)
+    struct store_session *ss)
 {
 	struct moved {
 		uint32_t	old_uid;
@@ -625,6 +548,7 @@ move_same_mailbox(struct imsg_mbox_copy *req, const st
 		char		keywords[512];
 	};
 
+	struct imsgev		*iev = &ss->iev;
 	struct moved		*moved = NULL;
 	size_t			 nmoved = 0, movedcap = 0, in, out, i;
 	struct seq_range	 resolved[SEQSET_MAX_RANGES];
@@ -633,10 +557,6 @@ move_same_mailbox(struct imsg_mbox_copy *req, const st
 
 	*nmoved_out = 0;
 
-	/*
-	 * "*"/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);
@@ -745,12 +665,12 @@ move_same_mailbox(struct imsg_mbox_copy *req, const st
 		}
 	}
 
-	if (index_save(mailbox_dir_fd, idx) == -1) {
+	if (index_save(ss->mailbox_dir_fd, idx) == -1) {
 		ok = 0;
 		goto done;
 	}
 
-	/* COPYUID mapping data first... */
+	/* COPYUID mapping first */
 	for (i = 0; i < nmoved; i++) {
 		struct imsg_mbox_copy_mapping	 mapping;
 
@@ -762,7 +682,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 old UIDs/seqnos (RFC 9051 SS6.4.8) */
+	/* then EXPUNGE (RFC 9051 SS6.4.8) */
 	for (i = 0; i < nmoved; i++) {
 		struct imsg_mbox_expunged	 exp;
 
@@ -781,22 +701,22 @@ done:
 	return (ok);
 }
 
-/* Cross-mailbox MOVE (SS6.4.8): dest commits before removal, no lost mail */
+/* RFC 9051 SS6.4.8: dest commits before removal */
 
 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,
-    int destfd, uint32_t *nmoved_out,
-    struct imsgev *iev)
+    int destfd, uint32_t *nmoved_out, struct store_session *ss)
 {
+	struct imsgev		*iev = &ss->iev;
 	struct copy_staged	*staged = NULL;
 	size_t			 nstaged = 0, i;
 	int			 ok = 1, any_removed = 0;
 
 	*nmoved_out = 0;
 
-	if (!stage_copy_messages(mailbox_dir_fd, srcidx, req, ranges,
-	    nranges, &staged, &nstaged))
+	if (!stage_copy_messages(ss, srcidx, req, ranges, nranges, &staged,
+	    &nstaged))
 		return (0);
 
 	if (nstaged == 0)
@@ -806,8 +726,7 @@ move_cross_mailbox(struct imsg_mbox_copy *req, const s
 		ok = 0;
 		goto cleanup;
 	}
-	if (!commit_copy_messages(destfd, destidx, staged, nstaged,
-	    iev)) {
+	if (!commit_copy_messages(ss, destfd, destidx, staged, nstaged)) {
 		ok = 0;
 		goto cleanup;
 	}
@@ -820,10 +739,6 @@ 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
-		 * 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) {
@@ -842,12 +757,12 @@ move_cross_mailbox(struct imsg_mbox_copy *req, const s
 		}
 		any_removed = 1;
 
-		if (locate_message_file(mailbox_dir_fd, rec.basename,
-		    &size, suffix, sizeof(suffix)) == 0) {
+		if (locate_message_file(&ss->cur_snap, ss->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))
-				unlinkat(mailbox_dir_fd, path, 0);
+				unlinkat(ss->mailbox_dir_fd, path, 0);
 		}
 
 		{
@@ -863,11 +778,11 @@ move_cross_mailbox(struct imsg_mbox_copy *req, const s
 		}
 	}
 
-	/* RFC 7162 SS3.1: HIGHESTMODSEQ bump per op; see handle_mbox_expunge */
+	/* RFC 7162 SS3.1 */
 	if (any_removed)
 		srcidx->highestmodseq++;
 
-	if (index_save(mailbox_dir_fd, srcidx) == -1) {
+	if (index_save(ss->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);
@@ -879,17 +794,18 @@ move_cross_mailbox(struct imsg_mbox_copy *req, const s
 
 cleanup:
 	for (i = 0; i < nstaged; i++)
-		free(staged[i].body);
+		free(staged[i].srcpath);
 	free(staged);
 	return (ok);
 }
 
-/* RFC 9051 SS6.4.8 MOVE dispatcher: locks index(es), hands off to helpers */
+/* RFC 9051 SS6.4.8 MOVE */
 
 int
 handle_mbox_move(struct imsg_mbox_copy *req, const struct seq_range *ranges,
-    uint32_t nranges, struct imsgev *iev)
+    uint32_t nranges, struct store_session *ss)
 {
+	struct imsgev			*iev = &ss->iev;
 	struct mbox_index		 idx_a, idx_b;
 	struct mbox_index		*srcidx = NULL, *destidx = NULL;
 	struct imsg_mbox_result	 result;
@@ -905,7 +821,7 @@ handle_mbox_move(struct imsg_mbox_copy *req, const str
 	memset(&idx_b, 0, sizeof(idx_b));
 	memset(&result, 0, sizeof(result));
 
-	got = lock_copy_move_mailboxes("MOVE", req->destname, &destfd,
+	got = lock_copy_move_mailboxes(ss, "MOVE", req->destname, &destfd,
 	    &idx_a, &idx_b, &srcidx, &destidx, desttarget,
 	    sizeof(desttarget), &cross_mailbox, &il_a, &il_b, &result);
 	if (got == 2)
@@ -917,11 +833,11 @@ handle_mbox_move(struct imsg_mbox_copy *req, const str
 
 	if (!cross_mailbox) {
 		if (!move_same_mailbox(req, ranges, nranges, srcidx, &nmoved,
-		    iev))
+		    ss))
 			ok = 0;
 	} else {
 		if (!move_cross_mailbox(req, ranges, nranges, srcidx, destidx,
-		    destfd, &nmoved, iev))
+		    destfd, &nmoved, ss))
 			ok = 0;
 	}
 
blob - e7b9051e620318760b38289f0fe6823b5a4cea7e
blob + a0ae81162de65bd2b4a9878669c2f7cdd5292754
--- src/mbox_fetch.c
+++ src/mbox_fetch.c
@@ -16,8 +16,6 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* FETCH and STATUS request handling. */
-
 #include <sys/types.h>
 #include <sys/file.h>
 #include <sys/stat.h>
@@ -38,36 +36,11 @@
 #include "log.h"
 #include "store_internal.h"
 
-/*
- * 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;
+static void	 fetch_walk_step(struct store_session *);
+static void	 fetch_walk_finish(struct store_session *, int);
 
-/* 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,
+fetch_send_part(struct store_session *ss, int imsg_type, const char *what,
     const void *meta, size_t metalen, int found, const void *buf,
     uint32_t buflen)
 {
@@ -81,103 +54,95 @@ fetch_send_part(struct imsgev *iev, int imsg_type, con
 	memcpy(combined, meta, metalen);
 	if (found && buflen > 0)
 		memcpy((char *)combined + metalen, buf, buflen);
-	if (imsg_compose(&iev->ibuf, imsg_type, 0, 0, -1, combined,
+	if (imsg_compose(&ss->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;
+		ss->fetch.composed += combined_len;
 	free(combined);
 }
 
-/*
- * Returns 0 having answered or started the walk, or 1 without answering
- * because another session holds the index lock; store.c runs it again.
- */
+/* returns 1, unanswered, if the index lock is busy */
 int
 handle_mbox_fetch(struct imsg_mbox_fetch *req, const struct seq_range *ranges,
-    uint32_t nranges, struct imsgev *iev)
+    uint32_t nranges, struct store_session *ss)
 {
-	struct mbox_index	*idx = &fetch_walk.idx;
+	struct store_fetch_walk	*fw = &ss->fetch;
+	struct imsgev		*iev = &ss->iev;
+	struct mbox_index	*idx = &fw->idx;
 	struct index_lock	 il = INDEX_LOCK_INIT;
 	uint32_t		 i;
 	int			 locked;
 
 	/* Before the reset below, so that a busy return changes nothing. */
-	locked = index_lock_acquire(mailbox_dir_fd, &il, LOCK_SH | LOCK_NB);
+	locked = index_lock_acquire(ss->mailbox_dir_fd, &il, LOCK_SH | LOCK_NB);
 	if (locked == 1)
 		return (1);
 
-	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.
-		 */
+	if (fw->active) {
 		log_warnx("session %u: FETCH arrived during a paused FETCH, "
 		    "abandoning the earlier walk", session_id);
-		index_free(&fetch_walk.idx);
+		index_free(&fw->idx);
 	}
-	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;
+	memset(fw, 0, sizeof(*fw));
+	fw->req = *req;
+	fw->next = 1;
 
 	if (locked == -1) {
-		fetch_walk_finish(0);
+		fetch_walk_finish(ss, 0);
 		return (0);
 	}
 	if (index_load(il.fd, idx) == -1) {
 		index_lock_release(&il);
-		fetch_walk_finish(0);
+		fetch_walk_finish(ss, 0);
 		return (0);
 	}
 	index_lock_release(&il);
 
-	/* RFC 9051 SS6.4.9: UID FETCH set is UIDs; see index_max_uid() */
-	fetch_walk.nresolved = seqset_resolve(ranges, nranges, req->by_uid ?
+	/* RFC 9051 SS6.4.9 */
+	fw->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);
+	    fw->resolved);
+	fw->max_hi = seqset_max_hi(fw->resolved, fw->nresolved);
 
-	/*
-	 * RFC 7162 SS3.2.6: VANISHED (EARLIER) precedes FETCH, per send order
-	 * here
-	 */
+	/* RFC 7162 SS3.2.6: VANISHED (EARLIER) first */
 	if (req->by_uid && req->want_vanished) {
-		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 = 0; i < fw->nresolved; i++)
+			send_vanished_range(idx, fw->resolved[i].lo,
+			    fw->resolved[i].hi, iev);
 	}
 
-	fetch_walk.active = 1;
-	fetch_walk_step();
+	fw->active = 1;
+	fetch_walk_step(ss);
 	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 int
+fetch_needs_parser(const struct imsg_mbox_fetch *req)
+{
+	if (req->attrs & (MBOX_FETCH_ENVELOPE | MBOX_FETCH_BODYSTRUCTURE))
+		return (1);
+	if ((req->attrs & MBOX_FETCH_HEADER_FIELDS) &&
+	    !(req->attrs & MBOX_FETCH_BODY_HEADER))
+		return (1);
+	return ((req->attrs & MBOX_FETCH_BODY_PART) &&
+	    !(req->attrs & (MBOX_FETCH_BODY_WHOLE | MBOX_FETCH_BODY_TEXT)));
+}
+
+/* returns, still active, once FETCH_BATCH_MAX is queued */
 static void
-fetch_walk_step(void)
+fetch_walk_step(struct store_session *ss)
 {
-	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;
+	struct store_fetch_walk	*fw = &ss->fetch;
+	struct mbox_index	*idx = &fw->idx;
+	struct imsg_mbox_fetch	*req = &fw->req;
+	struct imsgev		*iev = &ss->iev;
+	struct seq_range	*resolved = fw->resolved;
+	uint32_t		 nresolved = fw->nresolved;
+	uint32_t		 max_hi = fw->max_hi;
 	uint32_t		 i;
 
-	for (i = fetch_walk.next; i <= (uint32_t)idx->nlines; i++) {
+	for (i = fw->next; i <= (uint32_t)idx->nlines; i++) {
 		struct imsg_mbox_fetch_meta	 meta;
 		struct index_rec		 rec;
 		enum seqset_pos			 pos;
@@ -188,10 +153,6 @@ fetch_walk_step(void)
 		if (index_parse_line(idx->lines[i - 1], &rec) == -1)
 			continue;
 
-		/*
-		 * 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)
@@ -202,14 +163,27 @@ fetch_walk_step(void)
 		if (req->has_changedsince && rec.modseq <= req->changedsince)
 			continue;
 
+		if (!fw->no_parser && fetch_needs_parser(req)) {
+			int	 ready = parser_ready();
+
+			if (ready == 0) {
+				fw->next = i;
+				fw->wait_parser = 1;
+				return;
+			}
+			if (ready == -1)
+				fw->no_parser = 1;
+		}
+
 		memset(&meta, 0, sizeof(meta));
 		meta.seqno = i;
 		meta.uid = rec.uid;
 		meta.modseq = rec.modseq;
 
 		if (req->attrs & (MBOX_FETCH_RFC822_SIZE | MBOX_FETCH_FLAGS)) {
-			if (locate_message_file(mailbox_dir_fd, rec.basename,
-			    &size, suffix, sizeof(suffix)) == -1) {
+			if (locate_message_file(&ss->cur_snap,
+			    ss->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);
@@ -227,42 +201,50 @@ fetch_walk_step(void)
 			meta.internaldate = parse_maildir_timestamp(
 			    rec.basename);
 
-		/*
-		 * FETCH_HEADER precedes FETCH_META (listener order); always
-		 * sent, found=0
-		 */
+		/* FETCH_HEADER precedes FETCH_META */
 		if (req->attrs & (MBOX_FETCH_BODY_HEADER |
 		    MBOX_FETCH_HEADER_FIELDS)) {
 			struct imsg_mbox_fetch_header	 hdrmeta;
+			struct imsg_parser_req		 preq;
+			struct imsg_parser_rep		 prep;
 			char				*hdrbuf = NULL;
 			uint32_t			 hdrlen = 0;
-			int				 rc;
+			int				 mfd, rc;
 
 			memset(&hdrmeta, 0, sizeof(hdrmeta));
 			hdrmeta.seqno = i;
 			hdrmeta.uid = rec.uid;
 			if (req->attrs & MBOX_FETCH_BODY_HEADER)
-				rc = read_message_header(mailbox_dir_fd,
-				    rec.basename, &hdrbuf, &hdrlen);
-			else
-				rc = read_message_header_fields(mailbox_dir_fd,
-				    rec.basename, req->header_fields,
-				    req->header_fields_not, &hdrbuf, &hdrlen);
+				rc = read_message_header(&ss->cur_snap,
+				    ss->mailbox_dir_fd, rec.basename, &hdrbuf,
+				    &hdrlen);
+			else if ((mfd = open_message_file(&ss->cur_snap,
+			    ss->mailbox_dir_fd, rec.basename)) == -1)
+				rc = -1;
+			else {
+				memset(&preq, 0, sizeof(preq));
+				/* same size, and terminated on arrival */
+				(void)strlcpy(preq.fields, req->header_fields,
+				    sizeof(preq.fields));
+				preq.fields_not = req->header_fields_not;
+				rc = parser_request(IMSG_PARSER_HEADER_FIELDS,
+				    "HEADER.FIELDS", mfd, rec.basename, &preq,
+				    NULL, 0, FETCH_HEADER_MAX, &prep, &hdrbuf);
+				hdrlen = prep.len;
+				close(mfd);
+			}
 			if (rc == 0) {
 				hdrmeta.found = 1;
 				hdrmeta.hdrlen = hdrlen;
 			}
 
-			fetch_send_part(iev, IMSG_MBOX_FETCH_HEADER,
+			fetch_send_part(ss, IMSG_MBOX_FETCH_HEADER,
 			    "IMSG_MBOX_FETCH_HEADER", &hdrmeta, sizeof(hdrmeta),
 			    hdrmeta.found, hdrbuf, hdrlen);
 			free(hdrbuf);
 		}
 
-		/*
-		 * IMSG_MBOX_FETCH_BODY, same contract as HEADER; WHOLE wins
-		 * over TEXT/PART. It carries a descriptor and a range.
-		 */
+		/* WHOLE wins over TEXT and PART */
 		if (req->attrs & (MBOX_FETCH_BODY_WHOLE | MBOX_FETCH_BODY_TEXT |
 		    MBOX_FETCH_BODY_PART)) {
 			struct imsg_mbox_fetch_body	 bodymeta;
@@ -283,39 +265,42 @@ fetch_walk_step(void)
 			bodymeta.uid = rec.uid;
 
 			if (want_part) {
-				int	path[MIME_MAX_DEPTH];
-				int	pathlen;
+				struct imsg_parser_req	 preq;
+				struct imsg_parser_rep	 prep;
 
-				pathlen = parse_section_part(req->section_part,
-				    path, MIME_MAX_DEPTH);
-				if (pathlen != -1 &&
-				    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) {
+				memset(&preq, 0, sizeof(preq));
+				preq.pathlen = parse_section_part(
+				    req->section_part, preq.path,
+				    MIME_MAX_DEPTH);
+				if (preq.pathlen != -1 &&
+				    (fd = open_message_file(&ss->cur_snap,
+				    ss->mailbox_dir_fd, rec.basename)) == -1)
 					fdbusy = errno == EMFILE ||
 					    errno == ENFILE;
-					bodymeta.found = 0;
+				else if (preq.pathlen != -1 &&
+				    parser_request(IMSG_PARSER_PART,
+				    "BODY[<part>]", fd, rec.basename, &preq,
+				    NULL, 0, 0, &prep, NULL) == 0) {
+					bodymeta.found = 1;
+					off = prep.part_off;
+					len = prep.part_len;
 				}
+				/* the fd the extent was checked on */
+				if (fd != -1 && (!bodymeta.found || len == 0)) {
+					close(fd);
+					fd = -1;
+				}
 			} else {
-				fd = message_body_range(mailbox_dir_fd,
-				    rec.basename, want_text, &off, &len,
-				    &fdbusy);
+				fd = message_body_range(&ss->cur_snap,
+				    ss->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;
+			/* out of descriptors: retry once sent */
+			if (fdbusy && fw->fds > 0) {
+				fw->next = i;
 				return;
 			}
 
@@ -339,58 +324,63 @@ fetch_walk_step(void)
 				if (fd != -1)
 					close(fd);
 			} else {
-				fetch_composed += sizeof(bodymeta);
+				fw->composed += sizeof(bodymeta);
 				if (fd != -1)
-					fetch_fds++;
+					fw->fds++;
 			}
 		}
 
-		/*
-		 * FETCH_ENVELOPE, same contract as HEADER/BODY; bytes are
-		 * formatted text
-		 */
 		if (req->attrs & MBOX_FETCH_ENVELOPE) {
 			struct imsg_mbox_fetch_envelope	 envmeta;
+			struct imsg_parser_rep		 prep;
 			char					*envbuf = NULL;
-			uint32_t				 envlen = 0;
+			int					 mfd;
 
 			memset(&envmeta, 0, sizeof(envmeta));
 			envmeta.seqno = i;
 			envmeta.uid = rec.uid;
-			if (build_envelope(mailbox_dir_fd, rec.basename,
-			    &envbuf, &envlen) == 0) {
-				envmeta.found = 1;
-				envmeta.envlen = envlen;
+			if ((mfd = open_message_file(&ss->cur_snap,
+			    ss->mailbox_dir_fd, rec.basename)) != -1) {
+				if (parser_request(IMSG_PARSER_ENVELOPE,
+				    "ENVELOPE", mfd, rec.basename, NULL, NULL,
+				    0, ENVELOPE_MAX, &prep, &envbuf) == 0) {
+					envmeta.found = 1;
+					envmeta.envlen = prep.len;
+				}
+				close(mfd);
 			}
 
-			fetch_send_part(iev, IMSG_MBOX_FETCH_ENVELOPE,
+			fetch_send_part(ss, IMSG_MBOX_FETCH_ENVELOPE,
 			    "IMSG_MBOX_FETCH_ENVELOPE", &envmeta,
 			    sizeof(envmeta),
-			    envmeta.found, envbuf, envlen);
+			    envmeta.found, envbuf, envmeta.envlen);
 			free(envbuf);
 		}
 
-		/*
-		 * IMSG_MBOX_FETCH_BODYSTRUCTURE, same contract/shape as
-		 * ENVELOPE above
-		 */
 		if (req->attrs & MBOX_FETCH_BODYSTRUCTURE) {
 			struct imsg_mbox_fetch_bodystructure	 bsmeta;
+			struct imsg_parser_rep			 prep;
 			char					*bsbuf = NULL;
-			uint32_t				 bslen = 0;
+			int					 mfd;
 
 			memset(&bsmeta, 0, sizeof(bsmeta));
 			bsmeta.seqno = i;
 			bsmeta.uid = rec.uid;
-			if (build_bodystructure(mailbox_dir_fd,
-			    rec.basename, &bsbuf, &bslen) == 0) {
-				bsmeta.found = 1;
-				bsmeta.bslen = bslen;
+			if ((mfd = open_message_file(&ss->cur_snap,
+			    ss->mailbox_dir_fd, rec.basename)) != -1) {
+				if (parser_request(IMSG_PARSER_BODYSTRUCTURE,
+				    "BODYSTRUCTURE", mfd, rec.basename, NULL,
+				    NULL, 0, BODYSTRUCTURE_MAX, &prep,
+				    &bsbuf) == 0) {
+					bsmeta.found = 1;
+					bsmeta.bslen = prep.len;
+				}
+				close(mfd);
 			}
 
-			fetch_send_part(iev, IMSG_MBOX_FETCH_BODYSTRUCTURE,
+			fetch_send_part(ss, IMSG_MBOX_FETCH_BODYSTRUCTURE,
 			    "IMSG_MBOX_FETCH_BODYSTRUCTURE", &bsmeta,
-			    sizeof(bsmeta), bsmeta.found, bsbuf, bslen);
+			    sizeof(bsmeta), bsmeta.found, bsbuf, bsmeta.bslen);
 			free(bsbuf);
 		}
 
@@ -399,84 +389,84 @@ fetch_walk_step(void)
 			log_warn("session %u: imsg_compose "
 			    "IMSG_MBOX_FETCH_META", session_id);
 		else {
-			fetch_walk.sent++;
-			fetch_composed += sizeof(meta);
+			fw->sent++;
+			fw->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;
+		/* enough queued: let it drain */
+		if (fw->composed >= FETCH_BATCH_MAX ||
+		    fw->fds >= FETCH_FD_MAX) {
+			fw->next = i + 1;
 			return;
 		}
 	}
-	fetch_walk_finish(1);
+	fetch_walk_finish(ss, 1);
 }
 
-/* Sends the terminal result and drops the walk's snapshots. */
 static void
-fetch_walk_finish(int ok)
+fetch_walk_finish(struct store_session *ss, int ok)
 {
+	struct store_fetch_walk	*fw = &ss->fetch;
 	struct imsg_mbox_result	 result;
-	struct imsgev		*iev = fetch_walk.iev;
+	struct imsgev		*iev = &ss->iev;
 
-	index_free(&fetch_walk.idx);
-	cur_snapshot_discard();
+	index_free(&fw->idx);
+	cur_snapshot_discard(&ss->cur_snap);
 
 	memset(&result, 0, sizeof(result));
 	result.error = ok ? MBOX_OP_OK : MBOX_OP_ERR_GENERIC;
-	result.count = fetch_walk.sent;
+	result.count = fw->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;
+	fw->active = 0;
 }
 
-/* 1 while a FETCH walk is part way through; store.c keeps its state. */
 int
-fetch_walk_paused(void)
+fetch_walk_paused(struct store_session *ss)
 {
-	return (fetch_walk.active);
+	return (ss->fetch.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)
+fetch_walk_resume(struct store_session *ss)
 {
-	if (!fetch_walk.active || fetch_walk.iev != iev)
+	struct store_fetch_walk	*fw = &ss->fetch;
+
+	if (!fw->active || fw->wait_parser)
 		return;
-	if (imsgbuf_queuelen(&iev->ibuf) > 0)
+	if (imsgbuf_queuelen(&ss->iev.ibuf) > 0)
 		return;
-	fetch_composed = 0;
-	fetch_fds = 0;
-	fetch_walk_step();
+	fw->composed = 0;
+	fw->fds = 0;
+	fetch_walk_step(ss);
 }
 
-/* Drops a paused walk's snapshots when the child is told to exit. */
 void
-fetch_walk_abort(void)
+fetch_walk_parser(struct store_session *ss, int ok)
 {
-	if (!fetch_walk.active)
+	struct store_fetch_walk	*fw = &ss->fetch;
+
+	if (!fw->active || !fw->wait_parser)
 		return;
-	index_free(&fetch_walk.idx);
-	fetch_walk.active = 0;
-	fetch_walk.iev = NULL;
+	fw->wait_parser = 0;
+	if (!ok)
+		fw->no_parser = 1;
+	fetch_walk_step(ss);
 }
 
-/* Inverse of build_flags_string()'s table: flag letters back to MBOX_FLAG_* */
+void
+fetch_walk_abort(struct store_session *ss)
+{
+	struct store_fetch_walk	*fw = &ss->fetch;
+
+	if (!fw->active)
+		return;
+	index_free(&fw->idx);
+	fw->active = 0;
+}
+
 uint32_t
 letters_to_sysflags(const char *letters)
 {
@@ -495,7 +485,7 @@ letters_to_sysflags(const char *letters)
 	return (f);
 }
 
-/* Renders sysflags into maildir(5)'s ASCII letter order: D, F, R, S, T. */
+/* maildir(5) order: D, F, R, S, T */
 void
 sysflags_to_letters(uint32_t sysflags, char *out, size_t outsize)
 {
@@ -514,10 +504,11 @@ sysflags_to_letters(uint32_t sysflags, char *out, size
 	out[i] = '\0';
 }
 
-/* RFC 9051 SS6.3.11 STATUS; reuses SELECT's scan; extras scan if requested */
+/* RFC 9051 SS6.3.11 STATUS */
 int
-handle_mbox_status(struct imsg_mbox_status *req, struct imsgev *iev)
+handle_mbox_status(struct imsg_mbox_status *req, struct store_session *ss)
 {
+	struct imsgev			*iev = &ss->iev;
 	struct mbox_index		 idx;
 	struct imsg_mbox_status_result	 reply;
 	struct index_lock		 il = INDEX_LOCK_INIT;
@@ -528,7 +519,6 @@ handle_mbox_status(struct imsg_mbox_status *req, struc
 
 	memset(&reply, 0, sizeof(reply));
 
-	/* 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))
@@ -562,12 +552,7 @@ 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.
-	 */
+	/* STATUS may be first to touch a mailbox: persist a fresh header */
 	if (idx.fresh && index_save(tfd, &idx) == -1)
 		log_warnx("session %u: STATUS: could not persist the new "
 		    "index header", session_id);
@@ -629,8 +614,9 @@ handle_mbox_status(struct imsg_mbox_status *req, struc
 
 			if (index_parse_line(idx.lines[i], &rec) == -1)
 				continue;
-			if (locate_message_file(tfd, rec.basename,
-			    &size, suffix, sizeof(suffix)) == -1) {
+			if (locate_message_file(&ss->cur_snap, 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,
blob - b29930cfcfcfffe8151e681d4f505d879b046a93
blob + 1dc72a9e2541dae6be222c72dbf7351f2615bd57
--- src/mbox_manage.c
+++ src/mbox_manage.c
@@ -16,7 +16,6 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* mbox_manage.c: CREATE/DELETE/RENAME/LIST/APPEND/SELECT request handling. */
 
 #include <sys/types.h>
 #include <sys/file.h>
@@ -40,8 +39,9 @@
 
 int
 handle_mbox_select(struct imsg_mbox_select *req,
-    const struct seq_range *ranges, uint32_t nranges, struct imsgev *iev)
+    const struct seq_range *ranges, uint32_t nranges, struct store_session *ss)
 {
+	struct imsgev		*iev = &ss->iev;
 	struct mbox_index	 idx;
 	struct imsg_mbox_selected reply;
 	struct index_lock	 il = INDEX_LOCK_INIT;
@@ -50,15 +50,9 @@ handle_mbox_select(struct imsg_mbox_select *req,
 
 	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();
+	/* invalidate the IDLE state first */
+	idle_probe_reset(ss);
+	idle_baseline_reset(ss);
 
 	if (mailbox_name_is_inbox(req->mailbox))
 		target = "";
@@ -78,10 +72,6 @@ handle_mbox_select(struct imsg_mbox_select *req,
 		goto send;
 	}
 
-	/*
-	 * 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 */
@@ -101,17 +91,13 @@ handle_mbox_select(struct imsg_mbox_select *req,
 		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;
+	/* commit point: nothing above touched this session */
+	if (ss->mailbox_dir_fd != -1)
+		close(ss->mailbox_dir_fd);
+	ss->mailbox_dir_fd = dfd;
 
-	if (strlcpy(selected_mailbox, target, sizeof(selected_mailbox)) >=
-	    sizeof(selected_mailbox)) {
+	if (strlcpy(ss->selected_mailbox, target,
+	    sizeof(ss->selected_mailbox)) >= sizeof(ss->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);
@@ -122,14 +108,14 @@ handle_mbox_select(struct imsg_mbox_select *req,
 	}
 
 	reply.error = MBOX_OP_OK;
-	mailbox_selected = 1;	/* the store's gate; see store_internal.h */
+	ss->mailbox_selected = 1;
 	reply.exists = (uint32_t)idx.nlines;
 	reply.uidvalidity = idx.uidvalidity;
 	reply.uidnext = idx.uidnext;
 	reply.highestmodseq = idx.highestmodseq;
 
 	if (req->qresync && req->qresync_uidvalidity == idx.uidvalidity)
-		qresync_send_resync(req, ranges, nranges, &idx, iev);
+		qresync_send_resync(req, ranges, nranges, &idx, ss);
 
 	index_free(&idx);
 	index_lock_release(&il);
@@ -165,7 +151,7 @@ ensure_maildir_dirs(int dfd, const char *prefix)
 }
 
 
-/* IMSG_MBOX_CREATE (RFC 9051 SS6.3.4); mkdir EEXIST means already exists */
+/* RFC 9051 SS6.3.4 CREATE */
 
 void
 handle_mbox_create(struct imsg_mbox_create *req, struct imsgev *iev)
@@ -192,10 +178,7 @@ 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 */
 			result.error = MBOX_OP_ERR_ALREADY_EXISTS;
 		}
 		goto send;
@@ -204,10 +187,6 @@ handle_mbox_create(struct imsg_mbox_create *req, struc
 	if (snprintf(prefix, sizeof(prefix), "%s/", 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;
@@ -222,14 +201,11 @@ send:
 		    session_id);
 }
 
-/* bounded removal of a mailbox's tmp/new/cur + index; refuses non-regulars */
 static int
 remove_maildir_subtree(int dfd, const char *prefix)
 {
 	static const char *dirs[] = { "tmp", "new", "cur" };
-	/*
-	 * +32 must cover STORE_INDEX_NAME on a near-maximal mailbox name.
-	 */
+	/* room for STORE_INDEX_NAME after the longest name */
 	char	path[MBOX_NAME_MAX + 32];
 	size_t	i;
 
@@ -305,11 +281,7 @@ remove_maildir_subtree(int dfd, const char *prefix)
 		}
 	}
 
-	/*
-	 * 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.
-	 */
+	/* every file created here must go before rmdir(2) */
 	{
 		static const char *files[] = {
 			STORE_INDEX_NAME,
@@ -336,14 +308,12 @@ remove_maildir_subtree(int dfd, 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.
- */
+/* RFC 9051 SS6.3.5 DELETE */
 
 void
-handle_mbox_delete(struct imsg_mbox_delete *req, struct imsgev *iev)
+handle_mbox_delete(struct imsg_mbox_delete *req, struct store_session *ss)
 {
+	struct imsgev		*iev = &ss->iev;
 	struct imsg_mbox_result	 result;
 	struct stat			 st;
 	char				 prefix[MBOX_NAME_MAX + 1];
@@ -363,10 +333,7 @@ handle_mbox_delete(struct imsg_mbox_delete *req, struc
 	    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 */
 		result.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
 		goto send;
 	}
@@ -392,28 +359,22 @@ handle_mbox_delete(struct imsg_mbox_delete *req, struc
 	result.error = MBOX_OP_OK;
 
 send:
-	/*
-	 * 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.
-	 */
+	/* deleting the selection clears the store's gate */
 	if (result.error == MBOX_OP_OK &&
-	    strcmp(selected_mailbox, req->mailbox) == 0)
-		mailbox_selected = 0;
+	    strcmp(ss->selected_mailbox, req->mailbox) == 0)
+		ss->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).
- */
+/* RFC 9051 SS6.3.6 RENAME; INBOX is the root and cannot move */
 
 void
-handle_mbox_rename(struct imsg_mbox_rename *req, struct imsgev *iev)
+handle_mbox_rename(struct imsg_mbox_rename *req, struct store_session *ss)
 {
+	struct imsgev		*iev = &ss->iev;
 	struct imsg_mbox_result	 result;
 	struct stat			 st;
 
@@ -434,10 +395,7 @@ handle_mbox_rename(struct imsg_mbox_rename *req, struc
 	    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: example is RENAME failing on
-		 * missing source
-		 */
+		/* RFC 5530 SS3 NONEXISTENT */
 		result.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
 		goto send;
 	}
@@ -447,10 +405,7 @@ handle_mbox_rename(struct imsg_mbox_rename *req, struc
 	    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 worked example is this exact
-		 * RENAME case
-		 */
+		/* RFC 5530 SS3 ALREADYEXISTS */
 		result.error = MBOX_OP_ERR_ALREADY_EXISTS;
 		goto send;
 	}
@@ -466,15 +421,11 @@ handle_mbox_rename(struct imsg_mbox_rename *req, struc
 	result.error = MBOX_OP_OK;
 
 send:
-	/*
-	 * 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))
+	    strcmp(ss->selected_mailbox, req->oldname) == 0) {
+		if (strlcpy(ss->selected_mailbox, req->newname,
+		    sizeof(ss->selected_mailbox)) >=
+		    sizeof(ss->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);
@@ -485,13 +436,7 @@ send:
 		    session_id);
 }
 
-/*
- * 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.
- */
+/* LIST and the subscription code must agree on this */
 static int
 mbox_root_entry_is_mailbox(const char *name, int *badname)
 {
@@ -502,20 +447,11 @@ mbox_root_entry_is_mailbox(const char *name, int *badn
 	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.
-	 */
+	/* tmp, new and cur are INBOX's own */
 	if (strcmp(name, "tmp") == 0 || strcmp(name, "new") == 0 ||
 	    strcmp(name, "cur") == 0 || mailbox_name_reserved(name))
 		return (0);
@@ -527,10 +463,7 @@ mbox_root_entry_is_mailbox(const char *name, int *badn
 	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.
- */
+/* present 0: no file, so every mailbox is subscribed */
 struct sublist {
 	char	**names;
 	size_t	  n;
@@ -590,7 +523,6 @@ sublist_add(struct sublist *sl, const char *name)
 	return (0);
 }
 
-/* order is preserved so the file stays readable across rewrites */
 static void
 sublist_remove(struct sublist *sl, const char *name)
 {
@@ -607,12 +539,6 @@ sublist_remove(struct sublist *sl, const char *name)
 	}
 }
 
-/*
- * 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)
 {
@@ -639,11 +565,7 @@ sublist_load(struct sublist *sl)
 	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.
-		 */
+		/* fgets(3) splits an over-long line */
 		if (strchr(line, '\n') == NULL &&
 		    strlen(line) == sizeof(line) - 1) {
 			int	c = fgetc(fp);
@@ -659,12 +581,7 @@ sublist_load(struct sublist *sl)
 		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.
-		 */
+		/* a name SUBSCRIBE would refuse: the file was hand-edited */
 		if (!mailbox_name_syntax_ok(line)) {
 			log_warnx("session %u: unusable name in %s, refusing "
 			    "to parse it", session_id,
@@ -691,12 +608,7 @@ fail:
 	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.
- */
+/* as index_save(): O_EXCL temp, fsync(2), rename(2) */
 static int
 sublist_save(const struct sublist *sl)
 {
@@ -763,11 +675,6 @@ sublist_save(const struct sublist *sl)
 	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)
 {
@@ -797,13 +704,7 @@ subs_lock_release(int lockfd)
 	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.
- */
+/* the list an absent file stands for */
 static int
 subs_materialise(struct sublist *sl)
 {
@@ -837,10 +738,6 @@ subs_materialise(struct sublist *sl)
 	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)
 {
@@ -864,7 +761,7 @@ list_item_send(struct imsgev *iev, const char *name, i
 	return (1);
 }
 
-/* IMSG_MBOX_LIST (RFC 9051 SS6.3.9); streams LIST_ITEM/mailbox, no INBOX */
+/* RFC 9051 SS6.3.9 LIST; INBOX is not sent */
 
 void
 handle_mbox_list(struct imsg_mbox_list *req, struct imsgev *iev)
@@ -875,16 +772,11 @@ handle_mbox_list(struct imsg_mbox_list *req, struct im
 	struct dirent			*de;
 	size_t				 i;
 	int				 badname;
-	static int			 skip_warned;	/* see the loop below */
+	static int			 skip_warned;
 
 	memset(&result, 0, sizeof(result));
 
 
-	/*
-	 * 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;
 
@@ -905,14 +797,7 @@ handle_mbox_list(struct imsg_mbox_list *req, struct im
 
 	while ((de = readdir(dp)) != NULL) {
 		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.
-			 */
+			/* a rejected name hides real mail: say so */
 			if (badname && !skip_warned) {
 				skip_warned = 1;
 				log_warnx("session %u: LIST: skipping %s: "
@@ -931,11 +816,7 @@ handle_mbox_list(struct imsg_mbox_list *req, struct im
 	}
 	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.
-	 */
+	/* RFC 9051 SS6.3.9.6: subscribed names with no mailbox */
 	for (i = 0; i < sl.n; i++) {
 		struct stat	st;
 
@@ -955,7 +836,7 @@ send:
 		    session_id);
 }
 
-/* IMSG_MBOX_SUBSCRIBE (RFC 9051 SS6.3.7); idempotent, name must exist */
+/* RFC 9051 SS6.3.7 SUBSCRIBE; idempotent */
 
 void
 handle_mbox_subscribe(struct imsg_mbox_subscribe *req, struct imsgev *iev)
@@ -967,11 +848,6 @@ handle_mbox_subscribe(struct imsg_mbox_subscribe *req,
 
 	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;
@@ -984,12 +860,7 @@ handle_mbox_subscribe(struct imsg_mbox_subscribe *req,
 	}
 
 
-	/*
-	 * 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.
-	 */
+	/* RFC 9051 SS6.3.7 lets a server require the mailbox exist */
 	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",
@@ -1023,7 +894,7 @@ send:
 		    session_id);
 }
 
-/* IMSG_MBOX_UNSUBSCRIBE (RFC 9051 SS6.3.8); idempotent, no existence test */
+/* RFC 9051 SS6.3.8 UNSUBSCRIBE; idempotent */
 
 void
 handle_mbox_unsubscribe(struct imsg_mbox_subscribe *req, struct imsgev *iev)
@@ -1034,18 +905,13 @@ handle_mbox_unsubscribe(struct imsg_mbox_subscribe *re
 
 	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.
-	 */
+	/* RFC 9051 SS6.3.8: no existence test */
 	if (!mailbox_name_valid(req->mailbox)) {
 		log_debug("session %u: UNSUBSCRIBE %s: invalid name",
 		    session_id, req->mailbox);
@@ -1059,11 +925,7 @@ handle_mbox_unsubscribe(struct imsg_mbox_subscribe *re
 		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.
-	 */
+	/* first UNSUBSCRIBE: write out what the absent file meant */
 	if (!sl.present && subs_materialise(&sl) == -1) {
 		result.error = MBOX_OP_ERR_GENERIC;
 		goto send;
@@ -1090,7 +952,6 @@ send:
 		    session_id);
 }
 
-/* host for maildir basename uniquer; cached, falls back to "imapd" */
 const char *
 append_hostname(void)
 {
@@ -1110,38 +971,20 @@ 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
-append_abort(void)
+append_abort(struct store_session *ss)
 {
-	if (!ap.active)
+	struct store_append	*ap = &ss->append;
+
+	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));
+	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));
 }
 
 static void
@@ -1153,28 +996,29 @@ append_reply(struct imsgev *iev, const struct imsg_mbo
 		    session_id);
 }
 
-/* Checks the target and opens its tmp/ file; the reply waits for END. */
 void
-handle_mbox_append_begin(const struct imsg_mbox_append *req)
+handle_mbox_append_begin(struct store_session *ss,
+    const struct imsg_mbox_append *req)
 {
+	struct store_append	*ap = &ss->append;
 	char		target[MBOX_NAME_MAX];
 	int64_t		delivery_ts;
 
-	if (ap.active) {
+	if (ap->active) {
 		log_warnx("session %u: APPEND begun with another in flight, "
 		    "abandoning the first", session_id);
-		append_abort();
+		append_abort(ss);
 	}
-	memset(&ap, 0, sizeof(ap));
-	ap.active = 1;
-	ap.tfd = -1;
-	ap.tmpfd = -1;
-	ap.req = *req;
-	ap.remaining = req->msglen;
+	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;
+	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 "
@@ -1194,94 +1038,89 @@ handle_mbox_append_begin(const struct imsg_mbox_append
 	    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;
+		ap->error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
 		return;
 	}
 
-	if ((ap.tfd = mailbox_open_dir(target)) == -1) {
+	if ((ap->tfd = mailbox_open_dir(target)) == -1) {
 		log_debug("session %u: APPEND %s: no such mailbox",
 		    session_id, req->mailbox);
-		ap.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
+		ap->error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
 		return;
 	}
-	if (ensure_maildir_dirs(ap.tfd, "") == -1)
+	if (ensure_maildir_dirs(ap->tfd, "") == -1)
 		return;
 
 	delivery_ts = req->has_date ? req->date : (int64_t)time(NULL);
-	if (snprintf(ap.basename, sizeof(ap.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(ap.basename)) {
+	    append_hostname()) >= (int)sizeof(ap->basename)) {
 		log_warnx("session %u: generated basename too long",
 		    session_id);
 		return;
 	}
-	if (snprintf(ap.tmppath, sizeof(ap.tmppath), "tmp/%s",
-	    ap.basename) >= (int)sizeof(ap.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);
-		ap.tmppath[0] = '\0';
+		ap->tmppath[0] = '\0';
 		return;
 	}
-	if ((ap.tmpfd = openat(ap.tfd, ap.tmppath,
+	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);
+		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';
+		ap->tmppath[0] = '\0';
 		return;
 	}
-	ap.failed = 0;
+	ap->failed = 0;
 }
 
-/* Writes one piece of the literal to the tmp/ file. */
 void
-handle_mbox_append_data(const char *buf, size_t len)
+handle_mbox_append_data(struct store_session *ss, const char *buf, size_t len)
 {
+	struct store_append	*ap = &ss->append;
 	size_t	written = 0;
 
-	if (!ap.active) {
+	if (!ap->active) {
 		log_warnx("session %u: APPEND data with no APPEND in flight, "
 		    "ignoring", session_id);
 		return;
 	}
-	if (len > ap.remaining) {
+	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;
+		ap->failed = 1;
+		ap->error = MBOX_OP_ERR_GENERIC;
+		ap->remaining = 0;
 		return;
 	}
-	ap.remaining -= len;
-	if (ap.failed)
+	ap->remaining -= len;
+	if (ap->failed)
 		return;
 
 	while (written < len) {
 		ssize_t	n;
 
-		n = write(ap.tmpfd, buf + written, len - written);
+		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;
+			    ap->tmppath);
+			ap->failed = 1;
+			ap->error = MBOX_OP_ERR_GENERIC;
 			return;
 		}
 		written += (size_t)n;
 	}
 }
 
-/*
- * 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.
- */
+/* the index is updated before the tmp/ to cur/ rename */
 int
-handle_mbox_append_end(struct imsgev *iev)
+handle_mbox_append_end(struct store_session *ss)
 {
+	struct store_append		*ap = &ss->append;
+	struct imsgev			*iev = &ss->iev;
 	struct imsg_mbox_appended	 reply;
 	struct mbox_index		 idx;
 	struct index_lock		 il = INDEX_LOCK_INIT;
@@ -1294,33 +1133,33 @@ handle_mbox_append_end(struct imsgev *iev)
 	memset(&reply, 0, sizeof(reply));
 	reply.error = MBOX_OP_ERR_GENERIC;
 
-	if (!ap.active) {
+	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) {
+	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;
+		    session_id, (unsigned long long)ap->remaining);
+		ap->failed = 1;
+		ap->error = MBOX_OP_ERR_GENERIC;
 	}
-	if (ap.failed) {
-		reply.error = ap.error;
+	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)
+	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;
+			    session_id, ap->tmppath);
+		close(ap->tmpfd);
+		ap->tmpfd = -1;
 	}
 
-	locked = index_lock_acquire(ap.tfd, &il, LOCK_EX | LOCK_NB);
+	locked = index_lock_acquire(ap->tfd, &il, LOCK_EX | LOCK_NB);
 	if (locked == 1)
 		return (1);
 	if (locked == -1)
@@ -1329,20 +1168,19 @@ handle_mbox_append_end(struct imsgev *iev)
 		goto done_index;
 
 	reply.uid = idx.uidnext;
-	if (index_append(&idx, reply.uid, ap.basename) == -1)
+	if (index_append(&idx, reply.uid, ap->basename) == -1)
 		goto done_index;
 	idx.uidnext++;
 
-	if (ap.req.keywords[0] != '\0') {
+	if (ap->req.keywords[0] != '\0') {
 		int	n;
 
-		/* carry forward the modseq index_append() assigned */
 		n = snprintf(line, sizeof(line), "%u:%s:%s:%llu", reply.uid,
-		    ap.basename, ap.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, ap.basename);
+			    session_id, ap->basename);
 			goto done_index;
 		}
 		free(idx.lines[idx.nlines - 1]);
@@ -1352,7 +1190,7 @@ handle_mbox_append_end(struct imsgev *iev)
 		}
 	}
 
-	if (index_save(ap.tfd, &idx) == -1)
+	if (index_save(ap->tfd, &idx) == -1)
 		goto done_index;
 
 	reply.uidvalidity = idx.uidvalidity;
@@ -1360,29 +1198,27 @@ handle_mbox_append_end(struct imsgev *iev)
 	index_lock_release(&il);
 	index_free(&idx);
 
-	/* 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,
+	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,
-		    ap.basename);
+		    ap->basename);
 		goto done;
 	}
-	if (renameat(ap.tfd, ap.tmppath, ap.tfd, curpath) == -1) {
-		log_warn("session %u: rename %s -> %s", session_id, ap.tmppath,
+	if (renameat(ap->tfd, ap->tmppath, ap->tfd, curpath) == -1) {
+		log_warn("session %u: rename %s -> %s", session_id, ap->tmppath,
 		    curpath);
 		goto done;
 	}
-	ap.tmppath[0] = '\0';	/* now in cur/, append_abort() must keep it */
+	ap->tmppath[0] = '\0';	/* now in cur/, append_abort() must keep it */
 	reply.error = MBOX_OP_OK;
 	goto done;
 
 done_index:
-	/* both idempotent */
 	index_lock_release(&il);
 	index_free(&idx);
 done:
-	append_abort();
+	append_abort(ss);
 	append_reply(iev, &reply);
 	return (0);
 }
blob - 1cc1977f0e44ec4adf3ef66db1f58f3b6faed340
blob + 77c3a0fc50453fb1d0601bbdf426d29413f81417
--- src/mbox_search.c
+++ src/mbox_search.c
@@ -16,8 +16,6 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* SEARCH key evaluation. */
-
 #include <sys/types.h>
 #include <sys/file.h>
 #include <sys/stat.h>
@@ -38,7 +36,6 @@
 #include "log.h"
 #include "store_internal.h"
 
-/* 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)
 {
@@ -62,19 +59,9 @@ 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.
- */
 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.
-	 */
 	if (!*firstp && strlcat(out, ",", outsize) >= outsize) {
 		log_warnx("session %u: merge_keywords: keyword set does not "
 		    "fit in %zu bytes", session_id, outsize);
@@ -89,12 +76,7 @@ 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.
- */
+/* RFC 9051 SS6.4.6: SET ignores the old keywords */
 int
 merge_keywords(int mode, const char *old_kws, const char *new_kws,
     char *out, size_t outsize)
@@ -139,10 +121,6 @@ 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.
-	 */
 	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); "
@@ -159,22 +137,26 @@ merge_keywords(int mode, const char *old_kws, const ch
 	return (0);
 }
 
-/* Per-message context for search_eval()/search_eval_leaf() in this file. */
+/* A content key's value before the parser-worker has answered */
+#define SEARCH_UNKNOWN		2
+
+/* Parser requests a SEARCH makes before it lets other sessions run */
+#define SEARCH_YIELD_REQUESTS	64
+
+static void	 search_walk_step(struct store_session *);
+static void	 search_walk_finish(struct store_session *, int);
+static void	 search_walk_yield(int, short, void *);
+
 struct search_msg_ctx {
 	uint32_t	seqno;
 	uint32_t	uid;
 	uint32_t	sysflags;
-	/* comma-separated, as elsewhere in file */
 	const char	*keywords;
 	int64_t		internaldate;
-	uint64_t	size;		/* 64-bit for SEARCH LARGER/SMALLER */
+	uint64_t	size;
 	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.
- */
 static int
 search_eval_leaf(const struct search_node *n, const struct search_msg_ctx *m)
 {
@@ -227,13 +209,24 @@ search_eval_leaf(const struct search_node *n, const st
 	}
 }
 
-/* Evaluates nodes[0..nnodes), postfix from listener.c's parse_search_key(). */
+/* 1 for the RFC 9051 SS6.4.4 keys the parser-worker answers */
+int
+search_op_content(int op)
+{
+	return (op == SEARCH_OP_SUBJECT || op == SEARCH_OP_HEADER ||
+	    op == SEARCH_OP_SENTBEFORE || op == SEARCH_OP_SENTON ||
+	    op == SEARCH_OP_SENTSINCE || op == SEARCH_OP_FROM ||
+	    op == SEARCH_OP_TO || op == SEARCH_OP_CC || op == SEARCH_OP_BCC);
+}
+
 static int
 search_eval(const struct search_node *nodes, uint32_t nnodes,
-    const struct search_msg_ctx *m)
+    const struct search_msg_ctx *m, const uint32_t *leafidx,
+    const char *bits)
 {
 	int		stack[SEARCH_PROGRAM_MAX_NODES];
 	uint32_t	sp = 0, i;
+	int		a, b;
 
 	for (i = 0; i < nnodes; i++) {
 		const struct search_node *n = &nodes[i];
@@ -242,24 +235,34 @@ search_eval(const struct search_node *nodes, uint32_t 
 		case SEARCH_OP_AND:
 			if (sp < 2)
 				return (0);
-			sp--;
-			stack[sp - 1] = stack[sp - 1] && stack[sp];
+			b = stack[--sp];
+			a = stack[sp - 1];
+			stack[sp - 1] = (a == 0 || b == 0) ? 0 :
+			    (a == 1 && b == 1) ? 1 : SEARCH_UNKNOWN;
 			break;
 		case SEARCH_OP_OR:
 			if (sp < 2)
 				return (0);
-			sp--;
-			stack[sp - 1] = stack[sp - 1] || stack[sp];
+			b = stack[--sp];
+			a = stack[sp - 1];
+			stack[sp - 1] = (a == 1 || b == 1) ? 1 :
+			    (a == 0 && b == 0) ? 0 : SEARCH_UNKNOWN;
 			break;
 		case SEARCH_OP_NOT:
 			if (sp < 1)
 				return (0);
-			stack[sp - 1] = !stack[sp - 1];
+			if (stack[sp - 1] != SEARCH_UNKNOWN)
+				stack[sp - 1] = !stack[sp - 1];
 			break;
 		default:
 			if (sp >= SEARCH_PROGRAM_MAX_NODES)
 				return (0);
-			stack[sp++] = search_eval_leaf(n, m);
+			if (!search_op_content(n->op))
+				stack[sp++] = search_eval_leaf(n, m);
+			else if (bits == NULL)
+				stack[sp++] = SEARCH_UNKNOWN;
+			else
+				stack[sp++] = bits[leafidx[i]];
 			break;
 		}
 	}
@@ -267,64 +270,70 @@ 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. 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)
+    uint32_t nnodes, struct store_session *ss)
 {
-	struct mbox_index	 idx;
-	struct imsg_mbox_result	 result;
-	struct index_lock	 il = INDEX_LOCK_INIT;
-	int			 ok = 1, locked;
-	uint32_t		 sent = 0;
-	uint32_t		 max_uid = 0;
-	uint32_t		 i;
+	struct store_search_walk	*sw = &ss->search;
+	struct index_lock		 il = INDEX_LOCK_INIT;
+	struct imsg_parser_leaf		 leaf;
+	uint32_t			 max_uid, i;
+	int				 locked;
 
-	/* nnodes, passed separately, is currently its only field */
-	(void)req;
-
-	memset(&idx, 0, sizeof(idx));
-
-	/* Nodes are only rewritten after the load; busy leaves them intact. */
-	locked = index_lock_acquire(mailbox_dir_fd, &il, LOCK_SH | LOCK_NB);
+	/* Before the reset below, so that a busy return changes nothing. */
+	locked = index_lock_acquire(ss->mailbox_dir_fd, &il, LOCK_SH | LOCK_NB);
 	if (locked == 1)
 		return (1);
+
+	if (sw->active) {
+		/* unreachable while the listener holds the next command */
+		log_warnx("session %u: SEARCH arrived during a SEARCH, "
+		    "abandoning the earlier walk", session_id);
+		search_walk_abort(ss);
+	}
+	memset(sw, 0, sizeof(*sw));
+	evtimer_set(&sw->yield_ev, search_walk_yield, ss);
+
 	if (locked == -1) {
-		ok = 0;
-		goto done;
+		search_walk_finish(ss, 0);
+		return (0);
 	}
-	if (index_load(il.fd, &idx) == -1) {
+	if (index_load(il.fd, &sw->idx) == -1) {
 		index_lock_release(&il);
-		ok = 0;
-		goto done;
+		search_walk_finish(ss, 0);
+		return (0);
 	}
 	index_lock_release(&il);
 
-	/* shared with UID FETCH/STORE/EXPUNGE's own "*" resolution */
-	max_uid = index_max_uid(&idx);
+	if (nnodes > 0)
+		memcpy(sw->nodes, nodes, nnodes * sizeof(*nodes));
+	sw->nnodes = nnodes;
 
-	/*
-	 * 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.
-	 */
+	max_uid = index_max_uid(&sw->idx);
+
 	for (i = 0; i < nnodes; i++) {
-		struct search_node	*n = &nodes[i];
+		struct search_node	*n = &sw->nodes[i];
 		struct seq_range	 in, out;
 		uint32_t		 max;
 
+		if (search_op_content(n->op)) {
+			memset(&leaf, 0, sizeof(leaf));
+			leaf.op = n->op;
+			leaf.str_off = n->str_off;
+			leaf.str_len = n->str_len;
+			leaf.name_off = n->name_off;
+			leaf.name_len = n->name_len;
+			leaf.num = n->num;
+			memcpy(sw->extra + sw->nleaves * sizeof(leaf), &leaf,
+			    sizeof(leaf));
+			sw->leafidx[i] = sw->nleaves++;
+			continue;
+		}
 		if (n->op != SEARCH_OP_SEQSET && n->op != SEARCH_OP_UIDSET)
 			continue;
 
-		max = (n->op == SEARCH_OP_SEQSET) ? (uint32_t)idx.nlines :
-		    max_uid;
+		max = (n->op == SEARCH_OP_SEQSET) ?
+		    (uint32_t)sw->idx.nlines : max_uid;
 		in.lo = n->seq_lo;
 		in.hi = n->seq_hi;
 		in.lo_is_star = n->lo_is_star;
@@ -336,15 +345,78 @@ handle_mbox_search(struct imsg_mbox_search *req, struc
 		n->seq_hi = out.hi;
 		n->lo_is_star = n->hi_is_star = 0;
 	}
+	/* store.c has bounded poollen by the pool */
+	sw->poollen = req->poollen;
+	sw->extralen = sw->nleaves * sizeof(leaf);
+	memcpy(sw->extra + sw->extralen, req->pool, req->poollen);
+	sw->extralen += req->poollen;
 
-	for (i = 1; i <= (uint32_t)idx.nlines; i++) {
+	sw->next = 1;
+	sw->active = 1;
+	search_walk_step(ss);
+	return (0);
+}
+
+/* answers 0 or 1, or -1 if it could not be checked */
+static int
+search_walk_ask(struct store_session *ss, const struct search_msg_ctx *m,
+    const char *basename)
+{
+	struct store_search_walk	*sw = &ss->search;
+	struct imsg_parser_req		 preq;
+	struct imsg_parser_rep		 prep;
+	char				*bits = NULL;
+	int				 mfd, ready, rc;
+
+	if ((ready = parser_ready()) == 0) {
+		sw->wait_parser = 1;
+		return (-2);
+	}
+	if (ready == -1) {
+		log_warnx("session %u: SEARCH needs the parser-worker and "
+		    "none can be had", session_id);
+		return (-1);
+	}
+	if ((mfd = open_message_file(&ss->cur_snap, ss->mailbox_dir_fd,
+	    basename)) == -1) {
+		log_warnx("session %u: message %s cannot be opened, SEARCH "
+		    "cannot check it", session_id, basename);
+		return (-1);
+	}
+	memset(&preq, 0, sizeof(preq));
+	preq.nleaves = sw->nleaves;
+	preq.poollen = sw->poollen;
+	rc = parser_request(IMSG_PARSER_SEARCH, "SEARCH", mfd, basename,
+	    &preq, sw->extra, sw->extralen, sw->nleaves, &prep, &bits);
+	close(mfd);
+	sw->asked++;
+	if (rc == -1) {
+		log_warnx("session %u: message %s could not be checked, "
+		    "SEARCH fails", session_id, basename);
+		return (-1);
+	}
+	rc = search_eval(sw->nodes, sw->nnodes, m, sw->leafidx, bits);
+	free(bits);
+	return (rc == 1);
+}
+
+static void
+search_walk_step(struct store_session *ss)
+{
+	struct store_search_walk	*sw = &ss->search;
+	struct imsgev			*iev = &ss->iev;
+	struct timeval			 tv;
+	uint32_t			 i;
+
+	for (i = sw->next; i <= (uint32_t)sw->idx.nlines; i++) {
 		struct search_msg_ctx	 m;
 		struct index_rec	 rec;
 		const char		*letters;
 		off_t			 size = 0;
 		char			 suffix[64];
+		int			 r;
 
-		if (index_parse_line(idx.lines[i - 1], &rec) == -1)
+		if (index_parse_line(sw->idx.lines[i - 1], &rec) == -1)
 			continue;
 
 		memset(&m, 0, sizeof(m));
@@ -352,8 +424,8 @@ handle_mbox_search(struct imsg_mbox_search *req, struc
 		m.uid = rec.uid;
 		m.modseq = rec.modseq;
 
-		if (locate_message_file(mailbox_dir_fd, rec.basename,
-		    &size, suffix, sizeof(suffix)) == -1) {
+		if (locate_message_file(&ss->cur_snap, ss->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);
@@ -367,7 +439,28 @@ handle_mbox_search(struct imsg_mbox_search *req, struc
 		m.internaldate = parse_maildir_timestamp(rec.basename);
 		m.size = (uint64_t)size;
 
-		if (search_eval(nodes, nnodes, &m)) {
+		r = search_eval(sw->nodes, sw->nnodes, &m, sw->leafidx, NULL);
+		if (r == SEARCH_UNKNOWN) {
+			if (sw->asked >= SEARCH_YIELD_REQUESTS) {
+				sw->asked = 0;
+				sw->next = i;
+				tv.tv_sec = 0;
+				tv.tv_usec = 0;
+				evtimer_add(&sw->yield_ev, &tv);
+				return;
+			}
+			r = search_walk_ask(ss, &m, rec.basename);
+			if (r == -2) {
+				sw->next = i;
+				return;
+			}
+			if (r == -1) {
+				search_walk_finish(ss, 0);
+				return;
+			}
+		}
+
+		if (r == 1) {
 			struct imsg_mbox_search_match	 match;
 
 			memset(&match, 0, sizeof(match));
@@ -379,20 +472,75 @@ handle_mbox_search(struct imsg_mbox_search *req, struc
 				log_warn("session %u: imsg_compose "
 				    "IMSG_MBOX_SEARCH_MATCH", session_id);
 			else
-				sent++;
+				sw->sent++;
 		}
 	}
+	search_walk_finish(ss, 1);
+}
 
-done:
-	index_free(&idx);
+static void
+search_walk_finish(struct store_session *ss, int ok)
+{
+	struct store_search_walk	*sw = &ss->search;
+	struct imsg_mbox_result		 result;
 
+	index_free(&sw->idx);
+	cur_snapshot_discard(&ss->cur_snap);
+
 	memset(&result, 0, sizeof(result));
 	result.error = ok ? MBOX_OP_OK : MBOX_OP_ERR_GENERIC;
-	result.count = sent;
-	if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
+	result.count = sw->sent;
+	if (imsg_compose(&ss->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);
+	sw->active = 0;
 }
 
+static void
+search_walk_yield(int fd, short event, void *arg)
+{
+	struct store_session	*ss = arg;
+
+	(void)fd;
+	(void)event;
+	session_id = ss->id;
+	if (ss->search.active)
+		search_walk_step(ss);
+}
+
+int
+search_walk_paused(struct store_session *ss)
+{
+	return (ss->search.active);
+}
+
+void
+search_walk_abort(struct store_session *ss)
+{
+	struct store_search_walk	*sw = &ss->search;
+
+	if (!sw->active)
+		return;
+	evtimer_del(&sw->yield_ev);
+	index_free(&sw->idx);
+	sw->active = 0;
+}
+
+void
+search_walk_parser(struct store_session *ss, int ok)
+{
+	struct store_search_walk	*sw = &ss->search;
+
+	if (!sw->active || !sw->wait_parser)
+		return;
+	sw->wait_parser = 0;
+	if (!ok) {
+		log_warnx("session %u: no parser-worker, SEARCH fails",
+		    session_id);
+		search_walk_finish(ss, 0);
+		return;
+	}
+	search_walk_step(ss);
+}
+
blob - 60e14a41865a3ce1ab8609c51f88be1aaff823e0
blob + 281b003d5dc7a3ec06075dc3f18e345f7cf3e01a
--- src/mbox_store.c
+++ src/mbox_store.c
@@ -16,8 +16,6 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* mbox_store.c, STORE and EXPUNGE request handling. */
-
 #include <sys/types.h>
 #include <sys/file.h>
 #include <sys/stat.h>
@@ -38,31 +36,21 @@
 #include "log.h"
 #include "store_internal.h"
 
-/*
- * 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		 changed;
+	int		 rename;
 	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)
+store_plan(struct store_session *ss, 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;
@@ -70,8 +58,8 @@ store_plan(const struct imsg_mbox_store *req, const st
 	uint32_t	 old_sysflags, new_sysflags;
 	int		 len;
 
-	if (locate_message_file(mailbox_dir_fd, rec->basename, &size, suffix,
-	    sizeof(suffix)) == -1) {
+	if (locate_message_file(&ss->cur_snap, ss->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);
@@ -94,10 +82,7 @@ store_plan(const struct imsg_mbox_store *req, const st
 		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).
-	 */
+	/* RFC 9051 SS6.4.6: refuse rather than store a truncation */
 	if (merge_keywords(req->mode, rec->keywords, req->keywords,
 	    newkeywords, sizeof(newkeywords)) == -1) {
 		log_warnx("session %u: STORE: merged keyword set for uid %u "
@@ -125,7 +110,6 @@ store_plan(const struct imsg_mbox_store *req, const st
 	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)
@@ -141,14 +125,9 @@ store_paths(const char *basename, const struct store_s
 	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)
+store_undo(struct store_session *ss, const struct mbox_index *idx,
+    const struct store_step *steps, size_t nsteps)
 {
 	struct index_rec	 rec;
 	char			 oldpath[600], newpath[600];
@@ -163,7 +142,7 @@ store_undo(const struct mbox_index *idx, const struct 
 		    store_paths(rec.basename, st, oldpath, sizeof(oldpath),
 		    newpath, sizeof(newpath)) == -1)
 			continue;
-		if (renameat(mailbox_dir_fd, newpath, mailbox_dir_fd,
+		if (renameat(ss->mailbox_dir_fd, newpath, ss->mailbox_dir_fd,
 		    oldpath) == -1)
 			log_warn("session %u: STORE rollback: rename %s -> %s, "
 			    "left with the new flags", session_id, newpath,
@@ -171,21 +150,12 @@ store_undo(const struct mbox_index *idx, const struct 
 	}
 }
 
-/*
- * 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.
- */
+/* RFC 9051 SS6.4.6 STORE: plan every message, then change */
 int
 handle_mbox_store(struct imsg_mbox_store *req, const struct seq_range *ranges,
-    uint32_t nranges, struct imsgev *iev)
+    uint32_t nranges, struct store_session *ss)
 {
+	struct imsgev		*iev = &ss->iev;
 	int			 locked;
 	struct mbox_index	 idx;
 	struct imsg_mbox_result	 result;
@@ -205,11 +175,8 @@ handle_mbox_store(struct imsg_mbox_store *req, const s
 		ok = 0;
 		goto done;
 	}
-	/*
-	 * 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);
+	/* nothing touched yet: a busy return is free */
+	locked = index_lock_acquire(ss->mailbox_dir_fd, &il, LOCK_EX | LOCK_NB);
 	if (locked == 1)
 		return (1);
 	if (locked == -1) {
@@ -224,7 +191,7 @@ handle_mbox_store(struct imsg_mbox_store *req, const s
 	reported = idx.highestmodseq;
 	new_modseq = idx.highestmodseq + 1;
 
-	/* RFC 9051 SS6.4.9: UID STORE's set is UIDs, as handle_mbox_fetch() */
+	/* RFC 9051 SS6.4.9 */
 	nresolved = seqset_resolve(ranges, nranges, req->by_uid ?
 	    index_max_uid(&idx) : (uint32_t)idx.nlines, !req->by_uid,
 	    resolved);
@@ -256,7 +223,7 @@ handle_mbox_store(struct imsg_mbox_store *req, const s
 				st.modified = 1;
 				st.modseq = rec.modseq;
 			} else {
-				rc = store_plan(req, &rec, new_modseq, &st,
+				rc = store_plan(ss, req, &rec, new_modseq, &st,
 				    newline, sizeof(newline));
 				if (rc == 0)
 					continue;
@@ -294,8 +261,8 @@ handle_mbox_store(struct imsg_mbox_store *req, const s
 				ok = 0;
 				break;
 			}
-			if (st.rename && renameat(mailbox_dir_fd, oldpath,
-			    mailbox_dir_fd, newpath) == -1) {
+			if (st.rename && renameat(ss->mailbox_dir_fd, oldpath,
+			    ss->mailbox_dir_fd, newpath) == -1) {
 				log_warn("session %u: rename %s -> %s",
 				    session_id, oldpath, newpath);
 				free(dup);
@@ -312,11 +279,11 @@ handle_mbox_store(struct imsg_mbox_store *req, const s
 
 	if (ok && changed) {
 		idx.highestmodseq = new_modseq;
-		if (index_save(mailbox_dir_fd, &idx) == -1)
+		if (index_save(ss->mailbox_dir_fd, &idx) == -1)
 			ok = 0;
 	}
 	if (!ok) {
-		store_undo(&idx, steps, nsteps);
+		store_undo(ss, &idx, steps, nsteps);
 		goto done;
 	}
 	reported = idx.highestmodseq;
@@ -379,28 +346,17 @@ done:
 	return (0);
 }
 
-/*
- * 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.
- */
+/* the compacted index is saved before any file is removed */
 int
 handle_mbox_expunge(struct imsg_mbox_expunge *req,
-    const struct seq_range *ranges, uint32_t nranges, struct imsgev *iev)
+    const struct seq_range *ranges, uint32_t nranges, struct store_session *ss)
 {
 	struct expunged {
 		char		*line;
 		char		 suffix[64];
 		uint32_t	 seqno;
 	};
+	struct imsgev		*iev = &ss->iev;
 	struct mbox_index	 idx;
 	struct imsg_mbox_result	 result;
 	struct index_lock	 il = INDEX_LOCK_INIT;
@@ -414,7 +370,7 @@ handle_mbox_expunge(struct imsg_mbox_expunge *req,
 	memset(&idx, 0, sizeof(idx));
 
 	/* Nothing above has touched the mailbox, so a busy return is free. */
-	locked = index_lock_acquire(mailbox_dir_fd, &il, LOCK_EX | LOCK_NB);
+	locked = index_lock_acquire(ss->mailbox_dir_fd, &il, LOCK_EX | LOCK_NB);
 	if (locked == 1)
 		return (1);
 	if (locked == -1) {
@@ -427,11 +383,6 @@ handle_mbox_expunge(struct imsg_mbox_expunge *req,
 	}
 	reported = idx.highestmodseq;
 
-	/*
-	 * 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);
@@ -448,8 +399,8 @@ handle_mbox_expunge(struct imsg_mbox_expunge *req,
 			idx.lines[out++] = idx.lines[in];
 			continue;
 		}
-		if (locate_message_file(mailbox_dir_fd, rec.basename,
-		    &size, suffix, sizeof(suffix)) == -1) {
+		if (locate_message_file(&ss->cur_snap, ss->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);
@@ -462,7 +413,7 @@ handle_mbox_expunge(struct imsg_mbox_expunge *req,
 			idx.lines[out++] = idx.lines[in];
 			continue;
 		}
-		/* SS6.4.9: \Deleted outside UID EXPUNGE's ranges is kept */
+		/* RFC 9051 SS6.4.9: \Deleted outside the ranges is kept */
 		if (req->by_uid &&
 		    !seqset_contains(resolved, nresolved, rec.uid)) {
 			idx.lines[out++] = idx.lines[in];
@@ -494,7 +445,7 @@ handle_mbox_expunge(struct imsg_mbox_expunge *req,
 
 	if (ok && ngone > 0) {
 		idx.highestmodseq++;
-		if (index_save(mailbox_dir_fd, &idx) == -1)
+		if (index_save(ss->mailbox_dir_fd, &idx) == -1)
 			ok = 0;
 	}
 	if (!ok) {
@@ -514,7 +465,7 @@ handle_mbox_expunge(struct imsg_mbox_expunge *req,
 		    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 &&
+		else if (unlinkat(ss->mailbox_dir_fd, path, 0) == -1 &&
 		    errno != ENOENT)
 			log_warn("session %u: unlink %s, left on disk with no "
 			    "index entry", session_id, path);
@@ -537,7 +488,7 @@ handle_mbox_expunge(struct imsg_mbox_expunge *req,
 done:
 	/* RFC 9051 SS6.4.1: CLOSE deselects only once its expunge is saved. */
 	if (req->silent && ok)
-		mailbox_selected = 0;
+		ss->mailbox_selected = 0;
 
 	index_lock_release(&il);
 
blob - 1816ffbc3688578bbecb0cdbe54a822939add354
blob + f0673170737641f25ea55077ef7a880eec3800ad
--- src/mboxname.c
+++ src/mboxname.c
@@ -22,28 +22,13 @@
 #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 is case-insensitive */
 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.
- */
 int
 mailbox_name_syntax_ok(const char *name)
 {
@@ -53,10 +38,6 @@ 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).
-	 */
 	if (!utf8_mailbox_ok(name))
 		return (0);
 
@@ -66,10 +47,7 @@ mailbox_name_syntax_ok(const char *name)
 		/* RFC 9051 SS5.1.1: "/" is the hierarchy delimiter */
 		if (c == '/')
 			return (0);
-		/*
-		 * SS5.1 pt 2: MAY refuse CTL/non-graphic names; taking that MAY
-		 * for C0/DEL
-		 */
+		/* RFC 9051 SS5.1 lets a server refuse CTL names */
 		if (c < 0x20 || c == 0x7f)
 			return (0);
 	}
blob - 307e23dad0ae85a3d8ded4a0742f9850fa89960c
blob + 66731709f1033ef153c187bd122bfb804719994d
--- src/mboxname.h
+++ src/mboxname.h
@@ -19,17 +19,14 @@
 #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(). 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. */
-
+/*
+ * Reserved names, refused as mailbox names. Locks are taken on the .lock
+ * files, since index_save() rename(2)s the index and flock(2) binds an
+ * inode; the uidvalidity file is rewritten in place and is its own lock.
+ * An index lock may be held while taking the uidvalidity lock, never the
+ * reverse. The subscriptions lock is taken alone, and an absent
+ * subscriptions file means every mailbox is subscribed.
+ */
 #define STORE_INDEX_NAME		"imapd.index"
 #define STORE_INDEX_TMP_NAME		"imapd.index.tmp"
 #define STORE_INDEX_LOCK_NAME		"imapd.index.lock"
@@ -38,22 +35,12 @@
 #define STORE_SUBSCRIPTIONS_TMP_NAME	"imapd.subscriptions.tmp"
 #define STORE_SUBSCRIPTIONS_LOCK_NAME	"imapd.subscriptions.lock"
 
+/* the one mailbox-name rule; the listener and the store must agree */
 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
- * each used to carry an identical one-liner under a different name.
- */
+/* RFC 9051 SS5.1: INBOX is case-insensitive and always exists */
 int	 mailbox_name_is_inbox(const char *);
 
 #endif /* IMAPD_MBOXNAME_H */
blob - a3167586e86a8c6202f5d55df4ea81a7e7614998
blob + fa845ff835f6518c9e1430ea20524343597c0f2b
--- src/mime.c
+++ src/mime.c
@@ -16,11 +16,6 @@
  * 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.
- */
-
 #include <sys/types.h>
 #include <sys/file.h>
 #include <sys/stat.h>
@@ -41,25 +36,6 @@
 #include "log.h"
 #include "store_internal.h"
 
-/*
- * 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
 cur_snap_cmp(const void *a, const void *b)
 {
@@ -67,22 +43,21 @@ cur_snap_cmp(const void *a, const void *b)
 }
 
 void
-cur_snapshot_discard(void)
+cur_snapshot_discard(struct cur_snapshot *snap)
 {
 	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;
+	for (i = 0; i < snap->n; i++)
+		free(snap->names[i]);
+	free(snap->names);
+	snap->names = NULL;
+	snap->n = 0;
+	snap->dfd = -1;
+	snap->failed = 0;
 }
 
-/* Reads cur/ into the snapshot; -1 having left it empty and not retryable. */
 static int
-cur_snapshot_build(int dfd)
+cur_snapshot_build(struct cur_snapshot *snap, int dfd)
 {
 	DIR			*dp = NULL;
 	const struct dirent	*de;
@@ -90,13 +65,13 @@ cur_snapshot_build(int dfd)
 	size_t			  n = 0, cap = 0;
 	int			  curfd;
 
-	cur_snapshot_discard();
+	cur_snapshot_discard(snap);
 
 	curfd = openat(dfd, "cur", O_RDONLY | O_DIRECTORY);
 	if (curfd != -1 && (dp = fdopendir(curfd)) == NULL)
 		close(curfd);
 	if (dp == NULL) {
-		cur_snap.failed = 1;
+		snap->failed = 1;
 		return (-1);
 	}
 	while ((de = readdir(dp)) != NULL) {
@@ -117,9 +92,9 @@ cur_snapshot_build(int dfd)
 	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;
+	snap->names = names;
+	snap->n = n;
+	snap->dfd = dfd;
 	return (0);
 
 fail:
@@ -128,42 +103,37 @@ fail:
 		free(names[--n]);
 	free(names);
 	closedir(dp);
-	cur_snap.failed = 1;
+	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)
+cur_snapshot_find(struct cur_snapshot *snap, int dfd, const char *basename,
+    size_t baselen)
 {
 	size_t	lo = 0, hi, mid;
 
-	if (cur_snap.dfd != dfd) {
-		if (cur_snap.failed)
+	if (snap->dfd != dfd) {
+		if (snap->failed)
 			return (NULL);
-		if (cur_snapshot_build(dfd) == -1)
+		if (cur_snapshot_build(snap, dfd) == -1)
 			return (NULL);
 	}
-	hi = cur_snap.n;
+	hi = snap->n;
 	while (lo < hi) {
 		mid = lo + (hi - lo) / 2;
-		if (strncmp(cur_snap.names[mid], basename, baselen) < 0)
+		if (strncmp(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]);
+	if (lo < snap->n &&
+	    strncmp(snap->names[lo], basename, baselen) == 0 &&
+	    snap->names[lo][baselen] == ':')
+		return (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)
@@ -179,15 +149,9 @@ cur_entry_take(const char *name, size_t baselen, char 
 	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,
+scan_cur_for_basename(struct cur_snapshot *snap, int dfd,
+    const char *basename, char *path, size_t pathsize,
     char *suffix_out, size_t suffix_out_size)
 {
 	DIR			*dp;
@@ -196,7 +160,7 @@ scan_cur_for_basename(int dfd, const char *basename, c
 	size_t			 baselen = strlen(basename);
 	int			 rv = -1;
 
-	if ((hit = cur_snapshot_find(dfd, basename, baselen)) != NULL)
+	if ((hit = cur_snapshot_find(snap, dfd, basename, baselen)) != NULL)
 		return (cur_entry_take(hit, baselen, path, pathsize,
 		    suffix_out, suffix_out_size));
 
@@ -227,8 +191,8 @@ scan_cur_for_basename(int dfd, const char *basename, c
 }
 
 int
-locate_message_file(int dfd, const char *basename, off_t *size_out,
-    char *suffix_out, size_t suffix_out_size)
+locate_message_file(struct cur_snapshot *snap, int dfd, const char *basename,
+    off_t *size_out, char *suffix_out, size_t suffix_out_size)
 {
 	struct stat	 st;
 	char		 path[600];
@@ -250,7 +214,7 @@ locate_message_file(int dfd, const char *basename, off
 		return (-1);
 	}
 
-	if (scan_cur_for_basename(dfd, basename, path, sizeof(path),
+	if (scan_cur_for_basename(snap, dfd, basename, path, sizeof(path),
 	    suffix_out, suffix_out_size) == -1)
 		return (-1);
 	if (fstatat(dfd, path, &st, 0) == -1)
@@ -259,12 +223,8 @@ locate_message_file(int dfd, const char *basename, off
 	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.
- */
 int
-open_message_file(int dfd, const char *basename)
+open_message_file(struct cur_snapshot *snap, int dfd, const char *basename)
 {
 	char	 path[600];
 	int	 fd;
@@ -282,29 +242,35 @@ open_message_file(int dfd, const char *basename)
 		return (-1);
 	}
 
-	if (scan_cur_for_basename(dfd, basename, path, sizeof(path), NULL,
-	    0) == -1)
+	if (scan_cur_for_basename(snap, dfd, basename, path, sizeof(path),
+	    NULL, 0) == -1)
 		return (-1);
 	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.
- */
 int
-read_message_header(int dfd, const char *basename, char **buf_out,
+read_message_header(struct cur_snapshot *snap, int dfd, const char *basename,
+    char **buf_out, uint32_t *len_out)
+{
+	int	 fd, rc;
+
+	if ((fd = open_message_file(snap, dfd, basename)) == -1)
+		return (-1);
+	rc = read_header_from_fd(fd, basename, buf_out, len_out);
+	close(fd);
+	return (rc);
+}
+
+/* for the parser-worker, whose pledge(2) lacks "rpath" */
+int
+read_header_from_fd(int fd, const char *basename, char **buf_out,
     uint32_t *len_out)
 {
 	char	 readbuf[FETCH_HEADER_MAX + 1];
-	int	 fd;
 	ssize_t	 n, total = 0;
 	size_t	 i, hdrend = 0, limit, sepindex = 0;
 	int	 found_sep = 0;
 
-	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) {
@@ -313,26 +279,18 @@ read_message_header(int dfd, const char *basename, cha
 				continue;
 			log_warn("session %u: read message header (%s)",
 			    session_id, basename);
-			close(fd);
 			return (-1);
 		}
 		if (n == 0)
 			break;
 		total += n;
 	}
-	close(fd);
 
 	if (find_header_body_split(readbuf, (size_t)total, &hdrend) == 0) {
 		sepindex = hdrend;
 		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.
-	 */
 	limit = found_sep ? sepindex : (size_t)total;
 	for (i = 0; i < limit && i + 1 < (size_t)total; i++) {
 		if (readbuf[i] == '\0') {
@@ -360,42 +318,21 @@ read_message_header(int dfd, const char *basename, cha
 	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.
- */
 int
-read_message_body(int dfd, const char *basename, int text_only,
+read_body_from_fd(int fd, 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.
-	 */
 	size_t		 i, hdrend, sepindex = 0;
 
 	*buf_out = NULL;
 	*len_out = 0;
 
-	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.
-	 */
+	/* sized to the message, not the configured ceiling */
 	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)
@@ -404,7 +341,6 @@ read_message_body(int dfd, const char *basename, int t
 	if ((readbuf = malloc(readbuf_size)) == NULL) {
 		log_warn("session %u: malloc message body readbuf (%s)",
 		    session_id, basename);
-		close(fd);
 		return (-1);
 	}
 
@@ -416,7 +352,6 @@ read_message_body(int dfd, const char *basename, int t
 				continue;
 			log_warn("session %u: read message body (%s)",
 			    session_id, basename);
-			close(fd);
 			free(readbuf);
 			return (-1);
 		}
@@ -424,7 +359,6 @@ read_message_body(int dfd, const char *basename, int t
 			break;
 		total += n;
 	}
-	close(fd);
 
 	if (total > (ssize_t)maxlen) {
 		log_warnx("session %u: message %s exceeds %zu bytes, "
@@ -470,10 +404,7 @@ read_message_body(int dfd, const char *basename, int t
 	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.
- */
+/* RFC 9051 SS6.4.5.1: ASCII case-insensitive */
 int
 header_field_name_matches(const char *name, size_t namelen, const char *list)
 {
@@ -499,12 +430,7 @@ 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 field plus its RFC 5322 SS2.2.3 obs-fold lines */
 struct hdr_field {
 	size_t	start;
 	size_t	colon;
@@ -512,14 +438,6 @@ 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.
- */
 static int
 hdr_next_field(const char *hdr, size_t hdrlen, size_t *off,
     struct hdr_field *f)
@@ -551,11 +469,6 @@ 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
-	 */
 	while (*off < hdrlen && (hdr[*off] == ' ' || hdr[*off] == '\t')) {
 		j = *off;
 		while (j < hdrlen && hdr[j] != '\n')
@@ -571,13 +484,46 @@ 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.
- */
+/* RFC 9051 SS6.4.5.1 HEADER.FIELDS[.NOT] */
 int
-read_message_header_fields(int dfd, const char *basename,
+filter_header_fields(const char *hdr, size_t hdrlen, const char *fields_spec,
+    int want_not, char *out, size_t *outlen_out)
+{
+	size_t	 outlen = 0;
+	size_t	 off = 0;
+
+	*outlen_out = 0;
+
+	for (;;) {
+		struct hdr_field	 f;
+		int			 matched, include, r;
+
+		r = hdr_next_field(hdr, hdrlen, &off, &f);
+		if (r == -1)
+			/* no terminating blank line */
+			return (-1);
+		if (r == 0) {
+			/* RFC 9051 SS6.4.5: the blank line is included */
+			memcpy(out + outlen, hdr + f.start, f.end - f.start);
+			outlen += f.end - f.start;
+			*outlen_out = outlen;
+			return (0);
+		}
+
+		matched = header_field_name_matches(hdr + f.start,
+		    f.colon - f.start, fields_spec);
+		include = want_not ? !matched : matched;
+
+		if (include) {
+			memcpy(out + outlen, hdr + f.start, f.end - f.start);
+			outlen += f.end - f.start;
+		}
+	}
+}
+
+/* for the parser-worker, whose pledge(2) lacks "rpath" */
+int
+read_message_header_fields(int fd, const char *basename,
     const char *fields_spec, int want_not, char **buf_out,
     uint32_t *len_out)
 {
@@ -585,19 +531,13 @@ read_message_header_fields(int dfd, const char *basena
 	uint32_t	 hdrlen = 0;
 	char		*out;
 	size_t		 outlen = 0;
-	size_t		 off = 0;
-	int		 rc = -1;
 
 	*buf_out = NULL;
 	*len_out = 0;
 
-	if (read_message_header(dfd, basename, &hdrbuf, &hdrlen) == -1)
+	if (read_header_from_fd(fd, basename, &hdrbuf, &hdrlen) == -1)
 		return (-1);
 
-	/*
-	 * 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);
@@ -605,54 +545,20 @@ read_message_header_fields(int dfd, const char *basena
 		return (-1);
 	}
 
-	for (;;) {
-		struct hdr_field	 f;
-		int			 matched, include, r;
-
-		r = hdr_next_field(hdrbuf, hdrlen, &off, &f);
-		if (r == -1)
-			/* 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
-			 */
-			memcpy(out + outlen, hdrbuf + f.start,
-			    f.end - f.start);
-			outlen += f.end - f.start;
-			rc = 0;
-			break;
-		}
-
-		matched = header_field_name_matches(hdrbuf + f.start,
-		    f.colon - f.start, fields_spec);
-		include = want_not ? !matched : matched;
-
-		if (include) {
-			memcpy(out + outlen, hdrbuf + f.start,
-			    f.end - f.start);
-			outlen += f.end - f.start;
-		}
-	}
-
-	free(hdrbuf);
-
-	if (rc == -1) {
+	if (filter_header_fields(hdrbuf, hdrlen, fields_spec, want_not, out,
+	    &outlen) == -1) {
+		free(hdrbuf);
 		free(out);
 		return (-1);
 	}
 
+	free(hdrbuf);
 	*buf_out = out;
 	*len_out = (uint32_t)outlen;
 	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.
- */
+/* RFC 5322 SS2.2.3 unfolding; NIL vs "" per RFC 9051 SS7.5.2 */
 int
 extract_header_field(const char *hdr, size_t hdrlen, const char *name,
     char **val_out, size_t *vallen_out)
@@ -669,11 +575,6 @@ 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
-		 */
 		if (hdr_next_field(hdr, hdrlen, &off, &f) != 1)
 			return (-1);
 
@@ -681,11 +582,6 @@ 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
-		 */
 		vstart = f.colon + 1;
 		vend = f.end;
 		if (vend > vstart && hdr[vend - 1] == '\n')
@@ -720,10 +616,45 @@ extract_header_field(const char *hdr, size_t hdrlen, c
 }
 
 int
+header_next_field(const char *hdr, size_t hdrlen, size_t *off,
+    const char **name, size_t *namelen, const char **val, size_t *vallen)
+{
+	struct hdr_field	 f;
+	size_t			 vstart;
+
+	for (;;) {
+		if (hdr_next_field(hdr, hdrlen, off, &f) != 1)
+			return (0);
+		/* a line with no colon is not a field (RFC 5322 SS2.2) */
+		if (f.colon == f.line_end)
+			continue;
+		vstart = f.colon + 1;
+		while (vstart < f.end &&
+		    (hdr[vstart] == ' ' || hdr[vstart] == '\t'))
+			vstart++;
+		*name = hdr + f.start;
+		*namelen = f.colon - f.start;
+		*val = hdr + vstart;
+		*vallen = f.end - vstart;
+		return (1);
+	}
+}
+
+int
 find_header_body_split(const char *buf, size_t len, size_t *hdrend_out)
 {
 	size_t	 i;
 
+	/* a part with no header fields starts with CRLF */
+	if (len >= 2 && buf[0] == '\r' && buf[1] == '\n') {
+		*hdrend_out = 2;
+		return (0);
+	}
+	if (len >= 1 && buf[0] == '\n') {
+		*hdrend_out = 1;
+		return (0);
+	}
+
 	for (i = 0; i + 1 < len; i++) {
 		if (i + 3 < len && buf[i] == '\r' && buf[i + 1] == '\n' &&
 		    buf[i + 2] == '\r' && buf[i + 3] == '\n') {
@@ -745,10 +676,7 @@ 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
- */
+/* RFC 2045 token or quoted-string */
 int
 mime_read_token_or_qstring(const char *s, size_t len, size_t *pos,
     char *out, size_t outsize)
@@ -767,13 +695,7 @@ 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.
-			 */
+			/* no CR, LF or NUL inside a quoted-string either */
 			if (c == '\0' || c == '\r' || c == '\n')
 				return (-1);
 			if (outlen + 1 >= outsize)
@@ -802,10 +724,6 @@ 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
- */
 void
 mime_str_upper(char *s)
 {
@@ -815,10 +733,7 @@ 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; 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,
@@ -914,10 +829,7 @@ 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
-			 */
+			/* RFC 2046 SS5.1.1: at most 70 characters */
 			if (strlcpy(boundary_out, value, boundary_outsize) <
 			    boundary_outsize)
 				*has_boundary_out = 1;
@@ -938,10 +850,7 @@ 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 */
 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)
@@ -991,10 +900,7 @@ split_multipart(const char *body, size_t bodylen, cons
 			after++;
 		if (after < bodylen && body[after] != '\r' &&
 		    body[after] != '\n') {
-			/*
-			 * not CRLF/LF/EOF: coincidental match, not a real
-			 * delimiter
-			 */
+			/* a coincidental match, not a delimiter */
 			pos++;
 			continue;
 		}
@@ -1003,11 +909,7 @@ 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
-			 */
+			/* an empty part must not underflow the length */
 			if (content_end >= part_start + 2 &&
 			    body[content_end - 2] == '\r' &&
 			    body[content_end - 1] == '\n')
@@ -1029,10 +931,6 @@ split_multipart(const char *body, size_t bodylen, cons
 		else if (after < bodylen && body[after] == '\n')
 			after += 1;
 		else
-			/*
-			 * delimiter runs to EOF with no CRLF, no body-part can
-			 * follow
-			 */
 			break;
 
 		if (n >= maxparts)
@@ -1050,10 +948,7 @@ 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)
- */
+/* RFC 9051 SS6.4.5 section-part */
 int
 parse_section_part(const char *s, int *path, int maxpath)
 {
@@ -1089,10 +984,6 @@ 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()
- */
 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,
@@ -1127,18 +1018,13 @@ find_mime_part(int depth, const char *hdr, size_t hdrl
 
 	pbuf = body + part_starts[want - 1];
 	plen = part_ends[want - 1] - part_starts[want - 1];
-	/* same empty-part handling as build_body_structure() */
 	if (plen == 0)
 		phdrend = 0;
 	else if (find_header_body_split(pbuf, plen, &phdrend) == -1)
 		return (-1);
 
 	if (pathlen == 1) {
-		/*
-		 * path consumed; must be a genuine leaf (not
-		 * MULTIPART/MESSAGE-RFC822|GLOBAL), no "combined children"
-		 * concept exists
-		 */
+		/* a leaf only: no "combined children" of a multipart */
 		char	 ctype[64], csub[64], cparams[600];
 		char	 cboundary[70 + 1];
 		int	 chb;
@@ -1163,10 +1049,7 @@ 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")
- */
+/* RFC 9051 SS6.4.5.1: a non-multipart message is its own 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,
@@ -1215,12 +1098,8 @@ partial_range(int has_partial, uint32_t start, uint32_
 		*len = count;
 }
 
-/*
- * 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(int dfd, const char *basename, const int *path,
+extract_mime_part(int fd, const char *basename, const int *path,
     int pathlen, uint64_t *off_out, uint64_t *len_out)
 {
 	char		*wholebuf = NULL;
@@ -1232,7 +1111,7 @@ extract_mime_part(int dfd, const char *basename, const
 	*off_out = 0;
 	*len_out = 0;
 
-	if (read_message_body(dfd, basename, 0, bodystructure_read_max,
+	if (read_body_from_fd(fd, basename, 0, bodystructure_read_max,
 	    "BODY[<part>]", &wholebuf, &wholelen) == -1)
 		return (-1);
 	if (wholelen == 0 ||
@@ -1241,16 +1120,9 @@ extract_mime_part(int dfd, const char *basename, const
 		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.
-	 */
+	/* RFC 9051 SS6.4.5: a missing part is an empty item */
 	if (locate_mime_part(wholebuf, hdrend, wholebuf + hdrend,
 	    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;
 	}
@@ -1258,15 +1130,9 @@ extract_mime_part(int dfd, const char *basename, const
 	return (0);
 }
 
-/*
- * 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)
+message_body_range(struct cur_snapshot *snap, 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;
@@ -1278,7 +1144,7 @@ message_body_range(int dfd, const char *basename, int 
 	*len_out = 0;
 	*fdbusy_out = 0;
 
-	if ((fd = open_message_file(dfd, basename)) == -1) {
+	if ((fd = open_message_file(snap, dfd, basename)) == -1) {
 		if (errno == EMFILE || errno == ENFILE)
 			*fdbusy_out = 1;
 		return (-1);
@@ -1328,10 +1194,7 @@ message_body_range(int dfd, const char *basename, int 
 	return (fd);
 }
 
-/*
- * builds the space-separated IMAP flag-atom list from maildir flag-suffix
- * letters + index keywords; letters per Courier's maildir(5)
- */
+/* flag letters as in Courier's maildir(5) */
 void
 build_flags_string(const char *maildir_suffix, const char *keywords,
     char *out, size_t outsize)
@@ -1405,10 +1268,7 @@ 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.3: from the basename, not mtime */
 int64_t
 parse_maildir_timestamp(const char *basename)
 {
blob - a1ad40f9dd931b15cc09fee8a43afe409432650c
blob + 419823c5e4586ed3d978ce3412b312479e50daa4
--- src/parent.c
+++ src/parent.c
@@ -30,15 +30,6 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/*
- * 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>
 #include <sys/resource.h>
@@ -68,17 +59,12 @@
 /* l2tpd and pptpd both wait 5s for the same job */
 #define SHUTDOWN_TIMEOUT_SEC	5
 
-/* 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.
- */
+/* backstop for "connections max", whose range it bounds */
 #define OPEN_SESSION_MAX	4096
 
+/* how long accept(2) waits when out of descriptors, as in httpd */
+#define ACCEPT_PAUSE_SEC	1
+
 struct child {
 	pid_t				 pid;
 	enum openimap_proc_type	 type;
@@ -86,37 +72,31 @@ struct child {
 	TAILQ_ENTRY(child)		 entry;
 };
 
-/*
- * per-session store child; "pending" until IMSG_SETUP_DONE arrives or times
- * out; one struct, never copied (embeds struct event)
- */
+/* one store child per account, shared by its sessions */
 struct store_child {
 	uint32_t			 session_id;
+	uid_t				 uid;
+	gid_t				 gid;
+	char				 maildir[STORE_MAILDIR_MAX];
+	unsigned int			 nsessions;
+	int				 exiting;
 	pid_t				 pid;
-	struct imsgev			 iev;	/* parent<->this store child */
+	pid_t				 parser_pid;
+	struct imsgev			 iev;
 	int				 pending;
 	struct event			 timeout_ev;
-	/* 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.
- */
 struct open_session {
 	uint32_t			 session_id;
 	int				 authenticated;
 	struct imsgev			*listener_iev;
 	struct imsgev			*auth_iev;
 	pid_t				 listener_pid;
-	/* 0 if no auth-worker was spawned/is left */
 	pid_t				 auth_pid;
-	/* 0 if no search-oracle spawned/left */
-	pid_t				 search_pid;
+	struct store_child		*store;
+	struct sockaddr_storage		 peer;
 	TAILQ_ENTRY(open_session)	 entry;
 };
 
@@ -131,23 +111,21 @@ static struct imsgev	*iev_keymgr;
 static struct openimap_config *gconf;
 static char		 progpath[PATH_MAX];
 static char		**saved_argv;
-/* 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, see this file's header comment */
 static struct event	 ev_accept_cleartext[LISTENER_MAX_ADDRS];
 static struct event	 ev_accept_tls[LISTENER_MAX_ADDRS];
 
-/* 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;
+/* accept(2) paused for descriptors, and "connections max" reached */
+static struct event	 ev_accept_pause;
+static int		 connections_full;
 
 static __dead void	 exec_self_as(const char *);
 static pid_t	 fork_child(enum openimap_proc_type, struct imsgev **,
@@ -161,12 +139,23 @@ static void	 parent_dispatch_child(int, short, void *)
 static struct open_session *open_session_find(uint32_t);
 static unsigned int count_startups(void);
 static int	 startups_should_drop(unsigned int);
+static void	 accept_pause(void);
+static void	 accept_resume(int, short, void *);
+static unsigned int	 count_sessions(void);
+static int	 same_peer(const struct sockaddr_storage *,
+		    const struct sockaddr_storage *);
+static unsigned int	 count_startups_from(const struct sockaddr_storage *);
+static void	 refuse_connection(int, int);
 static void	 parent_accept(int, short, void *);
 static void	 spawn_connection(int, int, const struct sockaddr_storage *,
 		    socklen_t);
 static void	 parent_handle_store_fork(struct open_session *, uid_t, gid_t,
 		    const char *);
-static void	 store_fork_failed(struct open_session *);
+static void	 store_fork_failed(struct open_session *, int);
+static int	 store_child_attach(struct store_child *,
+		    struct open_session *);
+static void	 store_child_session_gone(struct store_child *);
+static void	 store_child_parser(struct store_child *);
 static void	 store_child_dispatch(int, short, void *);
 static void	 store_child_timeout(int, short, void *);
 static void	 store_child_teardown(struct store_child *, int);
@@ -186,7 +175,6 @@ static void	 sigterm_handler(int, short, void *);
 static void	 sigchld_handler(int, short, void *);
 static void	 reap_child(pid_t, int);
 
-/* config_load(), the full imapd.conf grammar, lives in parse.y */
 __dead void
 parent_main(const char *conffile, int argc, char *argv[],
     struct openimap_config *conf)
@@ -195,20 +183,13 @@ parent_main(const char *conffile, int argc, char *argv
 	int	 i;
 	struct rlimit	 rl;
 
-	/* 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 (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.
-	 */
+	/* the "daemon" login class caps descriptors below what startups need */
 	if (getrlimit(RLIMIT_NOFILE, &rl) == -1)
 		log_warn("getrlimit RLIMIT_NOFILE");
 	else if (rl.rlim_cur < rl.rlim_max) {
@@ -226,11 +207,7 @@ 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) below port 1024 needs root */
 	n_cleartext = conf->port_cleartext == 0 ? 0 :
 	    bind_listen_socket(conf->listen_addr, conf->port_cleartext,
 	    cleartext_fds);
@@ -243,26 +220,10 @@ 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.
-	 */
 	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.
-	 */
 	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.
-	 */
 	setup_done_send(iev_keymgr);
 
 	signal_set(&ev_sighup, SIGHUP, sighup_handler, NULL);
@@ -271,10 +232,6 @@ 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
-	 */
 
 	for (i = 0; i < n_cleartext; i++) {
 		event_set(&ev_accept_cleartext[i], cleartext_fds[i],
@@ -289,11 +246,7 @@ parent_main(const char *conffile, int argc, char *argv
 
 	/* 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
-	 */
+	/* proc exec sendfd: every connection forks and fd-passes */
 #ifdef __OpenBSD__
 	if (pledge("stdio rpath inet proc exec sendfd", NULL) == -1)
 		fatal("pledge");
@@ -303,11 +256,6 @@ 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
- */
 static __dead void
 exec_self_as(const char *role)
 {
@@ -317,35 +265,18 @@ 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.
-	 */
+	/* execv(3) takes char *const argv[] but never writes through it */
 	nargv[n++] = (char *)(uintptr_t)role;
 	for (i = 1; saved_argv[i] != NULL; i++) {
-		/* 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 would read past argv's NULL */
 			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.
-			 */
+			/* truncating could split an option from its value */
 			log_warnx("exec_self_as: argv too long to pass to the "
 			    "%s child, refusing to exec a truncated command "
 			    "line", role);
@@ -355,22 +286,11 @@ 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).
-	 */
 	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.
- */
+/* as smtpd.c's start_child() */
 static pid_t
 fork_child(enum openimap_proc_type type, struct imsgev **ievp,
     void (*handler)(int, short, void *))
@@ -382,11 +302,6 @@ 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.
- */
 static pid_t
 fork_child_nonfatal(enum openimap_proc_type type, struct imsgev **ievp,
     void (*handler)(int, short, void *))
@@ -414,6 +329,16 @@ fork_child_nonfatal(enum openimap_proc_type type, stru
 			_exit(1);
 		closefrom(4);
 
+		/* setrlimit(2) needs "proc" or "id" (sys/kern/kern_pledge.c) */
+		if (type == PROC_PARSER) {
+			struct rlimit	 rl;
+
+			rl.rlim_cur = PARSER_CPU_SOFT_SEC;
+			rl.rlim_max = PARSER_CPU_HARD_SEC;
+			if (setrlimit(RLIMIT_CPU, &rl) == -1)
+				_exit(1);
+		}
+
 		exec_self_as(log_procname(type));
 	}
 
@@ -429,7 +354,6 @@ fork_child_nonfatal(enum openimap_proc_type type, stru
 	}
 	c->pid = pid;
 	c->type = type;
-	/* 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);
 
@@ -437,26 +361,13 @@ 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 rides alongside imsg_type purely so a failure names the
- * wiring that failed, the same pairing send_mbox_request() uses in listener.c.
- */
+/* as smtpd.c's setup_peers(); id 0 for a child with one peer */
 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.
-	 */
 	if (socketpair(AF_UNIX, SOCK_STREAM, PF_UNSPEC, sp) == -1) {
 		log_warn("setup_peer_send %s: socketpair", imsgname);
 		return (-1);
@@ -464,13 +375,13 @@ setup_peer_send(struct imsgev *a, struct imsgev *b, in
 
 	if (imsg_compose(&a->ibuf, imsg_type, id, 0, sp[0], NULL, 0) == -1) {
 		log_warn("setup_peer_send %s: imsg_compose (a)", imsgname);
-		close(sp[0]);	/* neither end was taken */
+		close(sp[0]);
 		close(sp[1]);
 		return (-1);
 	}
 	if (imsg_compose(&b->ibuf, imsg_type, id, 0, sp[1], NULL, 0) == -1) {
 		log_warn("setup_peer_send %s: imsg_compose (b)", imsgname);
-		close(sp[1]);	/* sp[0] now belongs to a's msgbuf */
+		close(sp[1]);
 		return (-1);
 	}
 
@@ -485,11 +396,7 @@ 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
- */
+/* as smtpd.c's setup_done(); boot only */
 static void
 setup_done_send(struct imsgev *iev)
 {
@@ -518,11 +425,6 @@ 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.
- */
 static void
 parent_dispatch_child(int fd, short event, void *arg)
 {
@@ -530,10 +432,6 @@ parent_dispatch_child(int fd, short event, void *arg)
 	struct imsg	 imsg;
 	ssize_t		 n;
 
-	/*
-	 * 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");
@@ -543,7 +441,6 @@ parent_dispatch_child(int fd, short event, void *arg)
 		if ((n = imsgbuf_read(&iev->ibuf)) == -1)
 			fatal("imsgbuf_read");
 		if (n == 0) {
-			/* child's end closed, reaped via SIGCHLD */
 			event_del(&iev->ev);
 			return;
 		}
@@ -564,21 +461,10 @@ 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() does not NUL-terminate */
 			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.
-			 */
+			/* check against sessions the parent knows */
 			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 "
@@ -587,13 +473,7 @@ parent_dispatch_child(int fd, short event, void *arg)
 				    req.session_id);
 				break;
 			}
-			/*
-			 * 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.
-			 */
+			/* only this session's own auth-worker may grant it */
 			if (iev != os->auth_iev) {
 				log_warnx("refusing IMSG_AUTH_CRED for "
 				    "session %u: not from that session's own "
@@ -605,18 +485,17 @@ parent_dispatch_child(int fd, short event, void *arg)
 				log_warnx("refusing IMSG_AUTH_CRED for session "
 				    "%u: already authenticated, refusing "
 				    "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);
+				/* reply: the listener waits with no timeout */
+				store_fork_failed(os, 0);
 				break;
 			}
 			os->authenticated = 1;
 
+			if (imsg_compose(&iev->ibuf, IMSG_AUTH_EXIT, 0, 0, -1,
+			    NULL, 0) == -1)
+				log_warn("session %u: imsg_compose "
+				    "IMSG_AUTH_EXIT", req.session_id);
+
 			parent_handle_store_fork(os, req.uid, req.gid,
 			    req.maildir);
 			break;
@@ -632,7 +511,6 @@ parent_dispatch_child(int fd, short event, void *arg)
 	(void)fd;
 }
 
-/* The open_sessions lookup; see struct open_session's own comment. */
 static struct open_session *
 open_session_find(uint32_t session_id)
 {
@@ -645,12 +523,6 @@ open_session_find(uint32_t session_id)
 	return (NULL);
 }
 
-/*
- * 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)
 {
@@ -664,12 +536,7 @@ count_startups(void)
 	return (n);
 }
 
-/*
- * 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 */
 static int
 startups_should_drop(unsigned int nstartups)
 {
@@ -683,11 +550,6 @@ 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.
-	 */
 	if (gconf->max_startups_full <= gconf->max_startups_begin)
 		return (1);
 
@@ -697,39 +559,144 @@ startups_should_drop(unsigned int nstartups)
 	return (arc4random_uniform(100) < (uint32_t)p);
 }
 
-/*
- * 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.
- */
+/* out of descriptors: pause accept(2), as httpd does */
 static void
+accept_pause(void)
+{
+	struct timeval	 tv = { ACCEPT_PAUSE_SEC, 0 };
+	int		 i;
+
+	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]);
+	evtimer_set(&ev_accept_pause, accept_resume, NULL);
+	evtimer_add(&ev_accept_pause, &tv);
+}
+
+static void
+accept_resume(int fd, short event, void *arg)
+{
+	int	 i;
+
+	(void)fd; (void)event; (void)arg;
+
+	if (shutting_down)
+		return;
+	for (i = 0; i < n_cleartext; i++)
+		event_add(&ev_accept_cleartext[i], NULL);
+	for (i = 0; i < n_tls; i++)
+		event_add(&ev_accept_tls[i], NULL);
+}
+
+static unsigned int
+count_sessions(void)
+{
+	struct open_session	*os;
+	unsigned int		 n = 0;
+
+	TAILQ_FOREACH(os, &open_sessions, entry)
+		n++;
+	return (n);
+}
+
+static int
+same_peer(const struct sockaddr_storage *a, const struct sockaddr_storage *b)
+{
+	if (a->ss_family != b->ss_family)
+		return (0);
+	if (a->ss_family == AF_INET)
+		return (memcmp(&((const struct sockaddr_in *)a)->sin_addr,
+		    &((const struct sockaddr_in *)b)->sin_addr,
+		    sizeof(struct in_addr)) == 0);
+	if (a->ss_family == AF_INET6)
+		return (memcmp(&((const struct sockaddr_in6 *)a)->sin6_addr,
+		    &((const struct sockaddr_in6 *)b)->sin6_addr,
+		    sizeof(struct in6_addr)) == 0);
+	return (0);
+}
+
+/* as sshd's srclimit_check_allow() */
+static unsigned int
+count_startups_from(const struct sockaddr_storage *ss)
+{
+	struct open_session	*os;
+	unsigned int		 n = 0;
+
+	TAILQ_FOREACH(os, &open_sessions, entry) {
+		if (!os->authenticated && same_peer(&os->peer, ss))
+			n++;
+	}
+	return (n);
+}
+
+/* RFC 9051 SS7.1.5 BYE, on the cleartext port only */
+static void
+refuse_connection(int fd, int cleartext)
+{
+	static const char	 bye[] = "* BYE [UNAVAILABLE] too many "
+				    "connections, try again later\r\n";
+
+	if (cleartext)
+		(void)write(fd, bye, sizeof(bye) - 1);
+	close(fd);
+}
+
+static void
 parent_accept(int fd, short event, void *arg)
 {
 	struct sockaddr_storage	 ss;
 	socklen_t			 sslen = sizeof(ss);
-	int				 client_fd, flags, implicit_tls;
-	unsigned int			 nstartups;
+	int				 client_fd, flags, implicit_tls, on = 1;
+	unsigned int			 nstartups, nopen, nsource;
 
 	(void)event;
 
 	if ((client_fd = accept(fd, (struct sockaddr *)&ss, &sslen)) == -1) {
-		log_warn("accept");
+		/* else the listen event fires again at once, and spins */
+		if (errno == EMFILE || errno == ENFILE) {
+			log_warn("accept, pausing %ds", ACCEPT_PAUSE_SEC);
+			accept_pause();
+		} else
+			log_warn("accept");
 		return;
 	}
 
+	nopen = count_sessions();
+	if (nopen >= gconf->connections_max) {
+		if (!connections_full)
+			log_warnx("connections max reached (%u open), "
+			    "refusing new connections", nopen);
+		connections_full = 1;
+		refuse_connection(client_fd, arg == (void *)0);
+		return;
+	}
+	if (connections_full) {
+		log_info("below connections max again (%u open), "
+		    "accepting", nopen);
+		connections_full = 0;
+	}
+
+	if (gconf->max_startups_per_source != 0) {
+		nsource = count_startups_from(&ss);
+		if (nsource >= gconf->max_startups_per_source) {
+			log_debug("startups per-source: refusing connection "
+			    "(%u unauthenticated already open from its "
+			    "address)", nsource);
+			refuse_connection(client_fd, arg == (void *)0);
+			return;
+		}
+	}
+
 	nstartups = count_startups();
 	if (startups_should_drop(nstartups)) {
 		log_debug("MaxStartups: refusing connection (%u "
 		    "unauthenticated already open)", nstartups);
-		close(client_fd);
+		refuse_connection(client_fd, arg == (void *)0);
 		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) does not inherit O_NONBLOCK */
 	if ((flags = fcntl(client_fd, F_GETFL)) == -1 ||
 	    fcntl(client_fd, F_SETFL, flags | O_NONBLOCK) == -1) {
 		log_warn("fcntl O_NONBLOCK");
@@ -737,17 +704,15 @@ parent_accept(int fd, short event, void *arg)
 		return;
 	}
 
-	/* (void *)1 == port-993 listener */
+	/* as sshd's TCPKeepAlive: a session whose client vanished ends */
+	if (setsockopt(client_fd, SOL_SOCKET, SO_KEEPALIVE, &on,
+	    sizeof(on)) == -1)
+		log_warn("setsockopt SO_KEEPALIVE");
+
 	implicit_tls = (arg != (void *)0);
 	spawn_connection(client_fd, implicit_tls, &ss, sslen);
 }
 
-/*
- * 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)
@@ -755,20 +720,13 @@ spawn_connection(int client_fd, int implicit_tls,
 	struct open_session			*os, *it;
 	struct imsgev				*new_listener_iev;
 	struct imsgev				*new_auth_iev;
-	struct imsgev				*new_search_iev;
 	struct imsg_listener_session_init	 init;
 	uint32_t				 session_id;
 	pid_t					 listener_pid, auth_pid;
-	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.
-	 */
+	/* to listener.c, peer id 0 is the auth peer */
 	if (next_session_id == 0)
 		next_session_id = 1;
 
@@ -787,6 +745,7 @@ spawn_connection(int client_fd, int implicit_tls,
 		return;
 	}
 	os->session_id = session_id;
+	os->peer = *ss;
 
 	if ((listener_pid = fork_child_nonfatal(PROC_LISTENER,
 	    &new_listener_iev, parent_dispatch_child)) == -1) {
@@ -798,12 +757,7 @@ 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 early so reap_child() always finds it */
 	TAILQ_INSERT_TAIL(&open_sessions, os, entry);
 
 	if ((auth_pid = fork_child_nonfatal(PROC_AUTH, &new_auth_iev,
@@ -813,48 +767,15 @@ spawn_connection(int client_fd, int implicit_tls,
 		    "connection itself (listener-worker detects a missing "
 		    "auth peer the same way it detects one dying later)",
 		    session_id);
-		/* os->auth_iev/auth_pid stay 0/NULL; nothing to wire below. */
 	} else {
 		os->auth_pid = auth_pid;
 		os->auth_iev = new_auth_iev;
-		/*
-		 * id 0: listener-worker and auth-worker have exactly one peer
-		 * each for their whole (short) life, no discriminator needed on
-		 * either side -- see setup_peer_send()'s own comment
-		 */
 		if (send_auth_init(new_auth_iev, gconf) == -1 ||
 		    setup_peer_send(new_listener_iev, new_auth_iev,
 		    IMSG_SETUP_PEER, "IMSG_SETUP_PEER", 0) == -1)
 			goto fail_close;
 	}
 
-	/*
-	 * 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 "
-		    "session's SEARCH will fail until a new connection gets "
-		    "one (listener-worker detects a missing search peer the "
-		    "same way it detects one dying later)", session_id);
-		/* os->search_pid stays 0; nothing to wire below. */
-	} else {
-		os->search_pid = search_pid;
-		/* 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",
-		    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
-	 */
 	if (setup_peer_send(new_listener_iev, iev_keymgr, IMSG_SETUP_PEER,
 	    "IMSG_SETUP_PEER", session_id) == -1)
 		goto fail_close;
@@ -864,10 +785,6 @@ 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().
-	 */
 	init.idle_poll_secs = gconf->idle_poll_secs;
 	init.login_grace_secs = gconf->login_grace_secs;
 	init.append_max = gconf->append_max;
@@ -879,12 +796,7 @@ 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.
-	 */
+	/* client_fd now belongs to imsg; do not close it */
 	if (send_tls_cert(new_listener_iev, gconf) == -1)
 		goto fail_workers;
 	return;
@@ -892,18 +804,12 @@ 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.
-	 */
+	/* reap_child() frees os; do not free it here */
 	log_warnx("session %u: refusing connection: worker wiring failed",
 	    session_id);
 	kill(os->listener_pid, SIGKILL);
 	if (os->auth_pid != 0)
 		kill(os->auth_pid, SIGKILL);
-	if (os->search_pid != 0)
-		kill(os->search_pid, SIGKILL);
 }
 
 /* rejects a maildir that could escape the spool: empty/absolute/".." */
@@ -930,10 +836,6 @@ 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
- */
 static void
 parent_handle_store_fork(struct open_session *os, uid_t uid, gid_t gid,
     const char *maildir)
@@ -944,15 +846,8 @@ parent_handle_store_fork(struct open_session *os, uid_
 	struct timeval		 tv;
 	struct imsg_store_init	 init_payload;
 	struct store_child	*it;
-	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.
-	 */
 	if (os->listener_iev == NULL) {
 		log_warnx("refusing store spawn for session %u: "
 		    "listener-worker is gone", session_id);
@@ -972,25 +867,31 @@ parent_handle_store_fork(struct open_session *os, uid_
 		goto fail;
 	}
 
+	/* a second store for one session would orphan the first */
+	if (os->store != NULL) {
+		log_warnx("refusing store spawn: session %u already "
+		    "has a store child", session_id);
+		goto fail;
+	}
+
 	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.
-		 */
-		if (it->session_id == session_id) {
-			log_warnx("refusing store spawn: session %u already "
-			    "has a store child", session_id);
-			goto fail;
+		if (!it->exiting && it->uid == uid && it->gid == gid &&
+		    strcmp(it->maildir, maildir) == 0) {
+			if (it->nsessions >= gconf->account_sessions) {
+				log_info("session %u: refusing login: uid %u "
+				    "already has %u sessions (account "
+				    "sessions)", session_id, (unsigned int)uid,
+				    it->nsessions);
+				store_fork_failed(os, 1);
+				return;
+			}
+			if (store_child_attach(it, os) == -1)
+				goto fail;
+			log_debug("session %u: joined the store child of "
+			    "session %u", session_id, it->session_id);
+			return;
 		}
-		nchildren++;
 	}
-	if (nchildren >= STORE_CHILD_MAX) {
-		log_warnx("refusing store spawn: %u store children active "
-		    "(session %u)", nchildren, session_id);
-		goto fail;
-	}
 
 	if (socketpair(AF_UNIX, SOCK_STREAM, PF_UNSPEC, pair) == -1) {
 		log_warn("socketpair");
@@ -1015,35 +916,22 @@ parent_handle_store_fork(struct open_session *os, uid_
 
 	close(pair[1]);
 
-	/* 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.
-		 */
 		log_warn("calloc store child (session %u)", session_id);
 		kill(pid, SIGKILL);
 		close(pair[0]);
 		goto fail;
 	}
 	sc->session_id = session_id;
+	sc->uid = uid;
+	sc->gid = gid;
+	(void)strlcpy(sc->maildir, maildir, sizeof(sc->maildir));
 	sc->pid = pid;
 	sc->pending = 1;
-	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.
-	 */
+	/* zeroed so no stack bytes of root reach the store child */
 	memset(&init_payload, 0, sizeof(init_payload));
 	init_payload.session_id = session_id;
 	init_payload.uid = uid;
@@ -1073,20 +961,15 @@ parent_handle_store_fork(struct open_session *os, uid_
 		goto fail_kill;
 	}
 
-	/*
-	 * 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;
-
 	if (imsg_compose(&sc->iev.ibuf, IMSG_SETUP_DONE, 0, 0, -1, NULL, 0)
 	    == -1) {
 		log_warn("imsg_compose IMSG_SETUP_DONE");
 		goto fail_kill;
 	}
 
+	if (store_child_attach(sc, os) == -1)
+		goto fail_kill;
+
 	evtimer_set(&sc->timeout_ev, store_child_timeout, sc);
 	tv.tv_sec = STORE_SETUP_TIMEOUT_SEC;
 	tv.tv_usec = 0;
@@ -1098,48 +981,112 @@ parent_handle_store_fork(struct open_session *os, uid_
 fail_kill:
 	kill(pid, SIGKILL);
 	event_del(&sc->iev.ev);
-	/* 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);
+	store_fork_failed(os, 0);
 }
 
-/*
- * 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)
+store_fork_failed(struct open_session *os, int limit)
 {
 	struct imsg_store_fork	 fail_payload;
 
-	/*
-	 * fail only this one session: IMSG_STORE_FORK is now solely this
-	 * failure-reply type, to the session's own listener-worker
-	 */
 	if (os->listener_iev == NULL)
 		return;
 	memset(&fail_payload, 0, sizeof(fail_payload));
 	fail_payload.session_id = os->session_id;
+	fail_payload.limit = limit;
 	if (imsg_compose(&os->listener_iev->ibuf, IMSG_STORE_FORK, 0, 0, -1,
 	    &fail_payload, sizeof(fail_payload)) == -1)
 		log_warn("imsg_compose IMSG_STORE_FORK (failure reply)");
 }
 
-/* the trailing imsgev_rearm_read() is not optional; see its definition */
+static int
+store_child_attach(struct store_child *sc, struct open_session *os)
+{
+	if (setup_peer_send(&sc->iev, os->listener_iev, IMSG_SETUP_PEER,
+	    "IMSG_SETUP_PEER", os->session_id) == -1)
+		return (-1);
+	os->store = sc;
+	sc->nsessions++;
+	return (0);
+}
+
+/* the parent, not the store child, decides when it is done */
 static void
+store_child_session_gone(struct store_child *sc)
+{
+	if (sc->nsessions > 0)
+		sc->nsessions--;
+	if (sc->nsessions > 0 || sc->exiting)
+		return;
+	sc->exiting = 1;
+	if (imsg_compose(&sc->iev.ibuf, IMSG_STORE_EXIT, 0, 0, -1, NULL,
+	    0) == -1) {
+		log_warn("session %u: imsg_compose IMSG_STORE_EXIT",
+		    sc->session_id);
+		store_child_fail(sc);
+	}
+}
+
+/* the store child cannot fork: its pledge has no "proc exec" */
+static void
+store_child_parser(struct store_child *sc)
+{
+	struct imsg_parser_init	 payload;
+	struct imsgev		*parser_iev;
+	pid_t			 pid;
+
+	if (sc->parser_pid != 0) {
+		kill(sc->parser_pid, SIGKILL);
+		sc->parser_pid = 0;
+	}
+	if ((pid = fork_child_nonfatal(PROC_PARSER, &parser_iev,
+	    parent_dispatch_child)) == -1) {
+		log_warnx("session %u: could not spawn a parser-worker",
+		    sc->session_id);
+		goto none;
+	}
+	memset(&payload, 0, sizeof(payload));
+	payload.session_id = sc->session_id;
+	payload.uid = sc->uid;
+	payload.gid = sc->gid;
+	payload.bodystructure_read_max = gconf->bodystructure_read_max;
+	if (imsg_compose(&parser_iev->ibuf, IMSG_PARSER_INIT, 0, 0, -1,
+	    &payload, sizeof(payload)) == -1) {
+		log_warn("imsg_compose IMSG_PARSER_INIT");
+		goto kill;
+	}
+	if (imsgbuf_flush(&parser_iev->ibuf) == -1) {
+		log_warn("imsgbuf_flush IMSG_PARSER_INIT");
+		goto kill;
+	}
+	/* next_session_id skips 0, so id 0 is never a session */
+	if (setup_peer_send(&sc->iev, parser_iev, IMSG_SETUP_PEER,
+	    "IMSG_SETUP_PEER", 0) == -1)
+		goto kill;
+	sc->parser_pid = pid;
+	return;
+
+kill:
+	kill(pid, SIGKILL);
+none:
+	if (imsg_compose(&sc->iev.ibuf, IMSG_PARSER_NONE, 0, 0, -1, NULL,
+	    0) == -1)
+		log_warn("session %u: imsg_compose IMSG_PARSER_NONE",
+		    sc->session_id);
+}
+
+static void
 store_child_dispatch(int fd, short event, void *arg)
 {
 	struct store_child	*sc = arg;
 	struct imsg		 imsg;
 	ssize_t			 n;
 
-	/* 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);
@@ -1168,6 +1115,11 @@ store_child_dispatch(int fd, short event, void *arg)
 			imsg_free(&imsg);
 			continue;
 		}
+		if (imsg_get_type(&imsg) == IMSG_PARSER_WANT) {
+			imsg_free(&imsg);
+			store_child_parser(sc);
+			continue;
+		}
 
 		log_debug("store_child_dispatch: unhandled %d (session %u)",
 		    imsg_get_type(&imsg), sc->session_id);
@@ -1189,30 +1141,18 @@ 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
- */
 static void
 store_child_teardown(struct store_child *sc, int already_dead)
 {
-	if (sc->pending && sc->listener_iev != NULL) {
-		struct imsg_store_fork	 fail_payload;
+	struct open_session	*os;
 
-		memset(&fail_payload, 0, sizeof(fail_payload));
-		fail_payload.session_id = sc->session_id;
-		if (imsg_compose(&sc->listener_iev->ibuf, IMSG_STORE_FORK,
-		    0, 0, -1, &fail_payload, sizeof(fail_payload)) == -1)
-			log_warn("imsg_compose IMSG_STORE_FORK "
-			    "(failure reply)");
+	TAILQ_FOREACH(os, &open_sessions, entry) {
+		if (os->store != sc)
+			continue;
+		os->store = NULL;
+		if (sc->pending)
+			store_fork_failed(os, 0);
 	}
-	/*
-	 * 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);
@@ -1230,7 +1170,6 @@ store_child_fail(struct store_child *sc)
 	store_child_teardown(sc, 0);
 }
 
-/* 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)
@@ -1252,7 +1191,7 @@ bind_one(int family, const struct sockaddr *sa, sockle
 	return (fd);
 }
 
-/* resolves+binds "addr"/"port"; "*" or literal IPv4/IPv6 only, no DNS */
+/* "*" or a literal address; no DNS */
 static int
 bind_listen_socket(const char *addr, uint16_t port, int fds[LISTENER_MAX_ADDRS])
 {
@@ -1305,21 +1244,7 @@ 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 "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.
- */
+/* an oversized file or a short read is refused, never truncated */
 static int
 read_file_capped(FILE *fp, char *buf, size_t bufsize, size_t *n_out,
     const char *path, const char *what)
@@ -1347,11 +1272,6 @@ 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.
- */
 static int
 send_tls_cert(struct imsgev *iev, struct openimap_config *conf)
 {
@@ -1364,19 +1284,13 @@ 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.
-		 */
+		/* never send a truncated file */
 		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 */
 		fclose(fp);
 	}
 
-	/* Non-fatal: spawn_connection() calls this per connection. */
 	if (imsg_compose(&iev->ibuf, IMSG_TLS_CERT, 0, 0, -1, buf, n) == -1) {
 		log_warn("imsg_compose IMSG_TLS_CERT");
 		return (-1);
@@ -1388,12 +1302,7 @@ 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.
- */
+/* two composes, each under MAX_IMSGSIZE */
 static int
 send_keymgr_init(struct imsgev *iev, struct openimap_config *conf)
 {
@@ -1428,11 +1337,6 @@ send_keymgr_init(struct imsgev *iev, struct openimap_c
 	}
 
 	/* 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));
@@ -1447,23 +1351,11 @@ 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
- */
 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.
-	 */
 	memset(&init, 0, sizeof(init));
 	if (strlcpy(init.cred_file, conf->cred_file, sizeof(init.cred_file))
 	    >= sizeof(init.cred_file)) {
@@ -1484,11 +1376,7 @@ 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
- */
+/* listen_addr, port and cred_file need a restart to change */
 static void
 sighup_handler(int fd, short event, void *arg)
 {
@@ -1520,10 +1408,6 @@ sighup_handler(int fd, short event, void *arg)
 		    "restart is required for this to take effect", conf_path);
 	}
 
-	/*
-	 * 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)) {
@@ -1533,7 +1417,6 @@ sighup_handler(int fd, short event, void *arg)
 		return;
 	}
 
-	/* Safe to adopt immediately, see this function's header comment. */
 	(void)strlcpy(gconf->spool_root, newconf.spool_root,
 	    sizeof(gconf->spool_root));
 	gconf->bodystructure_read_max = newconf.bodystructure_read_max;
@@ -1541,27 +1424,20 @@ sighup_handler(int fd, short event, void *arg)
 	gconf->idle_poll_secs = newconf.idle_poll_secs;
 	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->account_sessions = newconf.account_sessions;
+	gconf->connections_max = newconf.connections_max;
+	/* startups and tls_cert_file are read afresh per 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;
+	gconf->max_startups_per_source = newconf.max_startups_per_source;
 
 	(void)strlcpy(gconf->tls_cert_file, newconf.tls_cert_file,
 	    sizeof(gconf->tls_cert_file));
 	(void)strlcpy(gconf->tls_key_file, newconf.tls_key_file,
 	    sizeof(gconf->tls_key_file));
 
-	/*
-	 * 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 holds the key for every connection, so it needs a push */
 	if (iev_keymgr != NULL) {
 		if (send_keymgr_init(iev_keymgr, gconf) == -1)
 			log_warnx("SIGHUP: pushing the reloaded certificate "
@@ -1574,7 +1450,6 @@ sighup_handler(int fd, short event, void *arg)
 	log_info("SIGHUP: reload complete");
 }
 
-/* children still to reap before the parent may exit */
 static int
 shutdown_children_left(void)
 {
@@ -1613,11 +1488,7 @@ sigterm_force(int fd, short event, void *arg)
 	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.
- */
+/* EOF, not a signal: the worker tears its own session down */
 static void
 sigterm_handler(int fd, short event, void *arg)
 {
@@ -1635,7 +1506,6 @@ sigterm_handler(int fd, short event, void *arg)
 	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++)
@@ -1658,7 +1528,6 @@ sigterm_handler(int fd, short event, void *arg)
 	    -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);
@@ -1679,7 +1548,6 @@ sigchld_handler(int fd, short 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");
@@ -1687,12 +1555,7 @@ sigchld_handler(int fd, short event, void *arg)
 	}
 }
 
-/*
- * 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.
- */
+/* keymgr is not restarted; a worker exiting is routine */
 static void
 reap_child(pid_t pid, int status)
 {
@@ -1705,7 +1568,6 @@ reap_child(pid_t pid, int status)
 
 			TAILQ_REMOVE(&children, c, entry);
 			event_del(&c->iev.ev);
-			/* sigterm_handler() may have closed this */
 			if (c->iev.ibuf.fd != -1)
 				close(c->iev.ibuf.fd);
 			imsgbuf_clear(&c->iev.ibuf);
@@ -1745,19 +1607,22 @@ reap_child(pid_t pid, int status)
 				return;
 			}
 
-			/*
-			 * 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.
-			 */
+			if (c->type == PROC_PARSER) {
+				log_debug("parser[%d] exited (status %d)",
+				    pid, status);
+				TAILQ_FOREACH(sc, &store_children, entry) {
+					if (sc->parser_pid == pid)
+						sc->parser_pid = 0;
+				}
+				free(c);
+				return;
+			}
+
 			TAILQ_FOREACH(os, &open_sessions, entry) {
 				if ((c->type == PROC_LISTENER &&
 				    os->listener_pid == pid) ||
 				    (c->type == PROC_AUTH &&
-				    os->auth_pid == pid) ||
-				    (c->type == PROC_SEARCH &&
-				    os->search_pid == pid))
+				    os->auth_pid == pid))
 					break;
 			}
 			if (os == NULL) {
@@ -1774,21 +1639,14 @@ 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).
-				 */
 				os->listener_iev = NULL;
-				TAILQ_FOREACH(s, &store_children, entry) {
-					if (s->session_id == os->session_id)
-						s->listener_iev = NULL;
+				if ((s = os->store) != NULL) {
+					os->store = NULL;
+					store_child_session_gone(s);
 				}
 				TAILQ_REMOVE(&open_sessions, os, entry);
 				free(os);
-			} else if (c->type == PROC_AUTH) {
+			} else {
 				log_debug("session %u: auth-worker[%d] "
 				    "exited (status %d); this session's "
 				    "listener-worker will detect this on "
@@ -1796,13 +1654,6 @@ reap_child(pid_t pid, int status)
 				    pid, status);
 				os->auth_iev = NULL;
 				os->auth_pid = 0;
-			} else {
-				log_debug("session %u: search-oracle[%d] "
-				    "exited (status %d); this session's "
-				    "listener-worker will detect this on "
-				    "its own search channel", os->session_id,
-				    pid, status);
-				os->search_pid = 0;
 			}
 
 			free(c);
blob - 9c564ba78b6053da3d754665b6ae523b9de5bda0
blob + cfad15bf92d2bb0d9cfdf97b9ce1d6cd053156ae
--- src/parse.y
+++ src/parse.y
@@ -28,8 +28,6 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* parse.y, imapd.conf grammar. */
-
 %{
 #include <sys/types.h>
 #include <sys/stat.h>
@@ -104,10 +102,12 @@ typedef struct {
 %token	LISTEN ON TLS PORT
 %token	SPOOL CREDENTIALS CERTIFICATE KEY
 %token	APPEND ATTACHMENT MAX
-%token	STARTUPS BEGIN RATE FULL
+%token	STARTUPS BEGIN RATE FULL PERSOURCE NONE
 %token	IDLE POLL
 %token	LOGIN GRACE
 %token	LOCK TIMEOUT
+%token	ACCOUNT SESSIONS
+%token	CONNECTIONS
 %token	INCLUDE
 %token	ERROR
 %token	<v.string>	STRING
@@ -127,15 +127,7 @@ grammar		: /* empty */
 include		: INCLUDE STRING		{
 			struct file	*nfile;
 
-			/*
-			 * secret=1, matching config_load()'s own pushfile():
-			 * an included file can set "tls key" and
-			 * "credentials", so it gets the same ownership and
-			 * permission check the main config gets. iked and
-			 * ldapd -- the two base parsers whose configs also
-			 * hold key material -- pass 1 here for the same
-			 * reason; httpd and smtpd check neither file.
-			 */
+			/* secret=1: an included file may hold "tls key" */
 			if ((nfile = pushfile($2, 1)) == NULL) {
 				yyerror("failed to include file %s", $2);
 				free($2);
@@ -272,14 +264,7 @@ main		: LISTEN ON STRING opttls PORT NUMBER	{
 			free($3);
 		}
 		| IDLE POLL NUMBER		{
-			/*
-			 * 0 is legal and means "do not poll": that restores
-			 * the behaviour this directive replaced, where an
-			 * IDLEing session saw nothing until it sent DONE. It
-			 * is here so an operator who dislikes the polling can
-			 * turn it off without patching, not because it is a
-			 * sensible thing to want.
-			 */
+			/* 0 disables polling */
 			if ($3 < 0 || $3 > IDLE_POLL_MAX) {
 				yyerror("idle poll out of range "
 				    "(0 to disable, otherwise 1-%d "
@@ -290,13 +275,7 @@ 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.
-			 */
+			/* 0 disables it, as sshd_config(5)'s LoginGraceTime */
 			if ($3 < 0 || $3 > LOGIN_GRACE_MAX) {
 				yyerror("login grace out of range "
 				    "(0 to disable, otherwise 1-%d "
@@ -307,12 +286,7 @@ main		: LISTEN ON STRING opttls PORT NUMBER	{
 			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.
-			 */
+			/* 0 disables the bound */
 			if ($3 < 0 || $3 > LOCK_TIMEOUT_MAX) {
 				yyerror("lock timeout out of range "
 				    "(0 to disable, otherwise 1-%d "
@@ -322,6 +296,24 @@ main		: LISTEN ON STRING opttls PORT NUMBER	{
 			}
 			conf->lock_timeout_secs = (uint32_t)$3;
 		}
+		| ACCOUNT SESSIONS NUMBER	{
+			if ($3 < 1 || $3 > ACCOUNT_SESSIONS_MAX) {
+				yyerror("account sessions out of range "
+				    "(1-%d): %lld", ACCOUNT_SESSIONS_MAX,
+				    (long long)$3);
+				YYERROR;
+			}
+			conf->account_sessions = (uint32_t)$3;
+		}
+		| CONNECTIONS MAX NUMBER	{
+			if ($3 < 1 || $3 > CONNECTIONS_MAX_MAX) {
+				yyerror("connections max out of range "
+				    "(1-%d): %lld", CONNECTIONS_MAX_MAX,
+				    (long long)$3);
+				YYERROR;
+			}
+			conf->connections_max = (uint32_t)$3;
+		}
 		| APPEND MAX NUMBER	{
 			if ($3 < 1 || $3 > APPEND_MAX_MAX) {
 				yyerror("append max out of range "
@@ -332,9 +324,6 @@ main		: LISTEN ON STRING opttls PORT NUMBER	{
 			conf->append_max = (uint64_t)$3;
 		}
 		| ATTACHMENT MAX NUMBER	{
-			/*
-			 * Range-validated
-			 */
 			if ($3 < BODYSTRUCTURE_MAX || $3 > 1073741824) {
 				yyerror("attachment max out of range "
 				    "(%d-1073741824 bytes): %lld",
@@ -343,18 +332,20 @@ main		: LISTEN ON STRING opttls PORT NUMBER	{
 			}
 			conf->bodystructure_read_max = (uint32_t)$3;
 		}
+		| STARTUPS PERSOURCE NUMBER	{
+			if ($3 < 1 || $3 > 1000000) {
+				yyerror("startups per-source out of range "
+				    "(1-1000000, or none): %lld",
+				    (long long)$3);
+				YYERROR;
+			}
+			conf->max_startups_per_source = (uint32_t)$3;
+		}
+		| STARTUPS PERSOURCE NONE	{
+			conf->max_startups_per_source = 0;
+		}
 		| STARTUPS BEGIN NUMBER RATE NUMBER FULL NUMBER {
-			/*
-			 * 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
-			 * linearly increasing probability starting at
-			 * "rate" percent; at or above "full", every new one
-			 * is refused. See parent.c's startups_should_drop()
-			 * (moved there with the accept loop; see parent.c's
-			 * header comment).
-			 */
+			/* as sshd_config(5)'s MaxStartups */
 			if ($3 < 0 || $3 > 1000000) {
 				yyerror("startups begin out of range "
 				    "(0-1000000): %lld", (long long)$3);
@@ -416,10 +407,12 @@ lookup(char *s)
 {
 	/* this has to be sorted always */
 	static const struct keywords keywords[] = {
+	    {"account",			ACCOUNT},
 	    {"append",			APPEND},
 	    {"attachment",		ATTACHMENT},
 	    {"begin",			BEGIN},
 	    {"certificate",		CERTIFICATE},
+	    {"connections",		CONNECTIONS},
 	    {"credentials",		CREDENTIALS},
 	    {"full",			FULL},
 	    {"grace",			GRACE},
@@ -430,10 +423,13 @@ lookup(char *s)
 	    {"lock",			LOCK},
 	    {"login",			LOGIN},
 	    {"max",			MAX},
+	    {"none",			NONE},
 	    {"on",			ON},
+	    {"per-source",		PERSOURCE},
 	    {"poll",			POLL},
 	    {"port",			PORT},
 	    {"rate",			RATE},
+	    {"sessions",			SESSIONS},
 	    {"spool",			SPOOL},
 	    {"startups",		STARTUPS},
 	    {"timeout",			TIMEOUT},
@@ -657,25 +653,7 @@ top:
 			return (NUMBER);
 		} else {
 nodigits:
-			/*
-			 * This un-reads the whole token so the string scanner
-			 * below can re-lex it, but lungetc() drops characters
-			 * silently once pushback_buffer fills (MAXPUSHBACK is
-			 * 128, buf is sizeof(buf)) -- and pushback is LIFO, so
-			 * the loss excises the MIDDLE of the token and the
-			 * lexer proceeds on input the file does not contain.
-			 * Refuse it instead, with the same error the two
-			 * length checks above use. (Newer revisions of the
-			 * shared OpenBSD parse.y skeleton grow a per-file
-			 * ungetbuf instead of dropping; this file predates
-			 * that.)
-			 *
-			 * The loop below pushes (p - buf) - 1 characters and
-			 * lungetc() accepts MAXPUSHBACK - 1 of them, so the
-			 * exact bound is "p - buf > MAXPUSHBACK"; this is
-			 * deliberately one stricter, which costs nothing and
-			 * removes an off-by-one to get wrong.
-			 */
+			/* lungetc() silently drops past MAXPUSHBACK */
 			if ((size_t)(p - buf) >= MAXPUSHBACK) {
 				yyerror("string too long");
 				return (findeol());
@@ -694,9 +672,6 @@ nodigits:
 	x != '!' && x != '=' && x != '#' && \
 	x != ','))
 
-	/*
-	 * '*' added alongside the pre-existing ':'
-	 */
 	if (isalnum(c) || c == ':' || c == '_' || c == '*') {
 		do {
 			*p++ = c;
@@ -721,10 +696,7 @@ nodigits:
 	return (c);
 }
 
-/*
- * Same root-owned-or-current-user, not-group-or-world-writable check this
- * project already applies to the TLS private key file
- */
+/* owned by root or this user, not group- or world-writable */
 static int
 check_file_secrecy(int fd, const char *fname)
 {
@@ -792,9 +764,6 @@ popfile(void)
 	return (file ? 0 : EOF);
 }
 
-/*
- * config_load(): imapd.h's public entry point.
- */
 int
 config_load(const char *path, struct openimap_config *xconf)
 {
@@ -809,6 +778,8 @@ config_load(const char *path, struct openimap_config *
 	conf->idle_poll_secs = IDLE_POLL_DEFAULT;
 	conf->login_grace_secs = LOGIN_GRACE_DEFAULT;
 	conf->lock_timeout_secs = LOCK_TIMEOUT_DEFAULT;
+	conf->account_sessions = ACCOUNT_SESSIONS_DEFAULT;
+	conf->connections_max = CONNECTIONS_MAX_DEFAULT;
 	(void)strlcpy(conf->spool_root, "/var/mail/imapd",
 	    sizeof(conf->spool_root));
 	(void)strlcpy(conf->cred_file, "/etc/imapd/credentials",
@@ -824,6 +795,7 @@ config_load(const char *path, struct openimap_config *
 	conf->max_startups_begin = 10;
 	conf->max_startups_rate = 30;
 	conf->max_startups_full = 100;
+	conf->max_startups_per_source = STARTUPS_PER_SOURCE_DEFAULT;
 
 	have_cleartext = 0;
 	have_tls_listen = 0;
@@ -837,16 +809,6 @@ config_load(const char *path, struct openimap_config *
 	errors = file->errors;
 	popfile();
 
-	/*
-	 * Carry the listener selection into the config. Both ports were
-	 * seeded with their defaults above; clear the one this config did not
-	 * ask for, so parent.c does not bind it.
-	 *
-	 * The guard matters: a config with NO "listen" line at all keeps both
-	 * defaults, which is what such a config has always produced. Only a
-	 * config that names a listener is taken to be selecting listeners --
-	 * which is what imapd.conf's syntax reads as, and previously was not.
-	 */
 	if (have_cleartext || have_tls_listen) {
 		if (!have_cleartext)
 			conf->port_cleartext = 0;
@@ -854,12 +816,7 @@ 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.
-	 */
+	/* here, since the two directives may come in either order */
 	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,
@@ -926,9 +883,6 @@ symset(const char *nam, const char *val, int persist)
 	return (0);
 }
 
-/*
- * cmdline_symset(): "-D name=value" command-line macro definitions.
- */
 int
 cmdline_symset(char *s)
 {
blob - /dev/null
blob + 89cadd6d7d8b8435bffef0913ac0d978bd4afb4e (mode 644)
--- /dev/null
+++ src/parser.c
@@ -0,0 +1,309 @@
+/*	$OpenIMAPD$	*/
+
+/*
+ * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+#include <sys/queue.h>
+#include <sys/resource.h>
+#include <sys/socket.h>
+#include <sys/time.h>
+
+#include <event.h>
+#include <grp.h>
+#include <imsg.h>
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "imapd.h"
+#include "log.h"
+#include "store_internal.h"
+
+static struct imsgev	 iev_store;
+
+static void	 parser_dispatch_store(int, short, void *);
+static int	 parser_spent(void);
+static void	 parser_handle_request(struct imsgev *, struct imsg *,
+		    uint32_t);
+static int	 parser_search(struct imsg *, const struct imsg_parser_req *,
+		    int, char **, uint32_t *);
+
+__dead void
+parser_main(void)
+{
+	struct imsgbuf		 ibuf3;
+	struct imsg		 imsg;
+	struct imsg_parser_init	 init;
+	ssize_t			 n;
+	gid_t			 gid;
+	int			 store_fd = -1;
+	int			 got_init = 0;
+
+	memset(&init, 0, sizeof(init));
+
+	imsgev_ibuf_init(&ibuf3, 3);
+
+	while (!got_init || store_fd == -1) {
+		if ((n = imsgbuf_get(&ibuf3, &imsg)) == -1)
+			fatal("imsgbuf_get");
+		if (n == 0) {
+			if ((n = imsgbuf_read(&ibuf3)) == -1)
+				fatal("imsgbuf_read");
+			if (n == 0)
+				fatalx("parent closed channel before boot "
+				    "finished");
+			continue;
+		}
+
+		switch (imsg_get_type(&imsg)) {
+		case IMSG_PARSER_INIT:
+			if (imsg_get_data(&imsg, &init, sizeof(init)) == -1)
+				fatalx("bad IMSG_PARSER_INIT payload");
+			session_id = init.session_id;
+			bodystructure_read_max = init.bodystructure_read_max;
+			got_init = 1;
+			break;
+		case IMSG_SETUP_PEER:
+			if (store_fd != -1) {
+				log_warnx("duplicate IMSG_SETUP_PEER, "
+				    "ignoring");
+				break;
+			}
+			if ((store_fd = imsg_get_fd(&imsg)) == -1)
+				fatalx("IMSG_SETUP_PEER carried no fd");
+			break;
+		default:
+			log_debug("parser boot: unhandled %d",
+			    imsg_get_type(&imsg));
+			break;
+		}
+		imsg_free(&imsg);
+	}
+
+	/* same refusal the parent applies before spawning a store child */
+	if (init.uid == 0 || init.gid == 0)
+		fatalx("session %u: refusing to run as uid %u gid %u",
+		    session_id, (unsigned int)init.uid,
+		    (unsigned int)init.gid);
+
+	/* the chroot is a second line behind the pledge */
+	if (chroot("/var/empty") == -1)
+		fatal("chroot /var/empty");
+	if (chdir("/") == -1)
+		fatal("chdir /");
+
+	/* the account's own uid, so a parse bug is confined to that account */
+	gid = init.gid;
+	if (setgroups(1, &gid) == -1 ||
+	    setresgid(init.gid, init.gid, init.gid) == -1 ||
+	    setresuid(init.uid, init.uid, init.uid) == -1)
+		fatal("session %u: cannot drop privileges to uid %u gid %u",
+		    session_id, (unsigned int)init.uid,
+		    (unsigned int)init.gid);
+
+	setproctitle("parser uid %u", (unsigned int)init.uid);
+
+	event_init();
+	imsgev_init(&iev_store, store_fd, parser_dispatch_store, NULL);
+
+	/* no "rpath": the only bytes arrive on passed descriptors */
+#ifdef __OpenBSD__
+	if (pledge("stdio recvfd", NULL) == -1)
+		fatal("pledge");
+#endif
+
+	event_dispatch();
+	fatalx("parser: exited event loop");
+}
+
+static void
+parser_handle_request(struct imsgev *iev, struct imsg *imsg, uint32_t type)
+{
+	struct imsg_parser_req	 req;
+	struct imsg_parser_rep	 rep;
+	char			 reply[sizeof(rep) + BODYSTRUCTURE_MAX];
+	char			*buf = NULL;
+	uint32_t		 len = 0, cap = 0;
+	size_t			 replylen = sizeof(rep);
+	int			 mfd, built = 0;
+
+	mfd = imsg_get_fd(imsg);
+	memset(&rep, 0, sizeof(rep));
+
+	if (imsg_get_buf(imsg, &req, sizeof(req)) == -1)
+		log_warnx("session %u: bad parser request %u", session_id,
+		    type);
+	else if (mfd == -1)
+		log_warnx("session %u: parser request %u carried no "
+		    "descriptor", session_id, type);
+	else {
+		/* the store composed it, but a copy must still terminate */
+		req.basename[sizeof(req.basename) - 1] = '\0';
+		req.fields[sizeof(req.fields) - 1] = '\0';
+		switch (type) {
+		case IMSG_PARSER_ENVELOPE:
+			cap = ENVELOPE_MAX;
+			built = build_envelope(mfd, req.basename, &buf,
+			    &len) == 0;
+			break;
+		case IMSG_PARSER_BODYSTRUCTURE:
+			cap = BODYSTRUCTURE_MAX;
+			built = build_bodystructure(mfd, req.basename, &buf,
+			    &len) == 0;
+			break;
+		case IMSG_PARSER_HEADER_FIELDS:
+			cap = FETCH_HEADER_MAX;
+			built = read_message_header_fields(mfd, req.basename,
+			    req.fields, req.fields_not, &buf, &len) == 0;
+			break;
+		case IMSG_PARSER_SEARCH:
+			cap = SEARCH_PROGRAM_MAX_NODES;
+			built = parser_search(imsg, &req, mfd, &buf,
+			    &len) == 0;
+			break;
+		case IMSG_PARSER_PART:
+			/* an extent, not the octets: a body never crosses */
+			if (req.pathlen >= 1 && req.pathlen <= MIME_MAX_DEPTH &&
+			    extract_mime_part(mfd, req.basename, req.path,
+			    req.pathlen, &rep.part_off, &rep.part_len) == 0)
+				rep.found = 1;
+			break;
+		}
+		if (built && len > 0 && len <= cap) {
+			rep.found = 1;
+			rep.len = len;
+		}
+	}
+	if (mfd != -1)
+		close(mfd);
+
+	rep.retiring = parser_spent();
+	memcpy(reply, &rep, sizeof(rep));
+	/* a found PART carries no bytes, and buf is NULL then */
+	if (rep.found && rep.len > 0) {
+		memcpy(reply + sizeof(rep), buf, rep.len);
+		replylen += rep.len;
+	}
+	free(buf);
+
+	if (imsg_compose(&iev->ibuf, type, imsg_get_id(imsg), 0, -1, reply,
+	    replylen) == -1)
+		fatal("imsg_compose parser reply");
+
+	if (rep.retiring) {
+		if (imsgbuf_flush(&iev->ibuf) == -1)
+			fatal("imsgbuf_flush parser reply");
+		log_debug("session %u: parser retiring after %d seconds of "
+		    "CPU", session_id, PARSER_CPU_RETIRE_SEC);
+		exit(0);
+	}
+}
+
+static int
+parser_search(struct imsg *imsg, const struct imsg_parser_req *req, int mfd,
+    char **buf, uint32_t *len)
+{
+	struct imsg_parser_leaf	 leaves[SEARCH_PROGRAM_MAX_NODES];
+	char			 pool[SEARCH_OPERANDS_MAX];
+	const struct imsg_parser_leaf	*l;
+	uint32_t		 i;
+
+	if (req->nleaves == 0 || req->nleaves > SEARCH_PROGRAM_MAX_NODES ||
+	    req->poollen > sizeof(pool) || imsg_get_len(imsg) !=
+	    req->nleaves * sizeof(leaves[0]) + req->poollen ||
+	    imsg_get_buf(imsg, leaves, req->nleaves * sizeof(leaves[0])) ==
+	    -1 || imsg_get_buf(imsg, pool, req->poollen) == -1) {
+		log_warnx("session %u: bad SEARCH request", session_id);
+		return (-1);
+	}
+	for (i = 0; i < req->nleaves; i++) {
+		l = &leaves[i];
+		if (!search_op_content(l->op) || l->str_off > req->poollen ||
+		    l->str_len > req->poollen - l->str_off ||
+		    l->name_off > req->poollen ||
+		    l->name_len > req->poollen - l->name_off) {
+			log_warnx("session %u: bad SEARCH key %u", session_id,
+			    i);
+			return (-1);
+		}
+	}
+	return (search_match(mfd, req->basename, leaves, req->nleaves, pool,
+	    buf, len));
+}
+
+/* getrusage(2) is allowed under "stdio" */
+static int
+parser_spent(void)
+{
+	struct rusage	 ru;
+	struct timeval	 tv;
+
+	if (getrusage(RUSAGE_SELF, &ru) == -1)
+		return (1);	/* cannot tell: go, a fresh one is cheap */
+	timeradd(&ru.ru_utime, &ru.ru_stime, &tv);
+	return (tv.tv_sec >= PARSER_CPU_RETIRE_SEC);
+}
+
+static void
+parser_dispatch_store(int fd, short event, void *arg)
+{
+	struct imsgev	*iev = arg;
+	struct imsg	 imsg;
+	ssize_t		 n;
+
+	(void)fd;
+
+	if (event & EV_WRITE) {
+		if (imsgbuf_write(&iev->ibuf) == -1)
+			fatal("imsgbuf_write");
+	}
+	if (event & EV_READ) {
+		if ((n = imsgbuf_read(&iev->ibuf)) == -1)
+			fatal("imsgbuf_read");
+		if (n == 0) {
+			log_debug("session %u: store closed channel, exiting",
+			    session_id);
+			exit(0);
+		}
+	}
+
+	for (;;) {
+		if ((n = imsgbuf_get(&iev->ibuf, &imsg)) == -1)
+			fatal("imsgbuf_get");
+		if (n == 0)
+			break;
+
+		switch (imsg_get_type(&imsg)) {
+		case IMSG_PARSER_ENVELOPE:
+		case IMSG_PARSER_BODYSTRUCTURE:
+		case IMSG_PARSER_HEADER_FIELDS:
+		case IMSG_PARSER_PART:
+		case IMSG_PARSER_SEARCH:
+			parser_handle_request(iev, &imsg,
+			    imsg_get_type(&imsg));
+			break;
+		default:
+			log_debug("session %u: unhandled request %d",
+			    session_id, imsg_get_type(&imsg));
+			break;
+		}
+		imsg_free(&imsg);
+	}
+
+	imsgev_rearm_read(iev);
+}
blob - 8bed78212415bc8cc45642b8d4ddc3428619f9f9
blob + 1ae52cd9d5eeda56251857220f67e5fde5d6bb59
--- src/search_cmd.c
+++ src/search_cmd.c
@@ -16,7 +16,6 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* search_cmd.c: SEARCH key parsing, dispatch, async completion path */
 
 #include <sys/types.h>
 #include <sys/queue.h>
@@ -69,7 +68,6 @@ parse_search_date(const char *s, int64_t *out)
 	tm.tm_mday = day;
 	tm.tm_mon = i;
 	tm.tm_year = year - 1900;
-	/* tm_hour/tm_min/tm_sec already 0 from memset, UTC midnight */
 
 	if ((t = timegm(&tm)) == (time_t)-1)
 		return (-1);
@@ -78,14 +76,16 @@ parse_search_date(const char *s, int64_t *out)
 	return (0);
 }
 
-/* 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 */
-	/* set when a MODSEQ key is pushed; see cmd_search() */
+	int			depth;
 	int			uses_modseq;
+	/* octets of every string operand, counted past the cap too */
+	size_t			operand_len;
+	int			uses_content;
+	char			pool[SEARCH_OPERANDS_MAX];
 };
 
 
@@ -101,13 +101,7 @@ 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.
- */
+/* RFC 9051 SS9 sequence-set, pushed as ORed ranges */
 static int
 parse_search_seqset(char **pp, struct search_parse_ctx *ctx, int op,
     const char **errmsg)
@@ -164,16 +158,13 @@ parse_search_seqset(char **pp, struct search_parse_ctx
 	return (0);
 }
 
-/* RFC 9051 SS6.4.4 search-key grammar, recursive-descent: ok=0 BAD=-1 NO=-2 */
+/* RFC 9051 SS6.4.4 search-key: 0 ok, -1 BAD, -2 NO */
 int
 parse_search_key(char **pp, struct search_parse_ctx *ctx, const char **errmsg)
 {
 	int	rc;
 
-	/*
-	 * every nesting level (parens, NOT, OR) re-enters here; bounds deep
-	 * nesting
-	 */
+	/* bounds nesting */
 	if (++ctx->depth > SEARCH_MAX_DEPTH) {
 		ctx->depth--;
 		*errmsg = "search criteria nested too deeply";
@@ -185,14 +176,6 @@ parse_search_key(char **pp, struct search_parse_ctx *c
 }
 
 
-/*
- * Skips leading spaces, then reads one space/')'-terminated token
- * from *pp into buf (bounded, NUL-terminated); shared "empty or
- * too long" bounds check for KEYWORD/UNKEYWORD, BEFORE/ON/SINCE's
- * unquoted date, LARGER/SMALLER, and MODSEQ's value below. Returns
- * 0 on success, -1 (with *errmsg set to errtext) if the token is
- * empty or doesn't fit in buf.
- */
 static int
 read_search_token(char **pp, char *buf, size_t bufsize, const char *errtext,
     const char **errmsg)
@@ -215,6 +198,106 @@ read_search_token(char **pp, char *buf, size_t bufsize
 	return (0);
 }
 
+/* RFC 9051 SS9 date, as the UTC midnight that begins it */
+static int
+read_search_date(char **pp, int64_t *out, const char **errmsg)
+{
+	char	*p = *pp;
+	char	 datebuf[32];
+
+	while (*p == ' ')
+		p++;
+	if (*p == '"') {
+		const char	*start = p + 1;
+		char		*end = strchr(start, '"');
+		size_t		 len;
+
+		if (end == NULL) {
+			*errmsg = "unterminated date string";
+			return (-1);
+		}
+		len = (size_t)(end - start);
+		if (len >= sizeof(datebuf)) {
+			*errmsg = "malformed date";
+			return (-1);
+		}
+		memcpy(datebuf, start, len);
+		datebuf[len] = '\0';
+		p = end + 1;
+	} else {
+		if (read_search_token(&p, datebuf, sizeof(datebuf),
+		    "malformed date", errmsg) == -1)
+			return (-1);
+	}
+
+	if (parse_search_date(datebuf, out) == -1) {
+		*errmsg = "malformed date";
+		return (-1);
+	}
+	*pp = p;
+	return (0);
+}
+
+static void
+search_pool_add(struct search_parse_ctx *ctx, char c)
+{
+	if (ctx->operand_len < sizeof(ctx->pool))
+		ctx->pool[ctx->operand_len] = c;
+	ctx->operand_len++;
+}
+
+/* RFC 9051 SS9 astring operand */
+static int
+read_search_astring(char **pp, struct search_parse_ctx *ctx,
+    uint32_t *offp, uint32_t *lenp, const char **errmsg)
+{
+	char	*p = *pp;
+	size_t	 start = ctx->operand_len;
+
+	while (*p == ' ')
+		p++;
+	if (*p == '{') {
+		*errmsg = "literals are not supported in SEARCH, send a "
+		    "quoted string";
+		return (-1);
+	}
+	if (*p == '"') {
+		for (p++; *p != '"'; p++) {
+			if (*p == '\0') {
+				*errmsg = "unterminated quoted string";
+				return (-1);
+			}
+			/* RFC 9051 SS9: quoted-specials are the only escapes */
+			if (*p == '\\' && *++p != '"' && *p != '\\') {
+				*errmsg = "malformed quoted string";
+				return (-1);
+			}
+			search_pool_add(ctx, *p);
+		}
+		p++;
+	} else {
+		/* RFC 9051 SS9 ASTRING-CHAR */
+		for (; *p != '\0' && *p != ' ' && *p != ')'; p++) {
+			if ((unsigned char)*p <= 0x1f ||
+			    (unsigned char)*p >= 0x7f ||
+			    strchr("({%*\"\\", *p) != NULL) {
+				*errmsg = "malformed search string";
+				return (-1);
+			}
+			search_pool_add(ctx, *p);
+		}
+		if (ctx->operand_len == start) {
+			*errmsg = "missing search string";
+			return (-1);
+		}
+	}
+	/* the offsets mean something only once the total is under the cap */
+	*offp = (uint32_t)start;
+	*lenp = (uint32_t)(ctx->operand_len - start);
+	*pp = p;
+	return (0);
+}
+
 int
 parse_search_key_inner(char **pp, struct search_parse_ctx *ctx,
     const char **errmsg)
@@ -336,7 +419,6 @@ parse_search_key_inner(char **pp, struct search_parse_
 	if (strcasecmp(word, "BEFORE") == 0 || strcasecmp(word, "ON") == 0 ||
 	    strcasecmp(word, "SINCE") == 0) {
 		struct search_node	node;
-		char			datebuf[32];
 
 		memset(&node, 0, sizeof(node));
 		if (strcasecmp(word, "BEFORE") == 0)
@@ -346,35 +428,8 @@ parse_search_key_inner(char **pp, struct search_parse_
 		else
 			node.op = SEARCH_OP_SINCE;
 
-		while (*p == ' ')
-			p++;
-		if (*p == '"') {
-			const char	*start = p + 1;
-			char		*end = strchr(start, '"');
-			size_t		 len;
-
-			if (end == NULL) {
-				*errmsg = "unterminated date string";
-				return (-1);
-			}
-			len = (size_t)(end - start);
-			if (len >= sizeof(datebuf)) {
-				*errmsg = "malformed date";
-				return (-1);
-			}
-			memcpy(datebuf, start, len);
-			datebuf[len] = '\0';
-			p = end + 1;
-		} else {
-			if (read_search_token(&p, datebuf, sizeof(datebuf),
-			    "malformed date", errmsg) == -1)
-				return (-1);
-		}
-
-		if (parse_search_date(datebuf, &node.num) == -1) {
-			*errmsg = "malformed date";
+		if (read_search_date(&p, &node.num, errmsg) == -1)
 			return (-1);
-		}
 
 		if (search_push(ctx, &node, errmsg) == -1)
 			return (-1);
@@ -403,12 +458,7 @@ 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 signed: refuse above INT64_MAX */
 		if (v > (unsigned long long)INT64_MAX) {
 			*errmsg = "octet count out of range";
 			return (-1);
@@ -443,10 +493,7 @@ parse_search_key_inner(char **pp, struct search_parse_
 		while (*p == ' ')
 			p++;
 
-		/*
-		 * RFC 7162 SS3.1.5: optional entry-name/type-req
-		 * (unimplemented) skipped
-		 */
+		/* RFC 7162 SS3.1.5 entry-name and type-req are skipped */
 		if (*p != '\0' && !isdigit((unsigned char)*p)) {
 			if (*p == '"') {
 				char	*end = strchr(p + 1, '"');
@@ -541,29 +588,82 @@ parse_search_key_inner(char **pp, struct search_parse_
 		return (0);
 	}
 
+	/* RFC 9051 SS6.4.4 content keys, answered from the message */
+	if (strcasecmp(word, "SENTBEFORE") == 0 ||
+	    strcasecmp(word, "SENTON") == 0 ||
+	    strcasecmp(word, "SENTSINCE") == 0) {
+		struct search_node	node;
+
+		memset(&node, 0, sizeof(node));
+		if (strcasecmp(word, "SENTBEFORE") == 0)
+			node.op = SEARCH_OP_SENTBEFORE;
+		else if (strcasecmp(word, "SENTON") == 0)
+			node.op = SEARCH_OP_SENTON;
+		else
+			node.op = SEARCH_OP_SENTSINCE;
+
+		if (read_search_date(&p, &node.num, errmsg) == -1)
+			return (-1);
+
+		if (search_push(ctx, &node, errmsg) == -1)
+			return (-1);
+		*pp = p;
+		return (0);
+	}
+
 	{
-		static const char *content_keys[] = {
-			"BCC", "BODY", "CC", "FROM", "HEADER", "SENTBEFORE",
-			"SENTON", "SENTSINCE", "SUBJECT", "TEXT", "TO",
+		static const struct {
+			const char	*name;
+			int		 op;
+		} string_keys[] = {
+			{ "BCC",	SEARCH_OP_BCC },
+			{ "CC",		SEARCH_OP_CC },
+			{ "FROM",	SEARCH_OP_FROM },
+			{ "HEADER",	SEARCH_OP_HEADER },
+			{ "SUBJECT",	SEARCH_OP_SUBJECT },
+			{ "TO",		SEARCH_OP_TO },
 		};
-		size_t	i;
+		struct search_node	node;
+		size_t			i;
 
-		for (i = 0; i < sizeof(content_keys) / sizeof(content_keys[0]);
+		for (i = 0; i < sizeof(string_keys) / sizeof(string_keys[0]);
 		    i++) {
-			if (strcasecmp(word, content_keys[i]) == 0) {
-				*errmsg = "search keys that require message "
-				    "content/header access are not "
-				    "supported in this pass";
-				return (-2);
-			}
+			if (strcasecmp(word, string_keys[i].name) != 0)
+				continue;
+			memset(&node, 0, sizeof(node));
+			node.op = string_keys[i].op;
+			/* HEADER's field-name is an astring too */
+			if (node.op == SEARCH_OP_HEADER &&
+			    read_search_astring(&p, ctx, &node.name_off,
+			    &node.name_len, errmsg) == -1)
+				return (-1);
+			if (read_search_astring(&p, ctx, &node.str_off,
+			    &node.str_len, errmsg) == -1)
+				return (-1);
+
+			if (search_push(ctx, &node, errmsg) == -1)
+				return (-1);
+			*pp = p;
+			return (0);
 		}
 	}
 
+	/* BODY and TEXT: parsed, but not yet answered */
+	if (strcasecmp(word, "BODY") == 0 || strcasecmp(word, "TEXT") == 0) {
+		uint32_t	off, len;
+
+		if (read_search_astring(&p, ctx, &off, &len, errmsg) == -1)
+			return (-1);
+		ctx->uses_content = 1;
+		*pp = p;
+		return (0);
+	}
+
 	*errmsg = "unknown search key";
 	return (-1);
 }
 
-/* search-key *(SP search-key), ANDed left-right; in_parens stops at ')' */
+/* RFC 9051 SS6.4.4 search-key list, ANDed */
 int
 parse_search_key_list(char **pp, struct search_parse_ctx *ctx,
     const char **errmsg, int in_parens)
@@ -614,18 +714,10 @@ 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.
- */
-int
-search_oracle_parse(char *args, struct search_node *nodes_out,
-    uint32_t *nnodes_out, int *uses_modseq_out, char *errmsg_out,
-    size_t errmsg_outsize)
+static int
+search_program_parse(char *args, struct search_node *nodes_out,
+    uint32_t *nnodes_out, int *uses_modseq_out, char *pool_out,
+    uint32_t *poollen_out, char *errmsg_out, size_t errmsg_outsize)
 {
 	struct search_parse_ctx	 ctx;
 	char			*p = args;
@@ -634,7 +726,15 @@ search_oracle_parse(char *args, struct search_node *no
 
 	memset(&ctx, 0, sizeof(ctx));
 	rc = parse_search_key_list(&p, &ctx, &errmsg, 0);
-	if (rc == 0 && ctx.n == 0) {
+	/* RFC 9051 SS7.1: LIMIT, an implementation limit was reached */
+	if (rc == 0 && ctx.operand_len > SEARCH_OPERANDS_MAX) {
+		errmsg = "[LIMIT] search strings exceed 4096 octets in all";
+		rc = -2;
+	} else if (rc == 0 && ctx.uses_content) {
+		errmsg = "search keys that require message content/header "
+		    "access are not supported in this pass";
+		rc = -2;
+	} else if (rc == 0 && ctx.n == 0) {
 		errmsg = "SEARCH requires search criteria";
 		rc = -1;
 	}
@@ -647,17 +747,19 @@ search_oracle_parse(char *args, struct search_node *no
 	memcpy(nodes_out, ctx.nodes, ctx.n * sizeof(*nodes_out));
 	*nnodes_out = ctx.n;
 	*uses_modseq_out = ctx.uses_modseq;
+	memcpy(pool_out, ctx.pool, ctx.operand_len);
+	*poollen_out = (uint32_t)ctx.operand_len;
 	return (0);
 }
 
-/* RFC 9051 SS6.4.4 search-return-opts; SAVE recognized but rejected -2/NO */
+/* RFC 9051 SS6.4.4 search-return-opts; SAVE is refused */
 int
 parse_search_return_opts(char **pp, uint32_t *opts_out, const char **errmsg)
 {
 	char	*p = *pp;
 
 	*opts_out = 0;
-	p++;	/* skip the '(' the caller already confirmed is there */
+	p++;
 
 	while (*p == ' ')
 		p++;
@@ -712,14 +814,16 @@ parse_search_return_opts(char **pp, uint32_t *opts_out
 	}
 }
 
-/* RFC 9051 SS6.4.4 SEARCH; CHARSET US-ASCII/UTF-8 only, else unsupported */
+static void	 search_dispatch_finish(struct session *,
+		    const struct search_parse_result *, struct search_node *);
+
+/* RFC 9051 SS6.4.4 SEARCH; US-ASCII and UTF-8 only */
 int
 cmd_search(struct session *s, const char *tag, char *args)
 {
 	return search_dispatch(s, tag, args, 0);
 }
 
-/* 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)
 {
@@ -761,7 +865,7 @@ search_dispatch(struct session *s, const char *tag, ch
 	}
 
 	if (return_opts == 0)
-		return_opts = SEARCH_RETURN_ALL;	/* SS6.4.4's default */
+		return_opts = SEARCH_RETURN_ALL;	/* RFC 9051 SS6.4.4 */
 
 	if (strncasecmp(p, "CHARSET", 7) == 0 &&
 	    (p[7] == ' ' || p[7] == '\0')) {
@@ -785,10 +889,7 @@ 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 charset
-			 * list; over SS6.4.4.4
-			 */
+			/* RFC 9051 SS9 puts the charset list in parens */
 			session_reply(s, tag, "NO",
 			    "[BADCHARSET (US-ASCII UTF-8)] unsupported "
 			    "CHARSET");
@@ -800,33 +901,17 @@ search_dispatch(struct session *s, const char *tag, ch
 	}
 
 	if (s->store_iev == NULL) {
-		/* same invariant check as cmd_fetch()/cmd_store_cmd() */
 		log_warnx("session %u: %s with no store channel wired",
 		    s->id, by_uid ? "UID SEARCH" : "SEARCH");
 		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
 		return (1);
 	}
 
-	/*
-	 * 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) {
+	if (strlen(p) >= SEARCH_ARGS_MAX) {
 		session_reply(s, tag, "BAD", "SEARCH criteria too long");
 		return (1);
 	}
 
-	if (iev_search.ibuf.fd == -1) {
-		session_reply(s, tag, "NO",
-		    "[UNAVAILABLE] search temporarily unavailable");
-		return (1);
-	}
-
-	/* defensive cleanup of a previous SEARCH; should not find any */
 	free(s->search_matches);
 	s->search_matches = NULL;
 	s->search_nmatches = 0;
@@ -840,38 +925,24 @@ search_dispatch(struct session *s, const char *tag, ch
 		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
 		return (1);
 	}
-	s->state = SESSION_SEARCH_PARSING;
+	{
+		struct search_parse_result	 res;
+		struct search_node	 nodes[SEARCH_PROGRAM_MAX_NODES];
 
-	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.
-		 */
-		log_warn("session %u: imsg_compose IMSG_SEARCH_PARSE_REQUEST",
-		    s->id);
-		session_reply(s, tag, "NO",
-		    "[UNAVAILABLE] search temporarily unavailable");
-		s->state = SESSION_SELECTED;
-		return (1);
+		memset(&res, 0, sizeof(res));
+		res.rc = search_program_parse(p, nodes, &res.nnodes,
+		    &res.uses_modseq, res.pool, &res.poollen, res.errmsg,
+		    sizeof(res.errmsg));
+		search_dispatch_finish(s, &res,
+		    res.nnodes > 0 ? nodes : NULL);
 	}
 
 	return (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.
- */
-void
+static void
 search_dispatch_finish(struct session *s,
-    const struct imsg_search_parse_result *res, struct search_node *nodes)
+    const struct search_parse_result *res, struct search_node *nodes)
 {
 	if (res->rc == -1) {
 		session_reply(s, s->pending_tag, "BAD", res->errmsg);
@@ -887,10 +958,7 @@ search_dispatch_finish(struct session *s,
 	s->search_used_modseq = res->uses_modseq;
 	s->search_max_modseq = 0;
 
-	/*
-	 * RFC 7162 SS3.1: a SEARCH with MODSEQ item is a CONDSTORE enabling
-	 * command
-	 */
+	/* RFC 7162 SS3.1: MODSEQ enables CONDSTORE */
 	if (res->uses_modseq)
 		session_condstore_enable(s);
 
@@ -901,6 +969,8 @@ search_dispatch_finish(struct session *s,
 
 		memset(&req, 0, sizeof(req));
 		req.nnodes = res->nnodes;
+		req.poollen = res->poollen;
+		memcpy(req.pool, res->pool, res->poollen);
 
 		if (!send_mbox_request(s, IMSG_MBOX_SEARCH, "SEARCH",
 		    "IMSG_MBOX_SEARCH", &req, sizeof(req), nodes, res->nnodes,
@@ -936,27 +1006,19 @@ session_handle_mbox_search_match(struct session *s,
 		s->search_matches_cap = newcap;
 	}
 
-	/*
-	 * RFC 9051 SS6.4.9: UID SEARCH reports UIDs, not seqnos, in ESEARCH
-	 * data
-	 */
+	/* RFC 9051 SS6.4.9: UID SEARCH reports UIDs */
 	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 used */
+	/* RFC 7162 SS3.1.6 */
 	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.
- */
 #define SEARCH_RESP_PREFIX_MAX	256
 #define SEARCH_ALL_PER_MATCH	11	/* "4294967295" + one separator */
 
-/* builds/sends ESEARCH response once store's SEARCH pass completes (SS6.4.4) */
+/* RFC 9051 SS7.3.4 ESEARCH response */
 void
 session_finish_search(struct session *s, struct imsg_mbox_result *res)
 {
@@ -967,11 +1029,6 @@ 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.
-	 */
 	log_debug("session %u: SEARCH done, status=%s, %u match(es)", s->id,
 	    res->error == MBOX_OP_OK ? "OK" : "ERROR", res->count);
 
@@ -982,13 +1039,6 @@ session_finish_search(struct session *s, struct imsg_m
 	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.
-	 */
 	bufsize = SEARCH_RESP_PREFIX_MAX +
 	    (size_t)s->search_nmatches * SEARCH_ALL_PER_MATCH + 1;
 	if ((buf = malloc(bufsize)) == NULL) {
@@ -998,12 +1048,6 @@ 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.
-		 */
 		log_warnx("session %u: SEARCH response prefix did not fit",
 		    s->id);
 		goto fail;
@@ -1047,21 +1091,13 @@ 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: MODSEQ in ESEARCH */
 	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.
-	 */
 	if (len >= bufsize - 1)
 		len = bufsize - 2;
 
@@ -1070,10 +1106,7 @@ 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
-	 */
+	/* as RFC 9051 SS6.4.9's "UID <cmd> completed" */
 	session_reply(s, s->pending_tag, "OK",
 	    s->cmd_by_uid ? "UID SEARCH completed" : "SEARCH completed");
 	goto cleanup;
blob - a67fc4d0d6d609010a94600dcbce2e6c785531b5 (mode 644)
blob + /dev/null
--- src/search_oracle.c
+++ /dev/null
@@ -1,299 +0,0 @@
-/*	$OpenIMAPD$	*/
-
-/*
- * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
- *
- * Permission to use, copy, modify, and distribute this software for any
- * purpose with or without fee is hereby granted, provided that the above
- * copyright notice and this permission notice appear in all copies.
- *
- * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
- * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
- * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
- * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
- * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
- * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
- * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
- */
-
-/*
- * search_oracle.c: 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>
-
-#include <event.h>
-#include <grp.h>
-#include <imsg.h>
-#include <pwd.h>
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include <unistd.h>
-
-#include "imapd.h"
-#include "log.h"
-
-static struct imsgev	 iev_listener;
-/* 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 *);
-static void	 search_oracle_fail(struct imsgev *, const char *);
-
-__dead void
-search_oracle_main(void)
-{
-	struct imsgbuf	 ibuf3;
-	struct imsg	 imsg;
-	struct passwd	*pw;
-	int		 peer_fd;
-	ssize_t		 n;
-	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
-	 */
-	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.
-	 */
-	for (;;) {
-		if ((n = imsgbuf_get(&ibuf3, &imsg)) == -1)
-			fatal("imsgbuf_get");
-		if (n != 0)
-			break;
-		if ((n = imsgbuf_read(&ibuf3)) == -1)
-			fatal("imsgbuf_read");
-		if (n == 0)
-			fatalx("search-oracle: parent closed channel before "
-			    "IMSG_SETUP_SEARCH_PEER");
-	}
-	if (imsg_get_type(&imsg) != IMSG_SETUP_SEARCH_PEER)
-		fatalx("search-oracle: expected IMSG_SETUP_SEARCH_PEER, got "
-		    "%d", imsg_get_type(&imsg));
-	peer_fd = imsg_get_fd(&imsg);
-	/* 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.
-	 */
-	if ((pw = getpwnam("_imapsearch")) == NULL)
-		fatalx("getpwnam _imapsearch: no such user "
-		    "(expected, not yet provisioned by an install script)");
-
-	if (chroot("/var/empty") == -1)
-		fatal("chroot /var/empty");
-	if (chdir("/") == -1)
-		fatal("chdir /");
-
-	if (setgroups(1, &pw->pw_gid) == -1 ||
-	    setresgid(pw->pw_gid, pw->pw_gid, pw->pw_gid) == -1 ||
-	    setresuid(pw->pw_uid, pw->pw_uid, pw->pw_uid) == -1)
-		fatal("cannot drop privileges to _imapsearch");
-
-	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,
-	    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.
-	 */
-#ifdef __OpenBSD__
-	if (pledge("stdio", NULL) == -1)
-		fatal("pledge");
-#endif
-
-	event_dispatch();
-	fatalx("search-oracle: exited event loop");
-}
-
-/* EV_WRITE must be handled: imsg_compose() queues, imsgbuf_write() sends it */
-static void
-search_oracle_dispatch(int fd, short event, void *arg)
-{
-	struct imsgev	*iev = arg;
-	struct imsg	 imsg;
-	ssize_t		 n;
-
-	if (event & EV_WRITE) {
-		if (imsgbuf_write(&iev->ibuf) == -1)
-			fatal("imsgbuf_write");
-	}
-
-	if (event & EV_READ) {
-		if ((n = imsgbuf_read(&iev->ibuf)) == -1)
-			fatal("imsgbuf_read");
-		if (n == 0) {
-			/*
-			 * 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);
-		}
-	}
-
-	for (;;) {
-		if ((n = imsgbuf_get(&iev->ibuf, &imsg)) == -1)
-			fatal("imsgbuf_get");
-		if (n == 0)
-			break;
-
-		switch (imsg_get_type(&imsg)) {
-		case IMSG_SEARCH_PARSE_REQUEST: {
-			char args[SEARCH_ORACLE_ARGS_MAX + 1];
-			struct search_node nodes[SEARCH_PROGRAM_MAX_NODES];
-			struct imsg_search_parse_result res;
-			size_t				 len, bodylen;
-			void				*combined;
-
-			len = imsg_get_len(&imsg);
-			if (len > (size_t)SEARCH_ORACLE_ARGS_MAX) {
-				log_warnx("IMSG_SEARCH_PARSE_REQUEST too "
-				    "large (%zu > %u)", len,
-				    (unsigned int)SEARCH_ORACLE_ARGS_MAX);
-				search_oracle_fail(iev,
-				    "SEARCH criteria too long");
-				break;
-			}
-			if (imsg_get_data(&imsg, args, len) == -1) {
-				log_warnx("bad IMSG_SEARCH_PARSE_REQUEST");
-				search_oracle_fail(iev,
-				    "malformed SEARCH request");
-				break;
-			}
-			/*
-			 * imsg_get_data() guarantees size, not NUL term, same
-			 * as auth.c's imsgs
-			 */
-			args[len] = '\0';
-
-			memset(&res, 0, sizeof(res));
-			res.rc = search_oracle_parse(args, nodes, &res.nnodes,
-			    &res.uses_modseq, res.errmsg, sizeof(res.errmsg));
-
-			bodylen = (res.rc == 0) ?
-			    (size_t)res.nnodes * sizeof(nodes[0]) : 0;
-			if ((combined = malloc(sizeof(res) + bodylen)) ==
-			    NULL) {
-				log_warn("malloc IMSG_SEARCH_PARSE_RESULT "
-				    "buffer");
-				search_oracle_fail(iev,
-				    "SEARCH temporarily unavailable");
-				break;
-			}
-			memcpy(combined, &res, sizeof(res));
-			if (bodylen > 0)
-				memcpy((char *)combined + sizeof(res), nodes,
-				    bodylen);
-
-			if (imsg_compose(&iev->ibuf, IMSG_SEARCH_PARSE_RESULT,
-			    0, 0, -1, combined, sizeof(res) + bodylen) == -1) {
-				log_warn("imsg_compose "
-				    "IMSG_SEARCH_PARSE_RESULT");
-				/*
-				 * the small reply may still fit where the full
-				 * one did not
-				 */
-				search_oracle_fail(iev,
-				    "SEARCH temporarily unavailable");
-			}
-			free(combined);
-			break;
-		}
-		default:
-			log_debug("search_oracle_dispatch: unhandled %d",
-			    imsg_get_type(&imsg));
-			break;
-		}
-		imsg_free(&imsg);
-	}
-	imsgev_rearm_read(iev);
-	(void)fd;
-}
-
-/*
- * Answers a parse request this process 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)
-{
-	struct imsg_search_parse_result	 res;
-
-	memset(&res, 0, sizeof(res));
-	res.rc = -2;
-	(void)strlcpy(res.errmsg, errmsg, sizeof(res.errmsg));
-
-	if (imsg_compose(&iev->ibuf, IMSG_SEARCH_PARSE_RESULT, 0, 0, -1,
-	    &res, sizeof(res)) == -1)
-		log_warn("imsg_compose IMSG_SEARCH_PARSE_RESULT (failure)");
-}
-
-/*
- * parent never sends search-oracle anything post-boot, 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)
-{
-	struct imsgev	*iev = arg;
-	struct imsg	 imsg;
-	ssize_t		 n;
-
-	if (event & EV_WRITE) {
-		if (imsgbuf_write(&iev->ibuf) == -1)
-			fatal("imsgbuf_write");
-	}
-
-	if (event & EV_READ) {
-		if ((n = imsgbuf_read(&iev->ibuf)) == -1)
-			fatal("imsgbuf_read");
-		if (n == 0) {
-			log_warnx("parent closed channel");
-			event_del(&iev->ev);
-			return;
-		}
-	}
-
-	for (;;) {
-		if ((n = imsgbuf_get(&iev->ibuf, &imsg)) == -1)
-			fatal("imsgbuf_get");
-		if (n == 0)
-			break;
-
-		log_debug("search_oracle_dispatch_parent: unhandled %d",
-		    imsg_get_type(&imsg));
-		imsg_free(&imsg);
-	}
-	imsgev_rearm_read(iev);
-	(void)fd;
-}
blob - 913b498731d9da804ddf2018bad6702f97c313af
blob + 62c0f546829cd6a07af029b8851ddef5fb6c0385
--- src/store.c
+++ src/store.c
@@ -16,11 +16,11 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* store.c: privilege-dropped mailbox-store process; handles IMSG_MBOX_*. */
 
 #include <sys/types.h>
 #include <sys/file.h>
 #include <sys/stat.h>
+#include <sys/uio.h>
 
 #include <dirent.h>
 #include <errno.h>
@@ -29,6 +29,7 @@
 #include <grp.h>
 #include <imsg.h>
 #include <limits.h>
+#include <poll.h>
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
@@ -40,25 +41,59 @@
 #include "mboxname.h"
 #include "store_internal.h"
 
-static struct imsgev	 iev_listener;
+static TAILQ_HEAD(, store_session)	 sessions =
+				    TAILQ_HEAD_INITIALIZER(sessions);
 
-/*
- * Defined below store_main(); declared here so boot can apply the
- * same NUL-termination rule to IMSG_STORE_INIT as every runtime
- * message.
- */
+static struct imsgev	 parent_iev;
+
+static int		 parent_gone;
+
+static void	 store_parent_dispatch(int, short, void *);
+static void	 store_attach(uint32_t, int);
+static void	 store_detach(struct store_session *);
+static void	 store_end(struct store_session *);
+static __dead void	 store_exit(void);
+
+/* a bare imsgbuf: every exchange with the parser is synchronous */
+static struct imsgbuf	 parser_ibuf;
+static int		 parser_up;	/* parser_ibuf holds a channel */
+static int		 parser_wanted;	/* asked the parent, no answer yet */
+static struct event	 parser_idle_ev;
+static struct event	 parser_wait_ev;
+
+/* kept here, not in the parser, so a parser cannot clear a strike */
+#define PARSER_STRIKE_SLOTS	32
+static struct {
+	char	 basename[sizeof(((struct imsg_parser_req *)NULL)->basename)];
+	int	 count;
+} parser_strikes[PARSER_STRIKE_SLOTS];
+static size_t		 parser_strike_next;
+
+static int	 parser_alive(void);
+static void	 parser_drop(void);
+static void	 parser_fail(const char *);
+static void	 parser_install(int);
+static void	 parser_give_up(void);
+static void	 parser_walks(int);
+static void	 parser_idle(int, short, void *);
+static void	 parser_wait_expired(int, short, void *);
+static int	 parser_struck_out(const char *);
+
+static int	 parser_reply_safe(const char *, size_t);
+static int	 parser_reply_valid(uint32_t, const char *, size_t,
+		    uint32_t);
+static int	 parser_literal_safe(const char *, size_t);
+static int	 parser_extent_safe(uint64_t, uint64_t, uint64_t);
+static int	 extent_has_nul(int, uint64_t, uint64_t);
+
 static int	 imsg_field_valid(const char *, size_t, const char *);
 
-/* definitions for store_internal.h's extern globals, shared across store_*.c */
 uint32_t		 session_id;
 uint32_t		 append_counter;
-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)
@@ -67,18 +102,11 @@ store_main(void)
 	struct imsg		 imsg;
 	ssize_t			 n;
 	struct imsg_store_init	 init;
-	int			 peer_fd;
 	gid_t			 gid;
 
-	/* store children take no imapd.conf; uid/gid/spool_root via INIT */
 
-	/*
-	 * fd-passing allowed on channel for SETUP_PEER fd; see
-	 * imsgev_ibuf_init()
-	 */
 	imsgev_ibuf_init(&ibuf3, 3);
 
-	/* IMSG_STORE_INIT first: privilege target is runtime, pre-chroot() */
 	for (;;) {
 		if ((n = imsgbuf_get(&ibuf3, &imsg)) == -1)
 			fatal("imsgbuf_get");
@@ -101,12 +129,7 @@ store_main(void)
 	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() does not NUL-terminate; these reach chroot(2) */
 	if (!imsg_field_valid(init.spool_root, sizeof(init.spool_root),
 	    "IMSG_STORE_INIT.spool_root") ||
 	    !imsg_field_valid(init.maildir, sizeof(init.maildir),
@@ -118,10 +141,6 @@ store_main(void)
 	if (chdir("/") == -1)
 		fatal("chdir /");
 
-	/*
-	 * 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 ||
@@ -129,16 +148,10 @@ store_main(void)
 		fatal("session %u: cannot drop privileges to uid %u gid %u",
 		    session_id, init.uid, init.gid);
 
-	setproctitle("session %u store", session_id);
+	setproctitle("store uid %u", (unsigned int)init.uid);
 
-	/*
-	 * 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.
-	 */
+	/* confine before the ack, so a failure can be reported */
 
-	/* unveil() scoped to the session's mailbox subdir, past chroot */
 	{
 		char	unveil_path[sizeof(init.maildir) + 1];
 
@@ -149,47 +162,31 @@ store_main(void)
 		if (unveil(unveil_path, "rwc") == -1)
 			fatal("unveil %s", unveil_path);
 
-		/*
-		 * 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).
-	 */
+	/* unveil(2) covers lookups relative to these (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, 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);
+	evtimer_set(&parser_idle_ev, parser_idle, NULL);
+	evtimer_set(&parser_wait_ev, parser_wait_expired, NULL);
+	imsgev_init_from_ibuf(&parent_iev, &ibuf3, store_parent_dispatch,
+	    NULL);
 
-	/*
-	 * 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.
-	 */
+	/* the handshake may already have read the first attach: take it */
+	store_parent_dispatch(parent_iev.ibuf.fd, 0, &parent_iev);
+
+	/* sendfd: FETCH hands the listener a read-only descriptor */
 #ifdef __OpenBSD__
-	if (pledge("stdio rpath wpath cpath flock sendfd", NULL) == -1)
+	if (pledge("stdio rpath wpath cpath flock recvfd sendfd", NULL) == -1)
 		fatal("pledge");
 #endif
 
@@ -201,17 +198,545 @@ store_main(void)
 	fatalx("store: exited event loop");
 }
 
+static void
+store_parent_dispatch(int fd, short event, void *arg)
+{
+	struct imsgev	*iev = arg;
+	struct imsg	 imsg;
+	ssize_t		 n;
+
+	if (event & EV_WRITE) {
+		if (imsgbuf_write(&iev->ibuf) == -1)
+			fatal("imsgbuf_write");
+	}
+	if (event & EV_READ) {
+		if ((n = imsgbuf_read(&iev->ibuf)) == -1)
+			fatal("imsgbuf_read");
+		if (n == 0) {
+			/* no session can arrive now; serve those here out */
+			log_debug("store: parent closed channel");
+			event_del(&iev->ev);
+			parent_gone = 1;
+			if (TAILQ_EMPTY(&sessions))
+				store_exit();
+			return;
+		}
+	}
+
+	for (;;) {
+		if ((n = imsgbuf_get(&iev->ibuf, &imsg)) == -1)
+			fatal("imsgbuf_get");
+		if (n == 0)
+			break;
+
+		switch (imsg_get_type(&imsg)) {
+		case IMSG_SETUP_PEER:
+			if (imsg_get_id(&imsg) == 0)
+				parser_install(imsg_get_fd(&imsg));
+			else
+				store_attach(imsg_get_id(&imsg),
+				    imsg_get_fd(&imsg));
+			break;
+		case IMSG_PARSER_NONE:
+			if (parser_wanted) {
+				log_warnx("store: no parser-worker could be "
+				    "started");
+				parser_give_up();
+			}
+			break;
+		case IMSG_STORE_EXIT:
+			imsg_free(&imsg);
+			store_exit();
+		default:
+			log_debug("store_parent_dispatch: unhandled %d "
+			    "(session %u)", imsg_get_type(&imsg), session_id);
+			break;
+		}
+		imsg_free(&imsg);
+	}
+	imsgev_rearm_read(iev);
+	(void)fd;
+}
+
+static void
+store_attach(uint32_t id, int fd)
+{
+	struct store_session	*ss;
+
+	if (fd == -1) {
+		log_warnx("session %u: IMSG_SETUP_PEER carried no fd", id);
+		return;
+	}
+	TAILQ_FOREACH(ss, &sessions, entry) {
+		if (ss->id == id)
+			break;
+	}
+	if (id == 0 || ss != NULL) {
+		log_warnx("session %u: refusing a session that is 0 or "
+		    "already attached", id);
+		close(fd);
+		return;
+	}
+	if ((ss = calloc(1, sizeof(*ss))) == NULL) {
+		log_warn("session %u: calloc", id);
+		close(fd);
+		return;
+	}
+	ss->id = id;
+	ss->cur_snap.dfd = -1;		/* nothing read from cur/ yet */
+	if ((ss->mailbox_dir_fd = mailbox_open_dir("")) == -1) {
+		log_warn("session %u: open maildir root", id);
+		free(ss);
+		close(fd);
+		return;
+	}
+	imsgev_init(&ss->iev, fd, store_dispatch, ss);
+	TAILQ_INSERT_TAIL(&sessions, ss, entry);
+	log_debug("session %u: attached", id);
+}
+
+/* the parser is assumed attackable: check its reply before use */
+static int
+parser_reply_safe(const char *buf, size_t len)
+{
+	size_t	 i;
+	int	 depth = 0, inquote = 0, escaped = 0;
+
+	if (len < 2 || buf[0] != '(' || buf[len - 1] != ')')
+		return (0);
+
+	/* no byte may end the line, quoted or not */
+	for (i = 0; i < len; i++) {
+		if (buf[i] == '\r' || buf[i] == '\n' || buf[i] == '\0')
+			return (0);
+	}
+
+	/* then framing, so a reply cannot swallow what follows it */
+	for (i = 0; i < len; i++) {
+		char	 c = buf[i];
+
+		if (escaped) {
+			escaped = 0;
+			continue;
+		}
+		if (inquote) {
+			if (c == '\\')
+				escaped = 1;
+			else if (c == '"')
+				inquote = 0;
+			continue;
+		}
+		if (c == '"')
+			inquote = 1;
+		else if (c == '(')
+			depth++;
+		else if (c == ')' && --depth < 0)
+			return (0);
+	}
+
+	return (!inquote && !escaped && depth == 0);
+}
+
+/* RFC 9051 SS9: a literal may not hold NUL */
+static int
+parser_literal_safe(const char *buf, size_t len)
+{
+	return (len > 0 && len <= FETCH_HEADER_MAX &&
+	    memchr(buf, '\0', len) == NULL);
+}
+
+static int
+parser_reply_valid(uint32_t type, const char *buf, size_t len,
+    uint32_t maxlen)
+{
+	size_t	 i;
+
+	switch (type) {
+	case IMSG_PARSER_HEADER_FIELDS:
+		return (parser_literal_safe(buf, len));
+	case IMSG_PARSER_SEARCH:
+		if (len != maxlen)
+			return (0);
+		for (i = 0; i < len; i++) {
+			if (buf[i] != 0 && buf[i] != 1)
+				return (0);
+		}
+		return (1);
+	default:
+		return (parser_reply_safe(buf, len));
+	}
+}
+
+static int
+parser_extent_safe(uint64_t off, uint64_t len, uint64_t size)
+{
+	return (off <= size && len <= size - off);
+}
+
+static int
+extent_has_nul(int fd, uint64_t off, uint64_t len)
+{
+	char	 buf[16384];
+	size_t	 want;
+	ssize_t	 n;
+
+	while (len > 0) {
+		want = len < sizeof(buf) ? (size_t)len : sizeof(buf);
+		if ((n = pread(fd, buf, want, (off_t)off)) == -1) {
+			if (errno == EINTR)
+				continue;
+			return (1);
+		}
+		if (n == 0 || memchr(buf, '\0', (size_t)n) != NULL)
+			return (1);
+		off += (uint64_t)n;
+		len -= (uint64_t)n;
+	}
+	return (0);
+}
+
+/* blocks until a reply, the deadline, or the parser dies */
 int
+parser_request(uint32_t type, const char *label, int fd,
+    const char *basename, struct imsg_parser_req *req, void *extra,
+    size_t extralen, uint32_t maxlen, struct imsg_parser_rep *rep_out,
+    char **buf_out)
+{
+	struct imsg_parser_req	 none;
+	struct imsg_parser_rep	 rep;
+	struct imsg		 imsg;
+	struct iovec		 iov[2];
+	struct pollfd		 pfd;
+	struct stat		 st;
+	struct timespec		 start, now;
+	struct timeval		 tv;
+	static uint32_t		 reqid;
+	uint32_t		 id;
+	ssize_t			 n;
+	int			 dupfd, ms, rc = -1;
+
+	memset(rep_out, 0, sizeof(*rep_out));
+	if (buf_out != NULL)
+		*buf_out = NULL;
+
+	if (!parser_up || parser_struck_out(basename))
+		return (-1);
+
+	if (req == NULL) {
+		memset(&none, 0, sizeof(none));
+		req = &none;
+	}
+	if (strlcpy(req->basename, basename, sizeof(req->basename)) >=
+	    sizeof(req->basename)) {
+		log_warnx("session %u: message name too long for the parser",
+		    session_id);
+		return (-1);
+	}
+
+	if ((dupfd = dup(fd)) == -1) {
+		log_warn("session %u: dup %s request", session_id, label);
+		return (-1);
+	}
+
+	id = ++reqid;
+	iov[0].iov_base = req;
+	iov[0].iov_len = sizeof(*req);
+	iov[1].iov_base = extra;
+	iov[1].iov_len = extralen;
+	/* imsg_composev() owns dupfd only once it succeeds */
+	if (imsg_composev(&parser_ibuf, type, id, 0, dupfd, iov,
+	    extra != NULL ? 2 : 1) == -1) {
+		log_warn("session %u: imsg_composev %s request", session_id,
+		    label);
+		close(dupfd);
+		return (-1);
+	}
+	if (imsgbuf_flush(&parser_ibuf) == -1) {
+		/* it never had the request: let it go, count nothing */
+		log_warn("session %u: imsgbuf_flush %s request", session_id,
+		    label);
+		parser_drop();
+		return (-1);
+	}
+
+	if (clock_gettime(CLOCK_MONOTONIC, &start) == -1)
+		fatal("clock_gettime");
+
+	for (;;) {
+		if ((n = imsgbuf_get(&parser_ibuf, &imsg)) == -1) {
+			log_warn("session %u: imsgbuf_get (parser)",
+			    session_id);
+			parser_fail(basename);
+			return (-1);
+		}
+		if (n == 0) {
+			if (clock_gettime(CLOCK_MONOTONIC, &now) == -1)
+				fatal("clock_gettime");
+			/* one budget for the whole exchange */
+			ms = PARSER_REPLY_TIMEOUT_SEC * 1000 -
+			    (int)((now.tv_sec - start.tv_sec) * 1000 +
+			    (now.tv_nsec - start.tv_nsec) / 1000000);
+			if (ms <= 0) {
+				log_warnx("session %u: parser-worker did not "
+				    "answer within %d seconds, message %s",
+				    session_id, PARSER_REPLY_TIMEOUT_SEC,
+				    basename);
+				parser_fail(basename);
+				return (-1);
+			}
+			pfd.fd = parser_ibuf.fd;
+			pfd.events = POLLIN;
+			if ((n = poll(&pfd, 1, ms)) == -1) {
+				if (errno == EINTR)
+					continue;
+				log_warn("session %u: poll (parser)",
+				    session_id);
+				parser_drop();
+				return (-1);
+			}
+			if (n == 0)
+				continue;	/* deadline rechecked above */
+			if ((n = imsgbuf_read(&parser_ibuf)) == -1) {
+				log_warn("session %u: imsgbuf_read (parser)",
+				    session_id);
+				parser_fail(basename);
+				return (-1);
+			}
+			if (n == 0) {
+				log_warnx("session %u: parser-worker closed "
+				    "its channel, message %s", session_id,
+				    basename);
+				parser_fail(basename);
+				return (-1);
+			}
+			continue;
+		}
+		if (imsg_get_type(&imsg) != type ||
+		    imsg_get_id(&imsg) != id) {
+			log_warnx("session %u: unexpected parser reply type "
+			    "%u id %u (wanted %u/%u)", session_id,
+			    imsg_get_type(&imsg), imsg_get_id(&imsg), type,
+			    id);
+			imsg_free(&imsg);
+			continue;
+		}
+		if (imsg_get_buf(&imsg, &rep, sizeof(rep)) == -1) {
+			log_warnx("session %u: bad parser reply header",
+			    session_id);
+			imsg_free(&imsg);
+			break;
+		}
+		if (rep.found && type == IMSG_PARSER_PART) {
+			/* checked against the file, not the parser's word */
+			if (imsg_get_len(&imsg) == 0 && fstat(fd, &st) == 0 &&
+			    parser_extent_safe(rep.part_off, rep.part_len,
+			    (uint64_t)st.st_size) &&
+			    !extent_has_nul(fd, rep.part_off, rep.part_len)) {
+				*rep_out = rep;
+				rc = 0;
+			} else
+				log_warnx("session %u: parser-worker returned "
+				    "a %s that cannot be sent, message %s "
+				    "skipped", session_id, label, basename);
+		} else if (rep.found && rep.len > 0 && rep.len <= maxlen &&
+		    imsg_get_len(&imsg) == rep.len) {
+			if ((*buf_out = malloc(rep.len)) == NULL)
+				log_warn("session %u: malloc %s", session_id,
+				    label);
+			else if (imsg_get_buf(&imsg, *buf_out, rep.len) ==
+			    -1) {
+				log_warnx("session %u: bad parser reply data",
+				    session_id);
+				free(*buf_out);
+				*buf_out = NULL;
+			} else if (!parser_reply_valid(type, *buf_out,
+			    rep.len, maxlen)) {
+				log_warnx("session %u: parser-worker returned "
+				    "a %s that cannot be sent, message %s "
+				    "skipped", session_id, label, basename);
+				free(*buf_out);
+				*buf_out = NULL;
+			} else {
+				*rep_out = rep;
+				rc = 0;
+			}
+		}
+		/* it exits once this reply is sent: let it go now */
+		if (rep.retiring) {
+			log_debug("session %u: parser-worker retiring",
+			    session_id);
+			imsg_free(&imsg);
+			parser_drop();
+			return (rc);
+		}
+		imsg_free(&imsg);
+		break;
+	}
+
+	evtimer_del(&parser_idle_ev);
+	tv.tv_sec = PARSER_IDLE_SEC;
+	tv.tv_usec = 0;
+	evtimer_add(&parser_idle_ev, &tv);
+	return (rc);
+}
+
+int
+parser_ready(void)
+{
+	struct timeval	 tv;
+
+	if (parser_up && !parser_alive())
+		parser_drop();
+	if (parser_up)
+		return (1);
+	if (parser_wanted)
+		return (0);
+	if (parent_gone)
+		return (-1);
+	if (imsg_compose(&parent_iev.ibuf, IMSG_PARSER_WANT, 0, 0, -1, NULL,
+	    0) == -1) {
+		log_warn("session %u: imsg_compose IMSG_PARSER_WANT",
+		    session_id);
+		return (-1);
+	}
+	parser_wanted = 1;
+	tv.tv_sec = PARSER_REPLY_TIMEOUT_SEC;
+	tv.tv_usec = 0;
+	evtimer_add(&parser_wait_ev, &tv);
+	return (0);
+}
+
+/* Between requests nothing may arrive: anything readable is its end. */
+static int
+parser_alive(void)
+{
+	struct pollfd	 pfd;
+
+	pfd.fd = parser_ibuf.fd;
+	pfd.events = POLLIN;
+	pfd.revents = 0;
+	if (poll(&pfd, 1, 0) == -1)
+		return (errno == EINTR);
+	return (pfd.revents == 0);
+}
+
+/* Lets the parser go; closing its channel is what ends it. */
+static void
+parser_drop(void)
+{
+	if (!parser_up)
+		return;
+	evtimer_del(&parser_idle_ev);
+	close(parser_ibuf.fd);
+	imsgbuf_clear(&parser_ibuf);
+	parser_up = 0;
+}
+
+static void
+parser_fail(const char *basename)
+{
+	size_t	 i;
+
+	parser_drop();
+	for (i = 0; i < PARSER_STRIKE_SLOTS; i++) {
+		if (parser_strikes[i].count > 0 &&
+		    strcmp(parser_strikes[i].basename, basename) == 0)
+			break;
+	}
+	if (i == PARSER_STRIKE_SLOTS) {
+		i = parser_strike_next++ % PARSER_STRIKE_SLOTS;
+		(void)strlcpy(parser_strikes[i].basename, basename,
+		    sizeof(parser_strikes[i].basename));
+		parser_strikes[i].count = 0;
+	}
+	if (++parser_strikes[i].count == PARSER_STRIKES)
+		log_warnx("session %u: message %s has failed the "
+		    "parser-worker %d times, not sending it again",
+		    session_id, basename, PARSER_STRIKES);
+}
+
+static int
+parser_struck_out(const char *basename)
+{
+	size_t	 i;
+
+	for (i = 0; i < PARSER_STRIKE_SLOTS; i++) {
+		if (parser_strikes[i].count >= PARSER_STRIKES &&
+		    strcmp(parser_strikes[i].basename, basename) == 0)
+			return (1);
+	}
+	return (0);
+}
+
+static void
+parser_install(int fd)
+{
+	struct timeval	 tv;
+
+	if (fd == -1) {
+		log_warnx("store: parser IMSG_SETUP_PEER carried no fd");
+		return;
+	}
+	if (parser_up) {
+		log_warnx("store: a second parser-worker, refusing it");
+		close(fd);
+		return;
+	}
+	imsgev_ibuf_init(&parser_ibuf, fd);
+	parser_up = 1;
+	parser_wanted = 0;
+	evtimer_del(&parser_wait_ev);
+	tv.tv_sec = PARSER_IDLE_SEC;
+	tv.tv_usec = 0;
+	evtimer_add(&parser_idle_ev, &tv);
+	parser_walks(1);
+}
+
+static void
+parser_give_up(void)
+{
+	parser_wanted = 0;
+	evtimer_del(&parser_wait_ev);
+	parser_walks(0);
+}
+
+static void
+parser_walks(int ok)
+{
+	struct store_session	*ss;
+
+	TAILQ_FOREACH(ss, &sessions, entry) {
+		session_id = ss->id;
+		fetch_walk_parser(ss, ok);
+		search_walk_parser(ss, ok);
+	}
+}
+
+static void
+parser_idle(int fd, short event, void *arg)
+{
+	(void)fd;
+	(void)event;
+	(void)arg;
+	log_debug("store: parser-worker idle, letting it go");
+	parser_drop();
+}
+
+static void
+parser_wait_expired(int fd, short event, void *arg)
+{
+	(void)fd;
+	(void)event;
+	(void)arg;
+	log_warnx("store: no parser-worker within %d seconds",
+	    PARSER_REPLY_TIMEOUT_SEC);
+	parser_give_up();
+}
+
+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.
-	 */
+	/* not NUL-terminated by imsg_get_data() */
 	if (memchr(name, '\0', MBOX_NAME_MAX) == NULL) {
 		log_warnx("session %u: mailbox name field is not "
 		    "NUL-terminated, refusing", session_id);
@@ -223,13 +748,6 @@ mailbox_name_valid(const char *name)
 
 
 
-/*
- * 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
 mailbox_open_dir(const char *target)
 {
@@ -237,7 +755,6 @@ mailbox_open_dir(const char *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)
 {
@@ -248,32 +765,34 @@ imsg_field_valid(const char *field, size_t size, const
 	return (0);
 }
 
-/* Same check applied to every node's keyword field of a SEARCH program. */
 static int
-search_nodes_valid(const struct search_node *nodes, uint32_t nnodes)
+search_nodes_valid(const struct search_node *nodes, uint32_t nnodes,
+    uint32_t poollen)
 {
-	uint32_t	i;
+	const struct search_node	*n;
+	uint32_t			 i;
 
 	for (i = 0; i < nnodes; i++) {
-		if (memchr(nodes[i].keyword, '\0', sizeof(nodes[i].keyword))
-		    == NULL) {
+		n = &nodes[i];
+		if (memchr(n->keyword, '\0', sizeof(n->keyword)) == NULL) {
 			log_warnx("session %u: SEARCH node %u keyword is not "
 			    "NUL-terminated, refusing the request",
 			    session_id, i);
 			return (0);
 		}
+		if (search_op_content(n->op) && (n->str_off > poollen ||
+		    n->str_len > poollen - n->str_off ||
+		    n->name_off > poollen ||
+		    n->name_len > poollen - n->name_off)) {
+			log_warnx("session %u: SEARCH node %u string lies "
+			    "outside its pool, refusing the request",
+			    session_id, i);
+			return (0);
+		}
 	}
 	return (1);
 }
 
-/*
- * Shared receive-side unpack for store_dispatch()'s "fixed header +
- * trailing array of `count` `elemsize`-sized elements" imsg shape
- * (SELECT/FETCH/STORE/EXPUNGE/COPY/MOVE's seq_range[], SEARCH's
- * search_node[], APPEND's raw body bytes with elemsize 1); *ok_out
- * tells a legitimate 0-element buffer (NULL) apart from failure
- * (also NULL, already logged).
- */
 static void *
 recv_trailing_array(struct imsg *imsg, const char *what, uint32_t count,
     uint32_t maxcount, size_t elemsize, int *ok_out)
@@ -312,57 +831,18 @@ recv_trailing_array(struct imsg *imsg, const char *wha
 	return (out);
 }
 
-/*
- * 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.
- */
+/* doubling keeps a long wait cheap; the cap bounds the 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 *);
+static void	 deferred_answer_busy_for(uint32_t, struct store_session *);
 
-/* True once "lock timeout" has passed. 0 disables the bound entirely. */
 static int
-deferred_expired(void)
+deferred_expired(struct store_session *ss)
 {
-	struct timespec	 now;
+	struct store_deferred	*d = &ss->deferred;
+	struct timespec		 now;
 
 	if (lock_timeout_secs == 0)
 		return (0);
@@ -370,15 +850,16 @@ deferred_expired(void)
 		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);
+	if (now.tv_sec != d->give_up_at.tv_sec)
+		return (now.tv_sec > d->give_up_at.tv_sec);
+	return (now.tv_nsec >= d->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)
+deferred_answer_busy_for(uint32_t type, struct store_session *ss)
 {
+	struct imsgev			*iev = &ss->iev;
 	struct imsg_mbox_result		 result;
 	struct imsg_mbox_selected	 selected;
 	struct imsg_mbox_status_result	 status;
@@ -416,7 +897,7 @@ deferred_answer_busy_for(uint32_t type, struct imsgev 
 		break;
 	case IMSG_MBOX_APPEND_END:
 		/* the tmp/ file and its descriptors go with the command */
-		append_abort();
+		append_abort(ss);
 		memset(&appended, 0, sizeof(appended));
 		appended.error = MBOX_OP_ERR_BUSY;
 		rtype = IMSG_MBOX_APPENDED;
@@ -437,135 +918,121 @@ deferred_answer_busy_for(uint32_t type, struct imsgev 
 	imsgev_rearm_read(iev);
 }
 
-/* The deferred command has run out of time. */
 static void
-deferred_give_up(void)
+deferred_give_up(struct store_session *ss)
 {
 	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;
+	deferred_answer_busy_for(ss->deferred.type, ss);
+	ss->deferred.active = 0;
 }
 
 static void
-deferred_arm(void)
+deferred_arm(struct store_session *ss)
 {
-	struct timeval	 tv;
+	struct store_deferred	*d = &ss->deferred;
+	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) {
+	tv.tv_sec = d->wait_ms / 1000;
+	tv.tv_usec = (d->wait_ms % 1000) * 1000;
+	evtimer_set(&d->ev, deferred_retry, ss);
+	if (evtimer_add(&d->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();
+		deferred_give_up(ss);
 		return;
 	}
-	if (deferred.wait_ms < LOCK_RETRY_CAP_MS)
-		deferred.wait_ms *= 2;
+	if (d->wait_ms < LOCK_RETRY_CAP_MS)
+		d->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;
+	struct store_session	*ss = arg;
+	struct store_deferred	*d = &ss->deferred;
+	int			 again;
 
 	(void)fd;
 	(void)event;
-	(void)arg;
+	session_id = ss->id;
 
-	switch (deferred.type) {
+	switch (d->type) {
 	case IMSG_MBOX_STORE:
-		again = handle_mbox_store(&deferred.req.store,
-		    deferred.elts.ranges, deferred.nelts, deferred.iev);
+		again = handle_mbox_store(&d->req.store, d->elts.ranges,
+		    d->nelts, ss);
 		break;
 	case IMSG_MBOX_EXPUNGE:
-		again = handle_mbox_expunge(&deferred.req.expunge,
-		    deferred.nelts > 0 ? deferred.elts.ranges : NULL,
-		    deferred.nelts, deferred.iev);
+		again = handle_mbox_expunge(&d->req.expunge,
+		    d->nelts > 0 ? d->elts.ranges : NULL, d->nelts, ss);
 		break;
 	case IMSG_MBOX_FETCH:
-		again = handle_mbox_fetch(&deferred.req.fetch,
-		    deferred.elts.ranges, deferred.nelts, deferred.iev);
+		again = handle_mbox_fetch(&d->req.fetch, d->elts.ranges,
+		    d->nelts, ss);
 		break;
 	case IMSG_MBOX_SEARCH:
-		again = handle_mbox_search(&deferred.req.search,
-		    deferred.nelts > 0 ? deferred.elts.nodes : NULL,
-		    deferred.nelts, deferred.iev);
+		again = handle_mbox_search(&d->req.search,
+		    d->nelts > 0 ? d->elts.nodes : NULL, d->nelts, ss);
 		break;
 	case IMSG_MBOX_SELECT:
-		again = handle_mbox_select(&deferred.req.select,
-		    deferred.nelts > 0 ? deferred.elts.ranges : NULL,
-		    deferred.nelts, deferred.iev);
+		again = handle_mbox_select(&d->req.select,
+		    d->nelts > 0 ? d->elts.ranges : NULL, d->nelts, ss);
 		break;
 	case IMSG_MBOX_STATUS:
-		again = handle_mbox_status(&deferred.req.status,
-		    deferred.iev);
+		again = handle_mbox_status(&d->req.status, ss);
 		break;
 	case IMSG_MBOX_COPY:
-		again = handle_mbox_copy(&deferred.req.copy,
-		    deferred.elts.ranges, deferred.nelts, deferred.iev);
+		again = handle_mbox_copy(&d->req.copy, d->elts.ranges,
+		    d->nelts, ss);
 		break;
 	case IMSG_MBOX_MOVE:
-		again = handle_mbox_move(&deferred.req.copy,
-		    deferred.elts.ranges, deferred.nelts, deferred.iev);
+		again = handle_mbox_move(&d->req.copy, d->elts.ranges,
+		    d->nelts, ss);
 		break;
 	case IMSG_MBOX_APPEND_END:
 		/* its state is the in-flight APPEND, not a saved request */
-		again = handle_mbox_append_end(deferred.iev);
+		again = handle_mbox_append_end(ss);
 		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();
+		    session_id, d->type);
+		deferred_give_up(ss);
 		return;
 	}
 	if (!again) {
-		deferred.active = 0;
-		imsgev_rearm_read(deferred.iev);
+		d->active = 0;
+		if (!fetch_walk_paused(ss) && !search_walk_paused(ss))
+			cur_snapshot_discard(&ss->cur_snap);
+		imsgev_rearm_read(&ss->iev);
 		return;
 	}
-	if (deferred_expired())
-		deferred_give_up();
+	if (deferred_expired(ss))
+		deferred_give_up(ss);
 	else
-		deferred_arm();
+		deferred_arm(ss);
 }
 
-/*
- * 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 store_session *ss)
 {
-	struct timespec	 now;
+	struct store_deferred	*d = &ss->deferred;
+	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.
-		 */
+	if (d->active) {
 		log_warnx("session %u: a second command to defer while one "
 		    "waits, refusing the new one", session_id);
-		deferred_answer_busy_for(type, iev);
+		deferred_answer_busy_for(type, ss);
 		return;
 	}
-	if (reqlen > sizeof(deferred.req) || nelts > maxelts ||
-	    (size_t)nelts * eltlen > sizeof(deferred.elts)) {
+	if (reqlen > sizeof(d->req) || nelts > maxelts ||
+	    (size_t)nelts * eltlen > sizeof(d->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);
+		deferred_answer_busy_for(type, ss);
 		return;
 	}
 	if (clock_gettime(CLOCK_MONOTONIC, &now) == -1) {
@@ -573,34 +1040,27 @@ defer_command(uint32_t type, const void *req, size_t r
 		now.tv_sec = 0;
 		now.tv_nsec = 0;
 	}
-	deferred.active = 1;
-	deferred.type = type;
-	deferred.iev = iev;
+	d->active = 1;
+	d->type = type;
 	if (reqlen > 0)
-		memcpy(&deferred.req, req, reqlen);
+		memcpy(&d->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;
+		memcpy(&d->elts, elts, (size_t)nelts * eltlen);
+	d->nelts = nelts;
+	d->give_up_at = now;
+	d->give_up_at.tv_sec += lock_timeout_secs;
+	d->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();
+	deferred_arm(ss);
 }
 
-/*
- * 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.
- */
+/* checked here, never trusted from the listener */
 static int
-require_mailbox_selected(const char *what)
+require_mailbox_selected(struct store_session *ss, const char *what)
 {
-	if (mailbox_selected)
+	if (ss->mailbox_selected)
 		return (1);
 	log_warnx("session %u: %s before a successful IMSG_MBOX_SELECT, "
 	    "refusing", session_id, what);
@@ -610,52 +1070,57 @@ require_mailbox_selected(const char *what)
 void
 store_dispatch(int fd, short event, void *arg)
 {
-	struct imsgev	*iev = arg;
+	struct store_session	*ss = arg;
+	struct imsgev		*iev = &ss->iev;
 	struct imsg	 imsg;
 	ssize_t		 n;
 
-	/* queued IMSG_MBOX_*_RESULT needs an imsgbuf_write() once writable */
+	/* the account's other sessions share this process: label the log */
+	session_id = ss->id;
+
+	/* a failure here ends this session, not the process */
 	if (event & EV_WRITE) {
-		if (imsgbuf_write(&iev->ibuf) == -1)
-			fatal("imsgbuf_write");
+		if (imsgbuf_write(&iev->ibuf) == -1) {
+			log_warn("session %u: imsgbuf_write", ss->id);
+			store_end(ss);
+			return;
+		}
 		/* a FETCH paused for its queue to drain carries on here */
-		fetch_walk_resume(iev);
+		fetch_walk_resume(ss);
 	}
 
 	if (event & EV_READ) {
-		if ((n = imsgbuf_read(&iev->ibuf)) == -1)
-			fatal("imsgbuf_read");
+		if ((n = imsgbuf_read(&iev->ibuf)) == -1) {
+			log_warn("session %u: imsgbuf_read", ss->id);
+			store_end(ss);
+			return;
+		}
 		if (n == 0) {
-			/*
-			 * listener's end closed; treat like
-			 * IMSG_STORE_SHUTDOWN: nothing to serve
-			 */
-			store_shutdown();
+			/* the listener's end closed, as IMSG_STORE_SHUTDOWN */
+			store_end(ss);
+			return;
 		}
 	}
 
 	for (;;) {
-		if ((n = imsgbuf_get(&iev->ibuf, &imsg)) == -1)
-			fatal("imsgbuf_get");
+		if ((n = imsgbuf_get(&iev->ibuf, &imsg)) == -1) {
+			log_warn("session %u: imsgbuf_get", ss->id);
+			store_end(ss);
+			return;
+		}
 		if (n == 0)
 			break;
 
 		switch (imsg_get_type(&imsg)) {
 		case IMSG_STORE_SHUTDOWN:
 			imsg_free(&imsg);
-			store_shutdown();
-			break;
+			store_end(ss);
+			return;
 		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.
-			 */
 			if (imsg_get_buf(&imsg, &req, sizeof(req)) == -1) {
 				log_warnx("bad IMSG_MBOX_SELECT (header)");
 				break;
@@ -665,24 +1130,14 @@ 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".
-			 */
-			mailbox_selected = 0;
+			/* RFC 9051 SS6.3.2: failure deselects */
+			ss->mailbox_selected = 0;
 
-			/*
-			 * composes own reply like handle_mbox_*(); EV_WRITE via
-			 * imsgev_on_compose
-			 */
 			if (handle_mbox_select(&req, ranges,
-			    req.qresync_nranges, iev) == 1)
+			    req.qresync_nranges, ss) == 1)
 				defer_command(IMSG_MBOX_SELECT, &req,
 				    sizeof(req), ranges, req.qresync_nranges,
-				    SEQSET_MAX_RANGES, sizeof(*ranges), iev);
+				    SEQSET_MAX_RANGES, sizeof(*ranges), ss);
 			free(ranges);
 			break;
 		}
@@ -691,13 +1146,9 @@ store_dispatch(int fd, short event, void *arg)
 			struct seq_range	*ranges;
 			int			 ok;
 
-			if (!require_mailbox_selected("IMSG_MBOX_FETCH"))
+			if (!require_mailbox_selected(ss, "IMSG_MBOX_FETCH"))
 				break;
 
-			/*
-			 * 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;
@@ -720,10 +1171,10 @@ store_dispatch(int fd, short event, void *arg)
 			if (!ok)
 				break;
 			if (handle_mbox_fetch(&req, ranges, req.nranges,
-			    iev) == 1)
+			    ss) == 1)
 				defer_command(IMSG_MBOX_FETCH, &req,
 				    sizeof(req), ranges, req.nranges,
-				    SEQSET_MAX_RANGES, sizeof(*ranges), iev);
+				    SEQSET_MAX_RANGES, sizeof(*ranges), ss);
 			free(ranges);
 			break;
 		}
@@ -732,13 +1183,9 @@ store_dispatch(int fd, short event, void *arg)
 			struct seq_range	*ranges;
 			int			 ok;
 
-			if (!require_mailbox_selected("IMSG_MBOX_STORE"))
+			if (!require_mailbox_selected(ss, "IMSG_MBOX_STORE"))
 				break;
 
-			/*
-			 * 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;
@@ -757,10 +1204,10 @@ store_dispatch(int fd, short event, void *arg)
 			if (!ok)
 				break;
 			if (handle_mbox_store(&req, ranges, req.nranges,
-			    iev) == 1)
+			    ss) == 1)
 				defer_command(IMSG_MBOX_STORE, &req,
 				    sizeof(req), ranges, req.nranges,
-				    SEQSET_MAX_RANGES, sizeof(*ranges), iev);
+				    SEQSET_MAX_RANGES, sizeof(*ranges), ss);
 			free(ranges);
 			break;
 		}
@@ -769,13 +1216,7 @@ 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.
-			 */
-			if (!require_mailbox_selected("IMSG_MBOX_EXPUNGE"))
+			if (!require_mailbox_selected(ss, "IMSG_MBOX_EXPUNGE"))
 				break;
 
 			if (imsg_get_buf(&imsg, &req, sizeof(req)) == -1) {
@@ -788,10 +1229,10 @@ store_dispatch(int fd, short event, void *arg)
 			if (!ok)
 				break;
 			if (handle_mbox_expunge(&req, ranges, req.nranges,
-			    iev) == 1)
+			    ss) == 1)
 				defer_command(IMSG_MBOX_EXPUNGE, &req,
 				    sizeof(req), ranges, req.nranges,
-				    SEQSET_MAX_RANGES, sizeof(*ranges), iev);
+				    SEQSET_MAX_RANGES, sizeof(*ranges), ss);
 			free(ranges);
 			break;
 		}
@@ -802,18 +1243,16 @@ store_dispatch(int fd, short event, void *arg)
 				log_warnx("bad IMSG_MBOX_IDLE_REFRESH");
 				break;
 			}
-			if (!require_mailbox_selected("IMSG_MBOX_IDLE_REFRESH"))
+			if (!require_mailbox_selected(ss,
+			    "IMSG_MBOX_IDLE_REFRESH"))
 				break;
-			handle_mbox_idle_refresh(&req, iev);
+			handle_mbox_idle_refresh(&req, ss);
 			break;
 		}
 		case IMSG_MBOX_APPEND: {
 			struct imsg_mbox_append	 req;
 
-			/*
-			 * No reply here even when refused: the literal follows
-			 * regardless; handle_mbox_append_end() answers.
-			 */
+			/* no reply: the literal follows regardless */
 			if (imsg_get_data(&imsg, &req, sizeof(req)) == -1) {
 				log_warnx("bad IMSG_MBOX_APPEND");
 				break;
@@ -821,7 +1260,7 @@ store_dispatch(int fd, short event, void *arg)
 			if (!imsg_field_valid(req.keywords,
 			    sizeof(req.keywords), "IMSG_MBOX_APPEND.keywords"))
 				break;
-			handle_mbox_append_begin(&req);
+			handle_mbox_append_begin(ss, &req);
 			break;
 		}
 		case IMSG_MBOX_APPEND_DATA: {
@@ -834,27 +1273,24 @@ store_dispatch(int fd, short event, void *arg)
 				log_warnx("bad IMSG_MBOX_APPEND_DATA");
 				break;
 			}
-			handle_mbox_append_data(buf, len);
+			handle_mbox_append_data(ss, buf, len);
 			break;
 		}
 		case IMSG_MBOX_APPEND_END:
-			if (handle_mbox_append_end(iev) == 1)
+			if (handle_mbox_append_end(ss) == 1)
 				defer_command(IMSG_MBOX_APPEND_END, NULL, 0,
-				    NULL, 0, 0, 0, iev);
+				    NULL, 0, 0, 0, ss);
 			break;
 		case IMSG_MBOX_SEARCH: {
 			struct imsg_mbox_search	 req;
 			struct search_node	*nodes;
 			int			 ok;
 
-			if (!require_mailbox_selected("IMSG_MBOX_SEARCH"))
+			if (!require_mailbox_selected(ss, "IMSG_MBOX_SEARCH"))
 				break;
 
-			/*
-			 * same header+variable-body as APPEND, trailing
-			 * search_node[] not raw
-			 */
-			if (imsg_get_buf(&imsg, &req, sizeof(req)) == -1) {
+			if (imsg_get_buf(&imsg, &req, sizeof(req)) == -1 ||
+			    req.poollen > sizeof(req.pool)) {
 				log_warnx("bad IMSG_MBOX_SEARCH (header)");
 				break;
 			}
@@ -864,16 +1300,17 @@ store_dispatch(int fd, short event, void *arg)
 			if (!ok)
 				break;
 			if (nodes != NULL &&
-			    !search_nodes_valid(nodes, req.nnodes)) {
+			    !search_nodes_valid(nodes, req.nnodes,
+			    req.poollen)) {
 				free(nodes);
 				break;
 			}
 			if (handle_mbox_search(&req, nodes, req.nnodes,
-			    iev) == 1)
+			    ss) == 1)
 				defer_command(IMSG_MBOX_SEARCH, &req,
 				    sizeof(req), nodes, req.nnodes,
 				    SEARCH_PROGRAM_MAX_NODES, sizeof(*nodes),
-				    iev);
+				    ss);
 			free(nodes);
 			break;
 		}
@@ -884,9 +1321,9 @@ store_dispatch(int fd, short event, void *arg)
 				log_warnx("bad IMSG_MBOX_STATUS");
 				break;
 			}
-			if (handle_mbox_status(&req, iev) == 1)
+			if (handle_mbox_status(&req, ss) == 1)
 				defer_command(IMSG_MBOX_STATUS, &req,
-				    sizeof(req), NULL, 0, 0, 0, iev);
+				    sizeof(req), NULL, 0, 0, 0, ss);
 			break;
 		}
 		case IMSG_MBOX_COPY: {
@@ -894,13 +1331,9 @@ store_dispatch(int fd, short event, void *arg)
 			struct seq_range	*ranges;
 			int			 ok;
 
-			if (!require_mailbox_selected("IMSG_MBOX_COPY"))
+			if (!require_mailbox_selected(ss, "IMSG_MBOX_COPY"))
 				break;
 
-			/*
-			 * 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;
@@ -916,10 +1349,10 @@ store_dispatch(int fd, short event, void *arg)
 			if (!ok)
 				break;
 			if (handle_mbox_copy(&req, ranges, req.nranges,
-			    iev) == 1)
+			    ss) == 1)
 				defer_command(IMSG_MBOX_COPY, &req,
 				    sizeof(req), ranges, req.nranges,
-				    SEQSET_MAX_RANGES, sizeof(*ranges), iev);
+				    SEQSET_MAX_RANGES, sizeof(*ranges), ss);
 			free(ranges);
 			break;
 		}
@@ -928,10 +1361,9 @@ store_dispatch(int fd, short event, void *arg)
 			struct seq_range	*ranges;
 			int			 ok;
 
-			if (!require_mailbox_selected("IMSG_MBOX_MOVE"))
+			if (!require_mailbox_selected(ss, "IMSG_MBOX_MOVE"))
 				break;
 
-			/* same header-plus-variable-body shape as COPY above */
 			if (imsg_get_buf(&imsg, &req, sizeof(req)) == -1) {
 				log_warnx("bad IMSG_MBOX_MOVE (header)");
 				break;
@@ -947,10 +1379,10 @@ store_dispatch(int fd, short event, void *arg)
 			if (!ok)
 				break;
 			if (handle_mbox_move(&req, ranges, req.nranges,
-			    iev) == 1)
+			    ss) == 1)
 				defer_command(IMSG_MBOX_MOVE, &req,
 				    sizeof(req), ranges, req.nranges,
-				    SEQSET_MAX_RANGES, sizeof(*ranges), iev);
+				    SEQSET_MAX_RANGES, sizeof(*ranges), ss);
 			free(ranges);
 			break;
 		}
@@ -981,7 +1413,7 @@ store_dispatch(int fd, short event, void *arg)
 				log_warnx("bad IMSG_MBOX_DELETE");
 				break;
 			}
-			handle_mbox_delete(&req, iev);
+			handle_mbox_delete(&req, ss);
 			break;
 		}
 		case IMSG_MBOX_RENAME: {
@@ -991,7 +1423,7 @@ store_dispatch(int fd, short event, void *arg)
 				log_warnx("bad IMSG_MBOX_RENAME");
 				break;
 			}
-			handle_mbox_rename(&req, iev);
+			handle_mbox_rename(&req, ss);
 			break;
 		}
 		case IMSG_MBOX_SUBSCRIBE: {
@@ -1021,28 +1453,49 @@ store_dispatch(int fd, short event, void *arg)
 		}
 		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();
+		/* cur/'s snapshot does not outlive the command */
+		if (!fetch_walk_paused(ss) && !search_walk_paused(ss))
+			cur_snapshot_discard(&ss->cur_snap);
 	}
 	imsgev_rearm_read(iev);
 	(void)fd;
 }
 
-/* IMSG_STORE_SHUTDOWN arrives directly from listener; no round-trip parent */
-__dead void
-store_shutdown(void)
+static void
+store_detach(struct store_session *ss)
 {
-	log_debug("session %u: store shutting down", session_id);
-	if (deferred.active)
-		evtimer_del(&deferred.ev);
-	fetch_walk_abort();
-	append_abort();
+	log_debug("session %u: detached", ss->id);
+	if (ss->deferred.active)
+		evtimer_del(&ss->deferred.ev);
+	fetch_walk_abort(ss);
+	search_walk_abort(ss);
+	append_abort(ss);
+	cur_snapshot_discard(&ss->cur_snap);
+	idle_baseline_reset(ss);
+	if (ss->mailbox_dir_fd != -1)
+		close(ss->mailbox_dir_fd);
+	event_del(&ss->iev.ev);
+	close(ss->iev.ibuf.fd);
+	imsgbuf_clear(&ss->iev.ibuf);
+	TAILQ_REMOVE(&sessions, ss, entry);
+	free(ss);
+}
+
+static void
+store_end(struct store_session *ss)
+{
+	store_detach(ss);
+	if (parent_gone && TAILQ_EMPTY(&sessions))
+		store_exit();
+}
+
+static __dead void
+store_exit(void)
+{
+	struct store_session	*ss;
+
+	while ((ss = TAILQ_FIRST(&sessions)) != NULL)
+		store_detach(ss);
+	log_debug("store: shutting down");
 	exit(0);
 }
blob - /dev/null
blob + f514c63455866a48bef14a150964c849578eced7 (mode 644)
--- /dev/null
+++ src/search_match.c
@@ -0,0 +1,533 @@
+/*	$OpenIMAPD$	*/
+
+/*
+ * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+#include <sys/queue.h>
+
+#include <errno.h>
+#include <event.h>
+#include <imsg.h>
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "imapd.h"
+#include "log.h"
+#include "store_internal.h"
+
+#define SEARCH_READ_BLOCK	65536
+
+static const char *const month_names[12] = {
+	"Jan", "Feb", "Mar", "Apr", "May", "Jun",
+	"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
+};
+
+static int	 ascii_lower(int);
+static int	 ci_contains(const char *, size_t, const char *, size_t);
+static int	 b64_value(int);
+static int	 hex_value(int);
+static size_t	 decode_word(const char *, size_t, char *);
+static size_t	 encoded_word_len(const char *, size_t);
+static char	*decode_value(const char *, size_t, size_t *);
+static int	 date_fws(int);
+static int	 date_field_day(const char *, size_t, int64_t *);
+static int	 read_header(int, const char *, char **, size_t *);
+static char	*unescape_name(const char *, size_t, size_t *);
+static int	 match_addresses(const char *, size_t, const char *, size_t);
+static int	 match_leaf(const char *, size_t,
+		    const struct imsg_parser_leaf *, const char *);
+
+/* RFC 9051 SS6.4.4 folds the ASCII range only, byte by byte */
+static int
+ascii_lower(int c)
+{
+	return ((c >= 'A' && c <= 'Z') ? c + ('a' - 'A') : c);
+}
+
+static int
+ci_contains(const char *hay, size_t haylen, const char *needle,
+    size_t needlelen)
+{
+	size_t	 i, j;
+
+	if (needlelen == 0)
+		return (1);
+	if (needlelen > haylen)
+		return (0);
+	for (i = 0; i + needlelen <= haylen; i++) {
+		for (j = 0; j < needlelen; j++) {
+			if (ascii_lower((unsigned char)hay[i + j]) !=
+			    ascii_lower((unsigned char)needle[j]))
+				break;
+		}
+		if (j == needlelen)
+			return (1);
+	}
+	return (0);
+}
+
+static int
+b64_value(int c)
+{
+	if (c >= 'A' && c <= 'Z')
+		return (c - 'A');
+	if (c >= 'a' && c <= 'z')
+		return (c - 'a' + 26);
+	if (c >= '0' && c <= '9')
+		return (c - '0' + 52);
+	if (c == '+')
+		return (62);
+	if (c == '/')
+		return (63);
+	return (-1);
+}
+
+static int
+hex_value(int c)
+{
+	if (c >= '0' && c <= '9')
+		return (c - '0');
+	if (c >= 'A' && c <= 'F')
+		return (c - 'A' + 10);
+	if (c >= 'a' && c <= 'f')
+		return (c - 'a' + 10);
+	return (-1);
+}
+
+/* RFC 2047 SS2 encoded-word */
+static size_t
+decode_word(const char *w, size_t len, char *out)
+{
+	const char	*p, *end = w + len - 2;
+	size_t		 n = 0;
+	unsigned int	 bits = 0;
+	int		 enc, nbits = 0, v, hi, lo;
+
+	if (len < 8 || w[0] != '=' || w[1] != '?' || w[len - 2] != '?' ||
+	    w[len - 1] != '=')
+		return (0);
+	if ((p = memchr(w + 2, '?', len - 4)) == NULL || p == w + 2)
+		return (0);
+	if (p + 3 > end || p[2] != '?')
+		return (0);
+	enc = ascii_lower((unsigned char)p[1]);
+	for (p += 3; p < end; p++) {
+		if (enc == 'b') {
+			if (*p == '=')
+				break;
+			if ((v = b64_value((unsigned char)*p)) == -1)
+				return (0);
+			/* 12 bits hold what one character can leave */
+			bits = ((bits << 6) | (unsigned int)v) & 0xfff;
+			if ((nbits += 6) >= 8) {
+				nbits -= 8;
+				out[n++] = (char)((bits >> nbits) & 0xff);
+			}
+		} else if (enc == 'q') {
+			if (*p == '_')
+				out[n++] = ' ';
+			else if (*p == '=') {
+				if (p + 2 >= end ||
+				    (hi = hex_value((unsigned char)p[1])) ==
+				    -1 ||
+				    (lo = hex_value((unsigned char)p[2])) == -1)
+					return (0);
+				out[n++] = (char)(hi << 4 | lo);
+				p += 2;
+			} else
+				out[n++] = *p;
+		} else
+			return (0);
+	}
+	return (n);
+}
+
+static size_t
+encoded_word_len(const char *s, size_t len)
+{
+	size_t	 i, cs;
+
+	if (len < 8 || s[0] != '=' || s[1] != '?')
+		return (0);
+	for (i = 2; i < len && s[i] != '?'; i++) {
+		if (s[i] == ' ' || s[i] == '\t')
+			return (0);
+	}
+	cs = i;
+	if (cs == 2 || cs + 3 >= len || s[cs + 2] != '?')
+		return (0);
+	for (i = cs + 3; i + 1 < len; i++) {
+		if (s[i] == ' ' || s[i] == '\t')
+			return (0);
+		if (s[i] == '?' && s[i + 1] == '=')
+			return (i + 2);
+	}
+	return (0);
+}
+
+/* RFC 5322 SS2.2.3 unfolding and RFC 2047 SS6.1 decoding */
+static char *
+decode_value(const char *v, size_t vlen, size_t *outlen)
+{
+	char	*out, *unf;
+	size_t	 i, n = 0, ulen = 0, wlen, dn, gap = 0;
+	int	 after_word = 0;
+
+	if ((unf = malloc(vlen + 1)) == NULL)
+		return (NULL);
+	for (i = 0; i < vlen; i++) {
+		if (v[i] != '\r' && v[i] != '\n')
+			unf[ulen++] = v[i];
+	}
+	/* decoding never lengthens a word, so ulen bounds the output */
+	if ((out = malloc(ulen + 1)) == NULL) {
+		free(unf);
+		return (NULL);
+	}
+	for (i = 0; i < ulen; ) {
+		if (unf[i] == ' ' || unf[i] == '\t') {
+			out[n++] = unf[i++];
+			gap++;
+			continue;
+		}
+		/* a word that fails leaves octets past n, copied over below */
+		if ((wlen = encoded_word_len(unf + i, ulen - i)) > 0 &&
+		    (dn = decode_word(unf + i, wlen, out + n)) > 0) {
+			/* between two encoded-words, the gap goes */
+			if (after_word) {
+				memmove(out + n - gap, out + n, dn);
+				n -= gap;
+			}
+			n += dn;
+			i += wlen;
+			after_word = 1;
+			gap = 0;
+			continue;
+		}
+		out[n++] = unf[i++];
+		after_word = 0;
+		gap = 0;
+	}
+	free(unf);
+	*outlen = n;
+	return (out);
+}
+
+/* RFC 5322 SS3.3 allows FWS, folds included, between a date's tokens */
+static int
+date_fws(int c)
+{
+	return (c == ' ' || c == '\t' || c == '\r' || c == '\n');
+}
+
+/* RFC 9051 SS6.4.4: the day only, ignoring time and zone */
+static int
+date_field_day(const char *v, size_t vlen, int64_t *out)
+{
+	struct tm	 tm;
+	const char	*p = v, *end = v + vlen;
+	int		 day = 0, year = 0, mon, nd = 0, ny = 0;
+	time_t		 t;
+
+	while (p < end && date_fws((unsigned char)*p))
+		p++;
+	/* RFC 5322 SS3.3: day-of-week is optional */
+	if (p < end && ascii_lower((unsigned char)*p) >= 'a' &&
+	    ascii_lower((unsigned char)*p) <= 'z') {
+		while (p < end && *p != ',')
+			p++;
+		if (p == end)
+			return (-1);
+		p++;
+		while (p < end && date_fws((unsigned char)*p))
+			p++;
+	}
+	for (; p < end && *p >= '0' && *p <= '9' && nd < 2; p++, nd++)
+		day = day * 10 + (*p - '0');
+	if (nd == 0 || p == end || !date_fws((unsigned char)*p))
+		return (-1);
+	while (p < end && date_fws((unsigned char)*p))
+		p++;
+	if (end - p < 3)
+		return (-1);
+	for (mon = 0; mon < 12; mon++) {
+		if (strncasecmp(p, month_names[mon], 3) == 0)
+			break;
+	}
+	if (mon == 12)
+		return (-1);
+	p += 3;
+	if (p == end || !date_fws((unsigned char)*p))
+		return (-1);
+	while (p < end && date_fws((unsigned char)*p))
+		p++;
+	for (; p < end && *p >= '0' && *p <= '9' && ny < 4; p++, ny++)
+		year = year * 10 + (*p - '0');
+	if (ny < 2 || day < 1 || day > 31)
+		return (-1);
+	if (ny == 2)
+		year += (year < 50) ? 2000 : 1900;
+	else if (ny == 3)
+		year += 1900;
+	if (year < 1970)
+		return (-1);
+
+	memset(&tm, 0, sizeof(tm));
+	tm.tm_mday = day;
+	tm.tm_mon = mon;
+	tm.tm_year = year - 1900;
+	if ((t = timegm(&tm)) == (time_t)-1)
+		return (-1);
+	*out = (int64_t)t;
+	return (0);
+}
+
+static int
+read_header(int fd, const char *basename, char **buf_out, size_t *len_out)
+{
+	char	*buf = NULL, *nbuf;
+	size_t	 len = 0, size = 0, hdrend, limit;
+	ssize_t	 n;
+
+	/* one octet past the cap tells a header at it from one over it */
+	limit = (size_t)bodystructure_read_max + 1;
+	for (;;) {
+		if (len == size) {
+			if (size == limit)
+				break;
+			size = (limit - size > SEARCH_READ_BLOCK) ?
+			    size + SEARCH_READ_BLOCK : limit;
+			if ((nbuf = realloc(buf, size)) == NULL) {
+				log_warn("session %u: realloc SEARCH header",
+				    session_id);
+				free(buf);
+				return (-1);
+			}
+			buf = nbuf;
+		}
+		if ((n = read(fd, buf + len, size - len)) == -1) {
+			if (errno == EINTR)
+				continue;
+			log_warn("session %u: read message header (%s)",
+			    session_id, basename);
+			free(buf);
+			return (-1);
+		}
+		if (n == 0)
+			break;
+		len += (size_t)n;
+		if (find_header_body_split(buf, len, &hdrend) == 0) {
+			len = hdrend;
+			break;
+		}
+	}
+	if (len > bodystructure_read_max) {
+		log_warnx("session %u: message %s has no header end within "
+		    "%u bytes, SEARCH cannot check it", session_id, basename,
+		    bodystructure_read_max);
+		free(buf);
+		return (-1);
+	}
+	*buf_out = buf;
+	*len_out = len;
+	return (0);
+}
+
+/* An RFC 5322 SS3.2.4 quoted string's content with its quoted-pairs undone */
+static char *
+unescape_name(const char *v, size_t vlen, size_t *outlen)
+{
+	char	*out;
+	size_t	 i, n = 0;
+
+	if ((out = malloc(vlen + 1)) == NULL)
+		return (NULL);
+	for (i = 0; i < vlen; i++) {
+		if (v[i] == '\\' && i + 1 < vlen)
+			i++;
+		out[n++] = v[i];
+	}
+	*outlen = n;
+	return (out);
+}
+
+/* RFC 9051 SS6.4.4: addresses as ENVELOPE shows them */
+static int
+match_addresses(const char *v, size_t vlen, const char *needle,
+    size_t needlelen)
+{
+	const char	*tok, *name, *mbox, *host;
+	char		*unf, *spec, *raw, *dec;
+	size_t		 i, ulen = 0, pos = 0, toklen, namelen, mboxlen;
+	size_t		 hostlen, rawlen, declen;
+	int		 hit = 0;
+
+	/* RFC 5322 SS2.2.3: unfolding drops CR and LF */
+	if ((unf = malloc(vlen + 1)) == NULL)
+		return (-1);
+	for (i = 0; i < vlen; i++) {
+		if (v[i] != '\r' && v[i] != '\n')
+			unf[ulen++] = v[i];
+	}
+
+	while (hit == 0 && address_list_next(unf, ulen, &pos, &tok,
+	    &toklen) == 1) {
+		if (address_split(tok, toklen, &name, &namelen, &mbox,
+		    &mboxlen, &host, &hostlen) == -1)
+			continue;
+		if ((spec = malloc(mboxlen + 1 + hostlen)) == NULL) {
+			hit = -1;
+			break;
+		}
+		memcpy(spec, mbox, mboxlen);
+		spec[mboxlen] = '@';
+		memcpy(spec + mboxlen + 1, host, hostlen);
+		hit = ci_contains(spec, mboxlen + 1 + hostlen, needle,
+		    needlelen);
+		free(spec);
+		if (hit || name == NULL)
+			continue;
+		if ((raw = unescape_name(name, namelen, &rawlen)) == NULL) {
+			hit = -1;
+			break;
+		}
+		dec = decode_value(raw, rawlen, &declen);
+		free(raw);
+		if (dec == NULL) {
+			hit = -1;
+			break;
+		}
+		hit = ci_contains(dec, declen, needle, needlelen);
+		free(dec);
+	}
+	free(unf);
+	return (hit);
+}
+
+static int
+match_leaf(const char *hdr, size_t hdrlen,
+    const struct imsg_parser_leaf *l, const char *pool)
+{
+	const char	*name, *val, *want;
+	size_t		 namelen, vallen, wantlen, off = 0, dlen;
+	char		*dec;
+	int64_t		 day;
+	int		 hit = 0, date = 0, addr = 0;
+
+	switch (l->op) {
+	case SEARCH_OP_SUBJECT:
+		want = "Subject";
+		wantlen = 7;
+		break;
+	case SEARCH_OP_HEADER:
+		want = pool + l->name_off;
+		wantlen = l->name_len;
+		break;
+	case SEARCH_OP_SENTBEFORE:
+	case SEARCH_OP_SENTON:
+	case SEARCH_OP_SENTSINCE:
+		want = "Date";
+		wantlen = 4;
+		date = 1;
+		break;
+	case SEARCH_OP_FROM:
+		want = "From";
+		wantlen = 4;
+		addr = 1;
+		break;
+	case SEARCH_OP_TO:
+		want = "To";
+		wantlen = 2;
+		addr = 1;
+		break;
+	case SEARCH_OP_CC:
+		want = "Cc";
+		wantlen = 2;
+		addr = 1;
+		break;
+	case SEARCH_OP_BCC:
+		want = "Bcc";
+		wantlen = 3;
+		addr = 1;
+		break;
+	default:
+		return (0);
+	}
+
+	while (!hit && header_next_field(hdr, hdrlen, &off, &name, &namelen,
+	    &val, &vallen) == 1) {
+		if (namelen != wantlen || strncasecmp(name, want, wantlen) != 0)
+			continue;
+		if (date) {
+			/* the first Date: decides; RFC 5322 SS3.6 allows one */
+			if (date_field_day(val, vallen, &day) == -1)
+				return (0);
+			if (l->op == SEARCH_OP_SENTBEFORE)
+				return (day < l->num);
+			if (l->op == SEARCH_OP_SENTON)
+				return (day == l->num);
+			return (day >= l->num);
+		}
+		if (addr) {
+			hit = match_addresses(val, vallen, pool + l->str_off,
+			    l->str_len);
+			if (hit == -1)
+				return (-1);
+			continue;
+		}
+		if ((dec = decode_value(val, vallen, &dlen)) == NULL)
+			return (-1);
+		hit = ci_contains(dec, dlen, pool + l->str_off, l->str_len);
+		free(dec);
+	}
+	return (hit);
+}
+
+int
+search_match(int fd, const char *basename,
+    const struct imsg_parser_leaf *leaves, uint32_t nleaves,
+    const char *pool, char **buf_out, uint32_t *len_out)
+{
+	char		*hdr, *out;
+	size_t		 hdrlen;
+	uint32_t	 i;
+	int		 m;
+
+	*buf_out = NULL;
+	*len_out = 0;
+	if (nleaves == 0 || read_header(fd, basename, &hdr, &hdrlen) == -1)
+		return (-1);
+	if ((out = malloc(nleaves)) == NULL) {
+		free(hdr);
+		return (-1);
+	}
+	for (i = 0; i < nleaves; i++) {
+		if ((m = match_leaf(hdr, hdrlen, &leaves[i], pool)) == -1) {
+			free(out);
+			free(hdr);
+			return (-1);
+		}
+		out[i] = (char)m;
+	}
+	free(hdr);
+	*buf_out = out;
+	*len_out = nleaves;
+	return (0);
+}
blob - 9631a04f8feb7ca94034ebea550d884825269adc
blob + 7e705ce2ffaab5a8a2440376b5d57698a08f9620
--- src/store_cmd.c
+++ src/store_cmd.c
@@ -16,10 +16,6 @@
  * 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.
- */
 
 #include <sys/types.h>
 #include <sys/queue.h>
@@ -48,7 +44,7 @@
 int
 cmd_idle(struct session *s, const char *tag, char *args)
 {
-	/* RFC 2177 takes no args, like other zero-arg commands */
+	/* RFC 9051 SS6.3.13: IDLE takes no arguments */
 	(void)args;
 
 	if (strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
@@ -61,14 +57,13 @@ cmd_idle(struct session *s, const char *tag, char *arg
 
 	if (s->state == SESSION_SELECTED) {
 		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, no untagged EXPUNGE (silent=1). */
+/* RFC 9051 SS6.4.1 CLOSE: a silent EXPUNGE */
 int
 cmd_close(struct session *s, const char *tag, char *args)
 {
@@ -76,7 +71,7 @@ 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 local state change. */
+/* RFC 9051 SS6.4.2 UNSELECT */
 int
 cmd_unselect(struct session *s, const char *tag, char *args)
 {
@@ -87,7 +82,7 @@ cmd_unselect(struct session *s, const char *tag, char 
 	return (1);
 }
 
-/* SS6.4.3: sends real IMSG_MBOX_EXPUNGE via session_request_expunge(). */
+/* RFC 9051 SS6.4.3 EXPUNGE */
 int
 cmd_expunge(struct session *s, const char *tag, char *args)
 {
@@ -95,7 +90,6 @@ cmd_expunge(struct session *s, const char *tag, char *
 	return session_request_expunge(s, tag, 0, 0, NULL, 0);
 }
 
-/* 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)
@@ -120,10 +114,7 @@ parse_store_flags(char *flagspec, uint32_t *sysflags_o
 		p[len - 1] = '\0';
 		p++;
 	}
-	/*
-	 * Empty flag-list is valid ABNF; SET (bare) may use it
-	 * (cmd_store_cmd()).
-	 */
+	/* an empty flag-list is valid */
 	if (*p == '\0')
 		return (0);
 
@@ -160,10 +151,7 @@ parse_store_flags(char *flagspec, uint32_t *sysflags_o
 			return (-2);
 		}
 
-		/*
-		 * No RFC flag-list cap; checked explicitly, not via strlcat(3)
-		 * truncation.
-		 */
+		/* no flag-list cap in the RFC; checked, not truncated */
 		if (!first) {
 			if (strlcat(keywords_out, ",", keywords_out_size) >=
 			    keywords_out_size) {
@@ -183,10 +171,7 @@ 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 UNCHANGEDSINCE; 0 is legal */
 int
 parse_store_modifiers(char *modspec, struct imsg_mbox_store *req,
     const char **errmsg)
@@ -214,13 +199,7 @@ 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.
-			 */
+			/* strtoull(3) accepts a sign */
 			if (*valtok < '0' || *valtok > '9') {
 				*errmsg = "invalid UNCHANGEDSINCE mod-sequence";
 				return (-1);
@@ -242,17 +221,13 @@ parse_store_modifiers(char *modspec, struct imsg_mbox_
 	return (0);
 }
 
-/* SS6.4.6: store-modifiers *lead* here, unlike FETCH's trailing ones. */
+/* RFC 9051 SS6.4.6: store-modifiers lead */
 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).
- */
 int
 store_do(struct session *s, const char *tag, char *args, int by_uid)
 {
@@ -301,7 +276,6 @@ store_do(struct session *s, const char *tag, char *arg
 			}
 			p++;
 		}
-		/* p points at the matching ')' for modspec (== args) */
 		modspec = args;
 		p++;	/* just past ')' */
 		if (*p == '\0') {
@@ -378,27 +352,19 @@ 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: EXAMINE is read-only */
 		session_reply(s, tag, "NO", "[CANNOT] Mailbox is read-only "
 		    "(selected via EXAMINE)");
 		return (1);
 	}
 
 	if (s->store_iev == NULL) {
-		/* ST_SELECTED requires store_iev to already be wired. */
 		log_warnx("session %u: %s with no store channel wired",
 		    s->id, cmdname);
 		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
 		return (1);
 	}
 
-	/*
-	 * req already memset(3)'d; has_unchangedsince set earlier, kept as is
-	 * here.
-	 */
 	req.nranges = nranges;
 	req.mode = mode;
 	req.silent = silent;
@@ -412,15 +378,10 @@ store_do(struct session *s, const char *tag, char *arg
 		return (1);
 	}
 
-	/* RFC 7162 SS3.1: UNCHANGEDSINCE is a CONDSTORE-enabling command. */
+	/* RFC 7162 SS3.1: UNCHANGEDSINCE enables CONDSTORE */
 	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
-	 */
 	free(s->store_modified);
 	s->store_modified = NULL;
 	s->store_modified_n = 0;
@@ -440,10 +401,7 @@ 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 COPY and SS6.4.8 MOVE */
 int
 copy_move_dispatch(struct session *s, const char *tag, char *args,
     int by_uid, int is_move)
@@ -507,17 +465,13 @@ copy_move_dispatch(struct session *s, const char *tag,
 	}
 
 	if (is_move && s->mbox_readonly) {
-		/*
-		 * MOVE removes msgs (SS6.4.8), forbidden by SS6.3.3 on
-		 * EXAMINE'd; COPY OK.
-		 */
+		/* RFC 9051 SS6.3.3: MOVE removes messages; COPY may proceed */
 		session_reply(s, tag, "NO", "[CANNOT] Mailbox is read-only "
 		    "(selected via EXAMINE)");
 		return (1);
 	}
 
 	if (s->store_iev == NULL) {
-		/* ST_SELECTED requires store_iev to already be wired. */
 		log_warnx("session %u: %s with no store channel wired",
 		    s->id, cmdname);
 		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
@@ -563,7 +517,7 @@ cmd_move(struct session *s, const char *tag, char *arg
 	return copy_move_dispatch(s, tag, args, 0, 1);
 }
 
-/* SS6.4.9 UID; dispatches to the same *_dispatch() bodies, by_uid=1. */
+/* RFC 9051 SS6.4.9 UID */
 int
 cmd_uid(struct session *s, const char *tag, char *args)
 {
@@ -603,7 +557,7 @@ cmd_uid(struct session *s, const char *tag, char *args
 	return (1);
 }
 
-/* SS7.5.1 untagged EXPUNGE; RFC7162 SS3.2.10.2 sends VANISHED <uid> instead. */
+/* RFC 9051 SS7.5.1 EXPUNGE, or RFC 7162 SS3.2.10.2 VANISHED */
 void
 session_send_expunge_response(struct session *s,
     const struct imsg_mbox_expunged *exp)
@@ -617,7 +571,7 @@ session_send_expunge_response(struct session *s,
 	session_untagged(s, buf);
 }
 
-/* RFC 7162 SS3.2.6: VANISHED (EARLIER) range written now; SELECT buffers */
+/* RFC 7162 SS3.2.6 VANISHED (EARLIER) */
 void
 session_handle_fetch_vanished(struct session *s,
     const struct imsg_mbox_select_vanished *v)
@@ -632,7 +586,7 @@ session_handle_fetch_vanished(struct session *s,
 	session_untagged(s, buf);
 }
 
-/* Shared by expunge/close/UID EXPUNGE: '*' is always a seqno, even for UID. */
+/* '*' is always a sequence number here */
 int
 session_request_expunge(struct session *s, const char *tag, int is_close,
     int by_uid, const struct seq_range *ranges, uint32_t nranges)
@@ -643,25 +597,18 @@ session_request_expunge(struct session *s, const char 
 
 	if (s->mbox_readonly) {
 		if (is_close) {
-			/*
-			 * SS6.4.1: EXAMINE'd, skip round trip; just deselect
-			 * and reply OK.
-			 */
+			/* RFC 9051 SS6.4.1: read-only, so just deselect */
 			s->state = SESSION_AUTHENTICATED;
 			session_reply(s, tag, "OK", "CLOSE completed");
 			return (1);
 		}
-		/*
-		 * Unlike CLOSE, EXPUNGE has no read-only exception; SS6.3.3
-		 * applies here.
-		 */
+		/* RFC 9051 SS6.3.3: no read-only exception for EXPUNGE */
 		session_reply(s, tag, "NO", "[CANNOT] Mailbox is read-only "
 		    "(selected via EXAMINE)");
 		return (1);
 	}
 
 	if (s->store_iev == NULL) {
-		/* ST_SELECTED requires store_iev to already be wired. */
 		log_warnx("session %u: %s with no store channel wired",
 		    s->id, cmdname);
 		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
@@ -693,10 +640,6 @@ 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.
- */
 int
 uid_expunge_dispatch(struct session *s, const char *tag, const char *args)
 {
@@ -717,14 +660,7 @@ 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).
- */
+/* RFC 7162 SS3.1.3 MODIFIED entry */
 void
 session_handle_store_modified(struct session *s,
     struct imsg_mbox_store_modified *m)
@@ -748,25 +684,11 @@ session_handle_store_modified(struct session *s,
 		s->store_modified_cap = newcap;
 	}
 
-	/*
-	 * 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.
- */
+/* refuses rather than truncates when it would not fit */
 static size_t
 append_range_token(char *buf, size_t bufsize, size_t written, int *first,
     uint32_t lo, uint32_t hi, int *truncated)
@@ -795,7 +717,7 @@ append_range_token(char *buf, size_t bufsize, size_t w
 	return (written);
 }
 
-/* Formats nums as comma-sep bare/lo:hi ranges (SS7.3.4 ESEARCH); pre-sorted. */
+/* RFC 9051 SS7.3.4 sequence list; input sorted */
 size_t
 format_seq_list(char *buf, size_t bufsize, const uint32_t *nums, uint32_t n,
     int *truncated)
@@ -813,10 +735,6 @@ format_seq_list(char *buf, size_t bufsize, const uint3
 		uint32_t	j = i + 1;
 		size_t		w;
 
-		/*
-		 * this function's compaction: collapse an ascending run into a
-		 * lo:hi token
-		 */
 		while (j < n && nums[j] == end + 1) {
 			end = nums[j];
 			j++;
@@ -833,7 +751,7 @@ format_seq_list(char *buf, size_t bufsize, const uint3
 	return (written);
 }
 
-/* RFC7162 sibling of format_seq_list() for VANISHED; ranges pre-compacted. */
+/* RFC 7162 VANISHED ranges, already compacted */
 size_t
 format_range_list(char *buf, size_t bufsize,
     const struct vanished_range *ranges,
@@ -846,10 +764,6 @@ format_range_list(char *buf, size_t bufsize,
 	*truncated = 0;
 	buf[0] = '\0';
 
-	/*
-	 * no compaction here, unlike format_seq_list(): store.c precompacts
-	 * these
-	 */
 	for (i = 0; i < n; i++) {
 		size_t	w;
 
@@ -863,18 +777,10 @@ format_range_list(char *buf, size_t bufsize,
 	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.
- */
 #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.
- */
+/* RFC 9051 SS6.4.7/SS6.4.8 terminal reply */
 void
 session_finish_copy_or_move(struct session *s,
     const struct imsg_mbox_result *res)
@@ -889,10 +795,7 @@ session_finish_copy_or_move(struct session *s,
 	if (res->error != MBOX_OP_OK || s->copy_alloc_failed) {
 		char	text[48];
 
-		/*
-		 * SS6.4.7/8: destname valid, not found: TRYCREATE (as
-		 * imsg_mbox_appended).
-		 */
+		/* RFC 9051 SS6.4.7: TRYCREATE */
 		if (!s->copy_alloc_failed &&
 		    res->error == MBOX_OP_ERR_NO_SUCH_MAILBOX)
 			snprintf(text, sizeof(text), "[TRYCREATE] no such "
@@ -906,10 +809,6 @@ session_finish_copy_or_move(struct session *s,
 		goto cleanup;
 	}
 
-	/*
-	 * 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;
 
 
@@ -918,13 +817,6 @@ session_finish_copy_or_move(struct session *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.
-		 */
 		listsize = (size_t)s->copy_n * COPYUID_PER_ENTRY + 1;
 		textsize = 2 * listsize + COPYUID_WRAPPER_MAX;
 
@@ -942,10 +834,7 @@ session_finish_copy_or_move(struct session *s,
 				log_warnx("session %u: COPYUID dest list "
 				    "truncated", s->id);
 
-			/*
-			 * MOVE's COPYUID untagged (OK follows EXPUNGEs); COPY
-			 * rides its own OK.
-			 */
+			/* MOVE sends COPYUID untagged, before the EXPUNGEs */
 			if (s->cmd_is_move)
 				n = snprintf(text, textsize,
 				    "* OK [COPYUID %u %s %s]\r\n",
@@ -976,10 +865,7 @@ session_finish_copy_or_move(struct session *s,
 
 			session_reply(s, s->pending_tag, "OK", "Done");
 		} else if (!sent) {
-			/*
-			 * SS6.4.7: COPYUID is a SHOULD; omitting is legal,
-			 * truncating is not.
-			 */
+			/* RFC 9051 SS6.4.7: a SHOULD; never truncated */
 			char	fallback[64];
 
 			snprintf(fallback, sizeof(fallback), "%s completed",
blob - 5ae316a2795414fad66a9ceb21cd3dbf5f43c4dc
blob + 09d189f53097622242549e88eafd6fa569c58af5
--- src/store_internal.h
+++ src/store_internal.h
@@ -16,14 +16,11 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/*
- * store_internal.h, internal, store-process-only shared declarations.
- */
-
 #ifndef STORE_INTERNAL_H
 #define STORE_INTERNAL_H
 
 #include <sys/types.h>
+#include <sys/queue.h>
 
 #include <stdint.h>
 
@@ -32,27 +29,16 @@
 struct mbox_index {
 	uint32_t	  uidvalidity;
 	uint32_t	  uidnext;
-	uint64_t	  highestmodseq; /* RFC 7162 SS3.1: per-mailbox highest
-					 * mod-sequence, persisted as the
-					 * index header's third field (see
-					 * index_load()/index_save()). Always
-					 * supported, so NOMODSEQ (RFC 7162
-					 * SS3.1.2.2) is unreachable here. */
-	/* 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. */
+	uint64_t	  highestmodseq;
+	/* no index was found: persist this UIDVALIDITY before unlocking */
 	int		  fresh;
-	/* raw "UID:basename:keywords:MODSEQ" lines, no trailing newline, */
-	/* one malloc(3) each; see index_parse_line() */
+	/* raw "UID:basename:keywords:MODSEQ" lines */
 	char		**lines;
 	size_t		  nlines;
 	size_t		  cap;
 };
 
-/* 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. */
+/* a three-field pre-CONDSTORE line parses with modseq 1 */
 struct index_rec {
 	uint32_t	uid;
 	char		basename[512];
@@ -60,100 +46,169 @@ struct index_rec {
 	uint64_t	modseq;
 };
 
-/* 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;
 	char		basename[256];
 	char		keywords[512];
 	uint32_t	sysflags;
-	void		*body;
-	size_t		bodylen;
+	char		*srcpath;
+	off_t		srcsize;
 };
 
-/* per message */
-#define COPY_STAGE_MSG_MAX	((uint64_t)64 * 1024 * 1024)
-/* per operation */
-#define COPY_STAGE_TOTAL_MAX	((uint64_t)512 * 1024 * 1024)
+/* a FETCH walk, paused while its output drains */
+struct store_fetch_walk {
+	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;
+	size_t			 composed;
+	int			 fds;
+	int			 wait_parser;	/* paused until one comes */
+	int			 no_parser;	/* none to be had: go without */
+};
 
-/* Globals shared across the files this process's source is split into
- * (definitions live in store.c's core).
- */
+/* RFC 9051 SS6.4.4 SEARCH, yielding every SEARCH_YIELD_REQUESTS */
+struct store_search_walk {
+	int			 active;
+	int			 wait_parser;	/* paused until one comes */
+	struct event		 yield_ev;
+	struct mbox_index	 idx;
+	struct search_node	 nodes[SEARCH_PROGRAM_MAX_NODES];
+	uint32_t		 nnodes;
+	uint32_t		 leafidx[SEARCH_PROGRAM_MAX_NODES];
+	uint32_t		 nleaves;
+	uint32_t		 poollen;
+	char			 extra[SEARCH_PROGRAM_MAX_NODES *
+				    sizeof(struct imsg_parser_leaf) +
+				    SEARCH_OPERANDS_MAX];
+	size_t			 extralen;
+	uint32_t		 next;		/* index line to resume at */
+	uint32_t		 sent;
+	uint32_t		 asked;
+};
+
+/* two stat(2) calls, no lock: "." and "new" */
+struct store_idle_probe {
+	int		 valid;
+	ino_t		 dir_ino;
+	ino_t		 new_ino;
+	struct timespec	 dir_mtim;
+	struct timespec	 new_mtim;
+};
+
+/* UIDs last reported, so a change costs one imsg, not one per message */
+struct store_idle_baseline {
+	uint32_t	*uids;
+	size_t		 n;
+	uint64_t	 modseq;	/* highest reported; above it is news */
+	int		 valid;
+};
+
+/* RFC 9051 SS6.3.12 APPEND; a failure is held until END */
+struct store_append {
+	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];
+};
+
+/* cur/ read once per command; discarded when the command is done */
+struct cur_snapshot {
+	int	  dfd;		/* directory described, -1 when empty */
+	int	  failed;
+	char	**names;
+	size_t	  n;
+};
+
+/* one command waiting on the index lock; re-run from the top */
+struct store_deferred {
+	int			 active;
+	uint32_t		 type;
+	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;
+};
+
+struct store_session {
+	uint32_t			 id;
+	TAILQ_ENTRY(store_session)	 entry;
+	struct imsgev			 iev;
+
+	/* a failed SELECT leaves nothing selected (RFC 9051 SS6.3.2) */
+	int				 mailbox_selected;
+
+	/* "" for INBOX */
+	char				 selected_mailbox[MBOX_NAME_MAX];
+
+	/* lookups relative to it are unveiled (sys/kern/vfs_lookup.c) */
+	int				 mailbox_dir_fd;
+
+	struct cur_snapshot		 cur_snap;
+	struct store_deferred		 deferred;
+	struct store_fetch_walk		 fetch;
+	struct store_search_walk	 search;
+	struct store_idle_probe		 idle_probe;
+	struct store_idle_baseline	 idle_baseline;
+	struct store_append		 append;
+};
+
 extern uint32_t	 session_id;
-extern uint32_t	 append_counter; /* per-store-child monotonic counter
-				 * feeding the maildir basename uniquer
-				 * (see handle_mbox_append_begin()); shared with
-				 * handle_mbox_copy() so a copy's minted
-				 * basename can't collide with an APPEND's */
-/*
- * 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 uint32_t	 append_counter; /* unique across APPEND and COPY */
 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.
- */
-extern uint32_t	 bodystructure_read_max; /* from IMSG_STORE_INIT; see
-					 * imapd.h's imsg_store_init and
-					 * 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	 bodystructure_read_max;
+extern uint64_t	 append_max;
 extern uint32_t	 lock_timeout_secs;
 
 void	 store_dispatch(int, short, void *);
-void	 store_shutdown(void);
-/* 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 *);
+		    const struct seq_range *, uint32_t, struct store_session *);
 int	 handle_mbox_fetch(struct imsg_mbox_fetch *,
-		    const struct seq_range *, uint32_t, struct imsgev *);
+		    const struct seq_range *, uint32_t, struct store_session *);
 int	 handle_mbox_store(struct imsg_mbox_store *,
-		    const struct seq_range *, uint32_t, struct imsgev *);
+		    const struct seq_range *, uint32_t, struct store_session *);
 int	 handle_mbox_expunge(struct imsg_mbox_expunge *,
-		    const struct seq_range *, uint32_t, struct imsgev *);
-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);
+		    const struct seq_range *, uint32_t, struct store_session *);
+void	 handle_mbox_append_begin(struct store_session *,
+		    const struct imsg_mbox_append *);
+void	 handle_mbox_append_data(struct store_session *, const char *,
+		    size_t);
+int	 handle_mbox_append_end(struct store_session *);
+void	 append_abort(struct store_session *);
 int	 handle_mbox_search(struct imsg_mbox_search *,
-		    struct search_node *, uint32_t, struct imsgev *);
-int	 handle_mbox_status(struct imsg_mbox_status *, struct imsgev *);
+		    struct search_node *, uint32_t, struct store_session *);
+int	 handle_mbox_status(struct imsg_mbox_status *, struct store_session *);
 int	 handle_mbox_copy(struct imsg_mbox_copy *,
-		    const struct seq_range *, uint32_t, struct imsgev *);
+		    const struct seq_range *, uint32_t, struct store_session *);
 int	 handle_mbox_move(struct imsg_mbox_copy *,
-		    const struct seq_range *, uint32_t, struct imsgev *);
+		    const struct seq_range *, uint32_t, struct store_session *);
 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_delete(struct imsg_mbox_delete *, struct store_session *);
+void	 handle_mbox_rename(struct imsg_mbox_rename *, struct store_session *);
 void	 handle_mbox_list(struct imsg_mbox_list *, struct imsgev *);
 void	 handle_mbox_subscribe(struct imsg_mbox_subscribe *,
 	    struct imsgev *);
@@ -161,18 +216,36 @@ void	 handle_mbox_unsubscribe(struct imsg_mbox_subscri
 	    struct imsgev *);
 int	 mailbox_name_valid(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,
+void	 cur_snapshot_discard(struct cur_snapshot *);
+int	 fetch_walk_paused(struct store_session *);
+void	 fetch_walk_resume(struct store_session *);
+void	 fetch_walk_abort(struct store_session *);
+void	 fetch_walk_parser(struct store_session *, int);
+int	 search_walk_paused(struct store_session *);
+void	 search_walk_abort(struct store_session *);
+void	 search_walk_parser(struct store_session *, int);
+int	 search_op_content(int);
+int	 search_match(int, const char *, const struct imsg_parser_leaf *,
+		    uint32_t, const char *, char **, uint32_t *);
+int	 header_next_field(const char *, size_t, size_t *, const char **,
+		    size_t *, const char **, size_t *);
+int	 address_split(const char *, size_t, const char **, size_t *,
+		    const char **, size_t *, const char **, size_t *);
+int	 address_list_next(const char *, size_t, size_t *, const char **,
+		    size_t *);
+int	 parser_ready(void);
+int	 locate_message_file(struct cur_snapshot *, int, const char *,
+		    off_t *, char *, size_t);
+int	 open_message_file(struct cur_snapshot *, int, const char *);
+int	 read_message_header(struct cur_snapshot *, int, const char *,
+		    char **, uint32_t *);
+int	 read_header_from_fd(int, const char *, char **, uint32_t *);
+int	 read_body_from_fd(int, const char *, int, size_t,
 		    const char *,
 		    char **, uint32_t *);
 int	 header_field_name_matches(const char *, size_t, const char *);
+int	 filter_header_fields(const char *, size_t, const char *, int,
+		    char *, size_t *);
 int	 read_message_header_fields(int, const char *, const char *,
 		    int,
 		    char **, uint32_t *);
@@ -191,6 +264,9 @@ int	 envbuf_append_address_list(char *, size_t, size_t
 int	 append_field_nstring(char *, size_t, size_t *, const char *,
 		    uint32_t, const char *);
 int	 build_envelope(int, const char *, char **, uint32_t *);
+int	 parser_request(uint32_t, const char *, int, const char *,
+		    struct imsg_parser_req *, void *, size_t, uint32_t,
+		    struct imsg_parser_rep *, char **);
 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 *,
@@ -211,48 +287,12 @@ int	 locate_mime_part(const char *, size_t, const char
 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 *);
+int	 message_body_range(struct cur_snapshot *, int, const char *, int,
+		    uint64_t *, uint64_t *, int *);
 
-/*
- * 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. 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 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. */
-
-/* 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. */
+/* the index is opened after the lock; init with INDEX_LOCK_INIT */
 struct index_lock {
 	int	lockfd;
 	int	fd;
@@ -260,21 +300,14 @@ struct index_lock {
 
 #define INDEX_LOCK_INIT	{ -1, -1 }
 
-/* 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(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 --
-					 * safe to write into a
-					 * colon-delimited index line */
-int	 index_basename_valid(const char *);	/* additionally no '/', no
-					 * leading '.', no control bytes --
-					 * safe to paste into "new/%s" */
+int	 index_field_valid(const char *);	/* no ':', CR or LF */
+int	 index_basename_valid(const char *);	/* safe in "new/%s" */
 uint32_t	 index_max_uid(struct mbox_index *);
 void	 send_vanished_range(const struct mbox_index *, uint32_t, uint32_t,
 		    struct imsgev *);
@@ -283,10 +316,7 @@ 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. 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. */
+/* PAST_END holds only because the scans walk in ascending order */
 enum seqset_pos {
 	SEQSET_PAST_END,
 	SEQSET_SKIP,
@@ -297,20 +327,16 @@ enum seqset_pos	 seqset_position(const struct seq_rang
 		    int, uint32_t, uint32_t);
 int	 refresh_index(int, struct mbox_index *, int);
 uint32_t uidvalidity_next(void);
-void	 idle_probe_reset(void);
-void	 idle_baseline_reset(void);
+void	 idle_probe_reset(struct store_session *);
+void	 idle_baseline_reset(struct store_session *);
 int	 index_lock_acquire(int, struct index_lock *, int);
 void	 index_lock_release(struct index_lock *);
 void	 handle_mbox_idle_refresh(const struct imsg_mbox_idle_refresh *,
-		    struct imsgev *);
+		    struct store_session *);
 void	 qresync_send_resync(const struct imsg_mbox_select *,
 		    const struct seq_range *, uint32_t, struct mbox_index *,
-		    struct imsgev *);
+		    struct store_session *);
 
-/*
- * The remaining six are single-purpose helpers that happen to be called
- * from more than one of the split-out files.
- */
 const char	*append_hostname(void);
 int		 ensure_maildir_dirs(int, const char *);
 uint32_t	 letters_to_sysflags(const char *);
blob - 68036f9bd0c0512b3cf54cf574d492051f023cb2
blob + 760d3f6ac69ae23a6461fef09899118f0e047e3d
--- src/store_ipc.c
+++ src/store_ipc.c
@@ -16,11 +16,6 @@
  * 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).
- */
 
 #include <sys/types.h>
 #include <sys/queue.h>
@@ -45,7 +40,6 @@
 #include "log.h"
 #include "listener.h"
 
-/* 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,
@@ -69,7 +63,7 @@ fetch_part_recv(struct session *s, struct imsg *imsg, 
 		return;
 	}
 	if (wirelen == 0) {
-		*foundp = 1;	/* found but empty is legitimate, see callers */
+		*foundp = 1;
 		return;
 	}
 	if ((*bufp = malloc(wirelen)) == NULL) {
@@ -94,10 +88,6 @@ 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
-	 * writable.
-	 */
 	if (event & EV_WRITE) {
 		if (imsgbuf_write(&s->store_iev->ibuf) == -1) {
 			log_warnx("session %u: imsgbuf_write (store)", s->id);
@@ -152,10 +142,6 @@ session_store_dispatch(int fd, short event, void *arg)
 		case IMSG_MBOX_FETCH_HEADER: {
 			struct imsg_mbox_fetch_header	 hdr;
 
-			/*
-			 * 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)");
@@ -170,12 +156,6 @@ session_store_dispatch(int fd, short event, void *arg)
 			struct imsg_mbox_fetch_body	 bodyhdr;
 			int				 bodyfd;
 
-			/*
-			 * 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);
@@ -209,10 +189,6 @@ session_store_dispatch(int fd, short event, void *arg)
 		case IMSG_MBOX_FETCH_ENVELOPE: {
 			struct imsg_mbox_fetch_envelope	 envhdr;
 
-			/*
-			 * 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 "
@@ -228,10 +204,6 @@ session_store_dispatch(int fd, short event, void *arg)
 		case IMSG_MBOX_FETCH_BODYSTRUCTURE: {
 			struct imsg_mbox_fetch_bodystructure	 bshdr;
 
-			/*
-			 * 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 "
@@ -253,19 +225,9 @@ 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() does not NUL-terminate */
 			meta.flags[sizeof(meta.flags) - 1] = '\0';
-			/*
-			 * Shared reply type for FETCH/STORE/QRESYNC resync;
-			 * SELECTING buffers per RFC 7162 SS3.2.6 order.
-			 */
+			/* SELECTING buffers, for RFC 7162 SS3.2.6 order */
 			if (s->state == SESSION_SELECTING)
 				session_handle_select_fetch(s, &meta);
 			else if (s->state == SESSION_STORING)
@@ -281,10 +243,7 @@ session_store_dispatch(int fd, short event, void *arg)
 				log_warnx("bad IMSG_MBOX_EXPUNGED");
 				break;
 			}
-			/*
-			 * During a MOVE, buffered here, flushed after COPYUID
-			 * by session_finish_copy_or_move() (SS6.4.8).
-			 */
+			/* buffered until after COPYUID (RFC 9051 SS6.4.8) */
 			if (s->state == SESSION_COPYING) {
 				if (s->move_expunged_n ==
 				    s->move_expunged_cap) {
@@ -336,10 +295,6 @@ 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 modifier;
-			 * SELECTING buffers, FETCHING writes now.
-			 */
 			if (s->state == SESSION_SELECTING)
 				session_handle_select_vanished(s, &v);
 			else
@@ -405,12 +360,7 @@ 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.
-			 */
+			/* not NUL-terminated by imsg_get_data() */
 			item.mailbox[sizeof(item.mailbox) - 1] = '\0';
 			session_handle_mbox_list_item(s, &item);
 			break;
@@ -435,18 +385,11 @@ session_store_dispatch(int fd, short event, void *arg)
 	imsgev_rearm_read(s->store_iev);
 	(void)fd;
 
-	/*
-	 * 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,
- * FETCH, tagged OK.
- */
+/* RFC 9051 SS6.3.2; for QRESYNC: VANISHED, FETCH, tagged OK */
 void
 session_handle_mbox_selected(struct session *s,
     const struct imsg_mbox_selected *res)
@@ -454,24 +397,14 @@ session_handle_mbox_selected(struct session *s,
 	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 5530 NONEXISTENT */
 		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.
-		 */
+		/* a compromised store child may have streamed resync data */
 		free(s->vanished_ranges);
 		s->vanished_ranges = NULL;
 		s->vanished_nranges = 0;
@@ -487,10 +420,6 @@ session_handle_mbox_selected(struct session *s,
 	s->state = SESSION_SELECTED;
 	s->mbox_highestmodseq = res->highestmodseq;
 
-	/*
-	 * 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);
 
@@ -508,10 +437,7 @@ session_handle_mbox_selected(struct session *s,
 		session_untagged(s, buf);
 	}
 
-	/*
-	 * PERMANENTFLAGS: EXAMINE gets none (SS6.3.3); SELECT adds "\*" since
-	 * keywords may be created (SS6.3.2).
-	 */
+	/* RFC 9051 SS6.3.3: EXAMINE gets no PERMANENTFLAGS */
 	session_untagged(s,
 	    "FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)");
 	if (s->mbox_readonly)
@@ -522,13 +448,6 @@ session_handle_mbox_selected(struct session *s,
 		    "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).
-	 */
 	{
 		char	qname[MBOX_QUOTED_MAX];
 		char	listbuf[MBOX_QUOTED_MAX + 64];
@@ -554,10 +473,6 @@ session_handle_mbox_selected(struct session *s,
 		size_t	vlen;
 		int	flen;
 
-		/*
-		 * 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)
@@ -589,10 +504,6 @@ session_handle_mbox_selected(struct session *s,
 	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, keyed off s->mbox_readonly.
-	 */
 	if (s->mbox_readonly)
 		session_reply(s, s->pending_tag, "OK",
 		    "[READ-ONLY] EXAMINE completed");
@@ -601,21 +512,13 @@ session_handle_mbox_selected(struct session *s,
 		    "[READ-WRITE] SELECT completed");
 }
 
-/*
- * Finishes STATUS (SS6.3.11); attrs emitted in fixed canonical order, not
- * request order.
- */
+/* RFC 9051 SS6.3.11; attributes in a fixed 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.
-	 */
+	/* RFC 9051 SS4.3-escaped name plus attributes */
 	char	buf[MBOX_QUOTED_MAX + 384];
 	size_t	len;
 	int	n, first = 1;
@@ -623,22 +526,13 @@ 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 an open/flock/index_load failure --
-		 * indistinguishable, so NONEXISTENT covers both.
-		 */
+		/* a missing mailbox and an I/O failure look alike */
 		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.
-	 */
 	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);
@@ -656,11 +550,7 @@ 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.
-		 */
+		/* RFC 9051 SS2.3.2 dropped \Recent; always 0 */
 		STATUS_APPEND("RECENT %u", 0U);
 	if (s->status_attrs & STATUS_ATT_UIDNEXT)
 		STATUS_APPEND("UIDNEXT %u", res->uidnext);
@@ -678,12 +568,6 @@ 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.
-	 */
 	if (len + 3 < sizeof(buf)) {
 		buf[len++] = ')';
 		buf[len++] = '\r';
@@ -699,11 +583,6 @@ session_handle_mbox_status_result(struct session *s,
 	session_reply(s, s->pending_tag, "OK", "STATUS completed");
 }
 
-/*
- * 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)
 {
@@ -727,37 +606,22 @@ 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 missing its source
-		 */
+		/* RFC 5530 SS3 NONEXISTENT */
 		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 dest
-		 */
+		/* RFC 5530 SS3 ALREADYEXISTS */
 		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.
-		 */
 		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 selection follows a RENAME */
 	if (s->state == SESSION_SELECTED &&
 	    strcmp(s->selected_mailbox, s->mbox_op_name) == 0) {
 		if (strcmp(cmdname, "RENAME") == 0) {
@@ -768,14 +632,7 @@ 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.
-			 */
+			/* the mailbox is gone: deselect, as CLOSE does */
 			s->state = SESSION_AUTHENTICATED;
 			s->selected_mailbox[0] = '\0';
 		}
@@ -785,10 +642,6 @@ session_finish_mbox_op(struct session *s, const struct
 	session_reply(s, s->pending_tag, "OK", text);
 }
 
-/*
- * 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)
@@ -802,16 +655,7 @@ session_handle_mbox_list_item(struct session *s,
 	if (!list_pattern_match(s->list_pattern, item->mailbox, 0))
 		return;
 
-	/*
-	 * 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.
-	 */
+	/* RFC 9051 SS7.3.1 attributes */
 	if (!s->list_subscribed_only)
 		attrs = "";
 	else if (s->list_is_lsub)
@@ -820,12 +664,6 @@ session_handle_mbox_list_item(struct session *s,
 		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) \"/\" %s\r\n", kw, attrs,
@@ -836,10 +674,6 @@ session_handle_mbox_list_item(struct session *s,
 		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 -- mismatches
- * already silently produce no item.
- */
 void
 session_finish_list(struct session *s, const struct imsg_mbox_result *res)
 {
@@ -858,10 +692,6 @@ 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); OOM sets copy_alloc_failed.
- */
 void
 session_handle_mbox_copy_mapping(struct session *s,
     const struct imsg_mbox_copy_mapping *m)
@@ -898,11 +728,7 @@ 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).
- */
+/* a dropped range would hide a UID from the client for ever */
 void
 session_handle_select_vanished(struct session *s,
     const struct imsg_mbox_select_vanished *v)
@@ -928,10 +754,6 @@ 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
- * ordering.
- */
 void
 session_handle_select_fetch(struct session *s,
     const struct imsg_mbox_fetch_meta *m)
@@ -956,7 +778,6 @@ session_handle_select_fetch(struct session *s,
 	s->qresync_fetches[s->qresync_nfetches++] = *m;
 }
 
-/* One untagged EXPUNGE the store child says to print, RFC 9051 SS7.5.1. */
 void
 session_handle_idle_expunge(struct session *s,
     const struct imsg_mbox_idle_expunge *item)
@@ -971,13 +792,7 @@ session_handle_idle_expunge(struct session *s,
 	session_untagged(s, buf);
 }
 
-/*
- * 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.
- */
+/* RFC 9051 SS6.3.13: an unsolicited FETCH carries UID */
 void
 session_handle_idle_fetch(struct session *s,
     const struct imsg_mbox_fetch_meta *meta)
@@ -998,12 +813,6 @@ session_handle_idle_fetch(struct session *s,
 	session_untagged(s, buf);
 }
 
-/*
- * 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_handle_idle_refreshed(struct session *s,
     const struct imsg_mbox_idle_refreshed *res)
@@ -1012,10 +821,6 @@ session_handle_idle_refreshed(struct session *s,
 
 	s->idle_refresh_pending = 0;
 
-	/*
-	 * 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);
@@ -1037,13 +842,6 @@ session_handle_idle_refreshed(struct session *s,
 	}
 }
 
-/*
- * 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, int seed)
 {
@@ -1067,13 +865,6 @@ session_request_idle_refresh(struct session *s, int se
 		    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).
- */
 static void
 session_idle_poll(int fd, short event, void *arg)
 {
@@ -1082,19 +873,7 @@ 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 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.
-		 */
 		log_debug("session %u: idle poll fired while not idling "
 		    "(idling=%d state=%d), ignored", s->id, s->idling,
 		    (int)s->state);
@@ -1105,7 +884,6 @@ session_idle_poll(int fd, short event, void *arg)
 	session_idle_poll_arm(s);	/* an evtimer is one-shot */
 }
 
-/* Once per session, early enough that session_idle_poll_disarm() is safe. */
 void
 session_idle_poll_init(struct session *s)
 {
@@ -1133,10 +911,7 @@ 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, RFC fixes content.
- */
+/* RFC 7162 SS3.2.5.1: always UID, FLAGS and MODSEQ */
 void
 session_send_qresync_fetch_response(struct session *s,
     const struct imsg_mbox_fetch_meta *meta)
@@ -1149,20 +924,10 @@ 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.
- */
+/* RFC 7162 SS3.1.3 MODIFIED, worst case per entry */
 #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 rest.
- */
 void
 session_handle_mbox_result(struct session *s, struct imsg_mbox_result *res)
 {
@@ -1180,26 +945,15 @@ session_handle_mbox_result(struct session *s, struct i
 	if (s->state == SESSION_CREATING || s->state == SESSION_DELETING ||
 	    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
-		 * 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.
-	 */
+	/* RFC 9051 SS6.4.9: "UID <CMD>" */
 	if (s->state == SESSION_STORING) {
 		cmdname = s->cmd_by_uid ? "UID STORE" : "STORE";
 		was_storing = 1;
@@ -1211,29 +965,15 @@ 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.
-	 */
 	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 9051 SS6.4.1: only a CLOSE that succeeded leaves the selected
-	 * state; a failed one removed nothing (handle_mbox_expunge()).
-	 */
+	/* RFC 9051 SS6.4.1: only a successful CLOSE deselects */
 	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);
@@ -1252,41 +992,18 @@ session_handle_mbox_result(struct session *s, struct i
 		return;
 	}
 
-	/* RFC 7162: post-op HIGHESTMODSEQ for session_condstore_enable() */
 	if (was_storing || was_expunging)
 		s->mbox_highestmodseq = res->highestmodseq;
 
-	/*
-	 * 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 MODIFIED */
 	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.
-		 */
 		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).
-		 */
 		rbufsize = (size_t)s->store_modified_n * MODIFIED_PER_ENTRY + 1;
 		textsize = rbufsize + MODIFIED_WRAPPER_MAX;
 
@@ -1296,14 +1013,6 @@ 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.
-				 */
 				if (truncated) {
 					log_warnx("session %u: MODIFIED list "
 					    "truncated", s->id);
@@ -1332,20 +1041,7 @@ 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.
-		 */
+		/* RFC 7162 SS3.1.3: an incomplete MODIFIED is refused */
 		if (incomplete) {
 			char	fail[64];
 
@@ -1368,10 +1064,7 @@ session_handle_mbox_result(struct session *s, struct i
 	s->store_modified_alloc_failed = 0;
 
 
-	/*
-	 * RFC 7162 SS3.2.7: real EXPUNGE (not CLOSE, forbidden by SS3.2.8) with
-	 * count>0 gets HIGHESTMODSEQ if CONDSTORE.
-	 */
+	/* RFC 7162 SS3.2.7, but never on CLOSE (SS3.2.8) */
 	if (was_expunging && !was_close && s->condstore_enabled &&
 	    res->count > 0) {
 		char	text[64];
@@ -1383,14 +1076,7 @@ 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.
-	 */
+	/* RFC 9051 SS6.4.5 */
 	if (s->fetch_incomplete) {
 		char	text[96];
 
blob - 9adfc3af86fd43a4edbbee8c3ee5b0cacca4be55
blob + 591d448412ee231a8ee8bc46eda9c51afb1b6fdf
--- src/utf8.c
+++ src/utf8.c
@@ -16,21 +16,10 @@
  * 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.
- */
+/* RFC 5198 SS2: UTF-8, no C1 controls, no BOM */
 
 #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.
- */
 int
 utf8_mailbox_ok(const char *name)
 {
@@ -39,7 +28,7 @@ utf8_mailbox_ok(const char *name)
 	while (*p != '\0') {
 		unsigned int	need, lo, hi, i;
 
-		if (p[0] < 0x80) {	/* ASCII, C0 and DEL included */
+		if (p[0] < 0x80) {
 			p++;
 			continue;
 		}
@@ -61,19 +50,9 @@ 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
-			 */
 			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.
-		 */
 		if (p[1] < lo || p[1] > hi)
 			return (0);
 		for (i = 2; i <= need; i++)
@@ -84,12 +63,7 @@ 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; refused anywhere */
 		if (p[0] == 0xef && p[1] == 0xbb && p[2] == 0xbf)
 			return (0);
 
blob - 55a0a1c34bc5f8d5174d4880637852c0cc9f5ec2
blob + 3c0922b2fc3e0d67ea1b498ff1a940617c49afc4
--- src/utf8.h
+++ src/utf8.h
@@ -19,12 +19,6 @@
 #ifndef IMAPD_UTF8_H
 #define IMAPD_UTF8_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 *);
 
 #endif /* IMAPD_UTF8_H */