Commit Diff


commit - f940a630e00423a0043b84fbe5affd05ea548d6d
commit + 65c7cf929f78f3280baaf04805e06a3d59b8b4f2
blob - 8c9c9862d382bfe7df46a4cc864956499b97ce3a
blob + 5705b36cfc88caba91ffb269193d7c04ba72f507
--- README.md
+++ README.md
@@ -53,6 +53,18 @@ doas install -o root -g wheel -m 600 \
 
 Every directive is documented inline in the sample file; the full reference is in `imapd(8)`'s FILES section.
 
+## Creating the daemon accounts
+
+imapd drops privileges to three accounts of its own and will not start until all three exist. Create them once, before the first mailbox account, since `imapduser(8)` gives the credentials file to the `_imapauth` group:
+
+```
+doas useradd -c "IMAP Daemon" -d /var/empty -s /sbin/nologin _imapd
+doas useradd -c "IMAP Auth" -d /var/empty -s /sbin/nologin _imapauth
+doas useradd -c "IMAP Key Manager" -d /var/empty -s /sbin/nologin _imapkey
+```
+
+Each runs in a group of its own name, which `useradd(8)` creates by default unless `/etc/usermgmt.conf` sets `group` otherwise.
+
 ## Creating an account
 
 imapd's users aren't real system accounts, `imapduser(8)` manages a bespoke credentials file (`username:passwordhash:uid:gid:maildir`, bcrypt via `crypt_checkpass(3)`) and the matching maildir ownership together, since no combination of `useradd(8)`/`userdel(8)` can safely keep both in sync:
blob - 3011ab2b68aa384025c3db089fbdca6bfd0a28ad
blob + 38f2e42d3962909ce6815417a407d690a860f0f2
--- contrib/imapd-teardown
+++ contrib/imapd-teardown
@@ -102,12 +102,8 @@ do_userdel() {
 			echo "  system account $_acct  ($_label)"
 		else
 			userdel "$_acct" 2>/dev/null || true
-			# useradd's own default (no -g given) creates a same-
-			# named group alongside the account, confirmed
-			# against the pre-rename _openimap/_openimapd
-			# accounts. userdel(8) itself makes no mention of
-			# touching groups, so that group is cleaned up
-			# separately here, guarded against already being gone.
+			# useradd(8) makes a same-named group by default and
+			# userdel(8) leaves a primary group behind.
 			groupdel "$_acct" 2>/dev/null || true
 			echo "${0##*/}: removed account $_acct (and its group, if any)" 1>&2
 		fi
@@ -129,6 +125,7 @@ list_targets() {
 	do_rm "TLS private key" "$TLS_KEY" f
 	do_userdel "auth's privilege-drop account" "_imapauth"
 	do_userdel "listener's privilege-drop account" "_imapd"
+	do_userdel "keymgr's privilege-drop account" "_imapkey"
 	if [ "$WIPE_MAIL" -eq 1 ]; then
 		do_rm "mail spool -- REAL MAIL DATA" "$SPOOL_ROOT" r
 	fi
blob - 60bed43482a76e1603b8d438342657969d72b840
blob + cd483dbb205734c6ecdc3591bf43d05bdd60f354
--- contrib/imapduser.8
+++ contrib/imapduser.8
@@ -2,7 +2,7 @@
 .\"
 .\" Written for the OpenIMAPD project. Public domain / no rights reserved.
 .\"
-.Dd $Mdocdate: September 27 2026 $
+.Dd $Mdocdate: September 28 2026 $
 .Dt IMAPDUSER 8
 .Os
 .Sh NAME
blob - f7a13b2a677ece560279a205ca1d8a83d19722fd
blob + 7e0c578730502999d1136d4258eab53efa0c9d4b
--- src/append_cmd.c
+++ src/append_cmd.c
@@ -339,8 +339,6 @@ void
 session_handle_mbox_appended(struct session *s,
     const struct imsg_mbox_appended *res)
 {
-	int	appended_to_selected;
-
 	s->state = s->append_prev_state;
 
 	if (res->error != MBOX_OP_OK) {
@@ -357,22 +355,6 @@ session_handle_mbox_appended(struct session *s,
 		return;
 	}
 
-	/* 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;
-	else
-		appended_to_selected =
-		    (strcmp(s->append_mailbox, s->selected_mailbox) == 0);
-
-
-	if (s->append_prev_state == SESSION_SELECTED && appended_to_selected) {
-		char	buf[32];
-
-		snprintf(buf, sizeof(buf), "%u EXISTS", res->exists);
-		session_untagged(s, buf);
-	}
-
 	{
 		char	buf[96];
 
blob - 15fc87a7f400101a1cce03f1c440bc849107d673
blob + a9edf8444f61eb110fa9d62bde920efa5aa13ed5
--- src/auth.c
+++ src/auth.c
@@ -119,9 +119,8 @@ auth_main(void)
 	init.cred_file[sizeof(init.cred_file) - 1] = '\0';
 	imsg_free(&imsg);
 
-	if ((pw = getpwnam("_imapauth")) == NULL)
-		fatalx("getpwnam _imapauth: no such user "
-		    "(expected, not yet provisioned by an install script)");
+	if ((pw = getpwnam(IMAPD_AUTH_USER)) == NULL)
+		fatalx("unknown user %s", IMAPD_AUTH_USER);
 
 	if (strlcpy(chrootdir, init.cred_file, sizeof(chrootdir)) >=
 	    sizeof(chrootdir))
blob - 9bdf08c3065723587c43f234b227be200861d536
blob + 295dbfe792a76217279f47edda320d56449332aa
--- src/auth_cmd.c
+++ src/auth_cmd.c
@@ -64,7 +64,16 @@ int
 cmd_noop(struct session *s, const char *tag, char *args)
 {
 	(void)args;
-	session_reply(s, tag, "OK", "NOOP completed");
+
+	/* RFC 9051 SS6.1.2: the poll; the reply waits for the refresh */
+	if (s->state != SESSION_SELECTED ||
+	    strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
+	    sizeof(s->pending_tag) || session_request_idle_refresh(s) == -1) {
+		session_reply(s, tag, "OK", "NOOP completed");
+		return (1);
+	}
+	s->refresh_status = "OK";
+	s->refresh_text = "NOOP completed";
 	return (1);
 }
 
@@ -277,16 +286,21 @@ session_handle_auth_continuation(struct session *s, co
 int
 session_handle_idle_continuation(struct session *s, const char *line)
 {
+	int	done;
+
 	s->idling = 0;
+	s->idle_refresh_again = 0;
 	session_idle_poll_disarm(s);
 
-	if (strcasecmp(line, "DONE") != 0) {
-		session_reply(s, s->pending_tag, "BAD",
-		    "expected DONE");
-		return (1);
+	done = strcasecmp(line, "DONE") == 0;
+	s->refresh_status = done ? "OK" : "BAD";
+	s->refresh_text = done ? "IDLE terminated" : "expected DONE";
+	/* RFC 9051 SS7.5.1: an EXPUNGE in flight goes before the reply */
+	if (!s->idle_refresh_pending) {
+		session_reply(s, s->pending_tag, s->refresh_status,
+		    s->refresh_text);
+		s->refresh_status = NULL;
 	}
-
-	session_reply(s, s->pending_tag, "OK", "IDLE terminated");
 	return (1);
 }
 
blob - a5728b7e6970463d7e16080c594c7fb8719cb862
blob + 3156bf94c2560eef628e87b9365638328a43164e
--- src/envelope.c
+++ src/envelope.c
@@ -39,6 +39,8 @@
 
 static size_t	 quoted_end(const char *, size_t, size_t);
 static size_t	 unquoted_find(const char *, size_t, size_t, const char *);
+static int	 envelope_from_header(const char *, size_t, char *, size_t,
+		    size_t *);
 
 int
 envbuf_append(char *buf, size_t bufsize, size_t *outlen, const char *data,
@@ -327,7 +329,7 @@ envbuf_append_address_list(char *buf, size_t bufsize, 
 
 int
 append_field_nstring(char *out, size_t outsize, size_t *outlen,
-    const char *hdrbuf, uint32_t hdrlen, const char *name)
+    const char *hdrbuf, size_t hdrlen, const char *name)
 {
 	char	*val;
 	size_t	 vallen;
@@ -343,59 +345,49 @@ append_field_nstring(char *out, size_t outsize, size_t
 }
 
 /* RFC 9051 SS7.5.2 ENVELOPE; Sender and Reply-To default to From */
-int
-build_envelope(int fd, const char *basename, char **buf_out,
-    uint32_t *len_out)
+static int
+envelope_from_header(const char *hdr, size_t hdrlen, char *out,
+    size_t outsize, size_t *outlen)
 {
-	char		*hdrbuf = NULL;
-	uint32_t	 hdrlen = 0;
-	char		 out[ENVELOPE_MAX];
-	size_t		 outlen = 0;
 	char		 from_formatted[ENVELOPE_MAX];
 	size_t		 from_len = 0;
 
-	*buf_out = NULL;
-	*len_out = 0;
-
-	if (read_header_from_fd(fd, basename, &hdrbuf, &hdrlen) == -1)
+	if (envbuf_append(out, outsize, outlen, "(", 1) == -1)
 		return (-1);
 
-	if (envbuf_append(out, sizeof(out), &outlen, "(", 1) == -1)
-		goto fail;
-
-	if (append_field_nstring(out, sizeof(out), &outlen, hdrbuf, hdrlen,
+	if (append_field_nstring(out, outsize, outlen, hdr, hdrlen,
 	    "Date") == -1)
-		goto fail;
-	if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1)
-		goto fail;
-	if (append_field_nstring(out, sizeof(out), &outlen, hdrbuf, hdrlen,
+		return (-1);
+	if (envbuf_append(out, outsize, outlen, " ", 1) == -1)
+		return (-1);
+	if (append_field_nstring(out, outsize, outlen, hdr, hdrlen,
 	    "Subject") == -1)
-		goto fail;
-	if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1)
-		goto fail;
+		return (-1);
+	if (envbuf_append(out, outsize, outlen, " ", 1) == -1)
+		return (-1);
 
 	{
 		char	*val;
 		size_t	 vallen;
 
-		if (extract_header_field(hdrbuf, hdrlen, "From", &val,
+		if (extract_header_field(hdr, hdrlen, "From", &val,
 		    &vallen) == 0) {
 			int	rc = envbuf_append_address_list(from_formatted,
 			    sizeof(from_formatted), &from_len, val, vallen);
 			free(val);
 			if (rc == -1)
-				goto fail;
+				return (-1);
 		} else {
 			if (envbuf_append_str(from_formatted,
 			    sizeof(from_formatted), &from_len, "NIL") == -1)
-				goto fail;
+				return (-1);
 		}
 	}
-	if (envbuf_append(out, sizeof(out), &outlen, from_formatted,
+	if (envbuf_append(out, outsize, outlen, from_formatted,
 	    from_len) == -1)
-		goto fail;
-	if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1)
-		goto fail;
+		return (-1);
+	if (envbuf_append(out, outsize, outlen, " ", 1) == -1)
+		return (-1);
 
 	{
 		static const char *const fallback_fields[] =
@@ -407,26 +399,26 @@ build_envelope(int fd, const char *basename, char **bu
 			size_t	 vallen;
 			int	 used_value = 0;
 
-			if (extract_header_field(hdrbuf, hdrlen,
+			if (extract_header_field(hdr, hdrlen,
 			    fallback_fields[fi], &val, &vallen) == 0) {
 				if (vallen > 0) {
 					int	rc = envbuf_append_address_list(
-					    out, sizeof(out), &outlen, val,
+					    out, outsize, outlen, val,
 					    vallen);
 					used_value = 1;
 					free(val);
 					if (rc == -1)
-						goto fail;
+						return (-1);
 				} else
 					free(val);
 			}
 			if (!used_value &&
-			    envbuf_append(out, sizeof(out), &outlen,
+			    envbuf_append(out, outsize, outlen,
 			    from_formatted, from_len) == -1)
-				goto fail;
-			if (envbuf_append(out, sizeof(out), &outlen,
+				return (-1);
+			if (envbuf_append(out, outsize, outlen,
 			    " ", 1) == -1)
-				goto fail;
+				return (-1);
 		}
 	}
 
@@ -438,36 +430,57 @@ build_envelope(int fd, const char *basename, char **bu
 			char	*val;
 			size_t	 vallen;
 
-			if (extract_header_field(hdrbuf, hdrlen,
+			if (extract_header_field(hdr, hdrlen,
 			    addr_fields[fi], &val, &vallen) == 0) {
 				int	rc = envbuf_append_address_list(out,
-				    sizeof(out), &outlen, val, vallen);
+				    outsize, outlen, val, vallen);
 				free(val);
 				if (rc == -1)
-					goto fail;
+					return (-1);
 			} else {
-				if (envbuf_append_str(out, sizeof(out),
-				    &outlen, "NIL") == -1)
-					goto fail;
+				if (envbuf_append_str(out, outsize, outlen,
+				    "NIL") == -1)
+					return (-1);
 			}
-			if (envbuf_append(out, sizeof(out), &outlen,
+			if (envbuf_append(out, outsize, outlen,
 			    " ", 1) == -1)
-				goto fail;
+				return (-1);
 		}
 	}
 
-	if (append_field_nstring(out, sizeof(out), &outlen, hdrbuf, hdrlen,
+	if (append_field_nstring(out, outsize, outlen, hdr, hdrlen,
 	    "In-Reply-To") == -1)
-		goto fail;
-	if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1)
-		goto fail;
-	if (append_field_nstring(out, sizeof(out), &outlen, hdrbuf, hdrlen,
+		return (-1);
+	if (envbuf_append(out, outsize, outlen, " ", 1) == -1)
+		return (-1);
+	if (append_field_nstring(out, outsize, outlen, hdr, hdrlen,
 	    "Message-Id") == -1)
-		goto fail;
+		return (-1);
 
-	if (envbuf_append(out, sizeof(out), &outlen, ")", 1) == -1)
-		goto fail;
+	return (envbuf_append(out, outsize, outlen, ")", 1));
+}
 
+int
+build_envelope(int fd, const char *basename, char **buf_out,
+    uint32_t *len_out)
+{
+	char		*hdrbuf = NULL;
+	uint32_t	 hdrlen = 0;
+	char		 out[ENVELOPE_MAX];
+	size_t		 outlen = 0;
+
+	*buf_out = NULL;
+	*len_out = 0;
+
+	if (read_header_from_fd(fd, basename, &hdrbuf, &hdrlen) == -1)
+		return (-1);
+	if (envelope_from_header(hdrbuf, hdrlen, out, sizeof(out),
+	    &outlen) == -1) {
+		log_warnx("session %u: message %s: formatted ENVELOPE exceeds "
+		    "ENVELOPE_MAX, ENVELOPE skipped", session_id, basename);
+		free(hdrbuf);
+		return (-1);
+	}
 	free(hdrbuf);
 
 	if ((*buf_out = malloc(outlen)) == NULL) {
@@ -478,12 +491,6 @@ build_envelope(int fd, const char *basename, char **bu
 	memcpy(*buf_out, out, outlen);
 	*len_out = (uint32_t)outlen;
 	return (0);
-
-fail:
-	log_warnx("session %u: message %s: formatted ENVELOPE exceeds "
-	    "ENVELOPE_MAX, ENVELOPE skipped", session_id, basename);
-	free(hdrbuf);
-	return (-1);
 }
 
 /* RFC 9051 SS7.5.2 BODYSTRUCTURE; no extension data */
@@ -545,17 +552,14 @@ build_body_structure(int depth, int *nparts_used, cons
 		return (envbuf_append(out, outsize, outlen, ")", 1));
 	}
 
-	if (strcasecmp(type, "MESSAGE") == 0 &&
-	    (strcasecmp(subtype, "RFC822") == 0 ||
-	    strcasecmp(subtype, "GLOBAL") == 0))
-		/* MESSAGE/RFC822 and MESSAGE/GLOBAL are not supported */
-		return (-1);
-
 	{
 		char	*idval = NULL, *descval = NULL, *encval = NULL;
-		size_t	 idlen = 0, desclen = 0, enclen = 0;
+		size_t	 idlen = 0, desclen = 0, enclen = 0, ihdr;
 		char	 encstr[40];
 		int	 is_text = (strcasecmp(type, "TEXT") == 0);
+		int	 is_msg = strcasecmp(type, "MESSAGE") == 0 &&
+		    (strcasecmp(subtype, "RFC822") == 0 ||
+		    strcasecmp(subtype, "GLOBAL") == 0);
 		int	 rc = 0;
 
 		if (envbuf_append(out, outsize, outlen, "(", 1) == -1)
@@ -641,7 +645,18 @@ build_body_structure(int depth, int *nparts_used, cons
 				return (-1);
 		}
 
-		if (is_text) {
+		/* RFC 9051 SS7.5.2 body-type-msg: envelope, body, lines */
+		if (is_msg && (bodylen == 0 ||
+		    find_header_body_split(body, bodylen, &ihdr) == -1 ||
+		    envbuf_append(out, outsize, outlen, " ", 1) == -1 ||
+		    envelope_from_header(body, ihdr, out, outsize,
+		    outlen) == -1 ||
+		    envbuf_append(out, outsize, outlen, " ", 1) == -1 ||
+		    build_body_structure(depth + 1, nparts_used, body, ihdr,
+		    body + ihdr, bodylen - ihdr, out, outsize, outlen) == -1))
+			return (-1);
+
+		if (is_text || is_msg) {
 			size_t	lines = 0, li;
 			char	numbuf2[32];
 
blob - fa0815a7ea209ae6c3da6c3e18505aba39d017ca
blob + d2ffc23c6531d1c39bc449b58c991c54b69674d8
--- src/fetch_cmd.c
+++ src/fetch_cmd.c
@@ -323,12 +323,11 @@ parse_partial_suffix(const char *s, int *has_partial_o
 }
 
 static int
-parse_body_peek_section_tok(const char *tok, uint32_t *attrs_inout,
+parse_body_peek_section_tok(const char *bracket_start, uint32_t *attrs_inout,
     char *section_part_out, size_t section_part_outsize,
     int *has_partial_out, uint32_t *partial_start_out,
     uint32_t *partial_count_out, int *degraded_out, const char **errmsg)
 {
-	const char	*bracket_start = tok + strlen("BODY.PEEK[");
 	char		*close;
 	char		 inner[SECTION_PART_MAX];
 	const char	*suffix;
@@ -375,15 +374,15 @@ parse_body_peek_section_tok(const char *tok, uint32_t 
 }
 
 static int
-parse_body_peek_header_fields_tok(const char *tok, uint32_t *attrs_inout,
+parse_body_peek_header_fields_tok(const char *sec, uint32_t *attrs_inout,
     int *header_fields_not_out, char *header_fields_out,
     size_t header_fields_outsize, char *header_fields_label_out,
     size_t header_fields_label_outsize, const char **errmsg)
 {
-	size_t	 toklen = strlen(tok);
+	size_t	 len = strlen(sec);
 	char	 inner[HEADER_FIELDS_LABEL_MAX];
 
-	if (toklen < strlen("BODY.PEEK[") + 1 || tok[toklen - 1] != ']') {
+	if (len < 1 || sec[len - 1] != ']') {
 		*errmsg = "malformed HEADER.FIELDS section";
 		return (-1);
 	}
@@ -391,13 +390,12 @@ parse_body_peek_header_fields_tok(const char *tok, uin
 		/* already captured one, ignore any further duplicates */
 		return (0);
 
-	if (toklen - strlen("BODY.PEEK[") - 1 >= sizeof(inner)) {
+	if (len - 1 >= sizeof(inner)) {
 		*errmsg = "HEADER.FIELDS section too long";
 		return (-1);
 	}
-	memcpy(inner, tok + strlen("BODY.PEEK["),
-	    toklen - strlen("BODY.PEEK[") - 1);
-	inner[toklen - strlen("BODY.PEEK[") - 1] = '\0';
+	memcpy(inner, sec, len - 1);
+	inner[len - 1] = '\0';
 
 	if (parse_header_fields_att(inner, header_fields_not_out,
 	    header_fields_out, header_fields_outsize) == -1) {
@@ -413,6 +411,17 @@ parse_body_peek_header_fields_tok(const char *tok, uin
 	return (1);
 }
 
+static const char *
+body_section(const char *tok, int *plain)
+{
+	*plain = strncasecmp(tok, "BODY[", 5) == 0;
+	if (*plain)
+		return (tok + 5);
+	if (strncasecmp(tok, "BODY.PEEK[", 10) == 0)
+		return (tok + 10);
+	return (NULL);
+}
+
 /* RFC 9051 SS6.4.5 fetch-att; unsupported items are skipped */
 int
 parse_fetch_atts(char *spec, uint32_t *attrs_out, int *degraded_out,
@@ -424,9 +433,10 @@ parse_fetch_atts(char *spec, uint32_t *attrs_out, int 
     uint32_t *partial_count_out, const char **errmsg)
 {
 	char		*p, *tok, *save;
+	const char	*sec;
 	size_t		 len;
 	uint32_t	 attrs = 0;
-	int		 degraded = 0;
+	int		 degraded = 0, plain, d;
 	int		 has_partial = 0;
 	uint32_t	 partial_start = 0, partial_count = 0;
 
@@ -484,25 +494,27 @@ parse_fetch_atts(char *spec, uint32_t *attrs_out, int 
 		} else if (strcasecmp(tok, "MODSEQ") == 0) {
 			/* RFC 7162 SS3.1.4.2 */
 			attrs |= MBOX_FETCH_MODSEQ;
-		} else if (strcasecmp(tok, "BODY.PEEK[HEADER]") == 0) {
-			/* exact BODY[...]; \Seen unimplemented */
-			attrs |= MBOX_FETCH_BODY_HEADER;
-		} else if (strncasecmp(tok, "BODY.PEEK[",
-		    strlen("BODY.PEEK[")) == 0 &&
-		    strncasecmp(tok, "BODY.PEEK[HEADER.FIELDS",
-		    strlen("BODY.PEEK[HEADER.FIELDS")) != 0) {
-			if (parse_body_peek_section_tok(tok, &attrs,
+		} else if ((sec = body_section(tok, &plain)) != NULL) {
+			d = 0;
+			if (strcasecmp(sec, "HEADER]") == 0)
+				attrs |= MBOX_FETCH_BODY_HEADER;
+			else if (strncasecmp(sec, "HEADER.FIELDS",
+			    strlen("HEADER.FIELDS")) == 0) {
+				if (parse_body_peek_header_fields_tok(sec,
+				    &attrs, header_fields_not_out,
+				    header_fields_out, header_fields_outsize,
+				    header_fields_label_out,
+				    header_fields_label_outsize,
+				    errmsg) == -1)
+					return (-1);
+			} else if (parse_body_peek_section_tok(sec, &attrs,
 			    section_part_out, section_part_outsize,
 			    &has_partial, &partial_start, &partial_count,
-			    &degraded, errmsg) == -1)
+			    &d, errmsg) == -1)
 				return (-1);
-		} else if (strncasecmp(tok, "BODY.PEEK[HEADER.FIELDS",
-		    strlen("BODY.PEEK[HEADER.FIELDS")) == 0) {
-			if (parse_body_peek_header_fields_tok(tok, &attrs,
-			    header_fields_not_out, header_fields_out,
-			    header_fields_outsize, header_fields_label_out,
-			    header_fields_label_outsize, errmsg) == -1)
-				return (-1);
+			if (plain && !d)
+				attrs |= MBOX_FETCH_SET_SEEN;
+			degraded |= d;
 		} else if (strcasecmp(tok, "ENVELOPE") == 0) {
 			/* SS7.5.2; no .PEEK, no \Seen effect */
 			attrs |= MBOX_FETCH_ENVELOPE;
@@ -526,7 +538,7 @@ parse_fetch_atts(char *spec, uint32_t *attrs_out, int 
 	if (attrs == 0) {
 		*errmsg = "cannot fetch that message content yet, "
 		    "supported: FLAGS/UID/INTERNALDATE/RFC822.SIZE/MODSEQ/"
-		    "ENVELOPE/(BODY|BODYSTRUCTURE)/BODY.PEEK[...]";
+		    "ENVELOPE/(BODY|BODYSTRUCTURE)/BODY[...]/BODY.PEEK[...]";
 		return (-2);
 	}
 
@@ -629,6 +641,7 @@ session_send_fetch_response(struct session *s,
 	char	 date[40];
 	size_t	 len = 0;
 	int	 need_sp = 0;
+	int	 cs = meta->seen_set && s->condstore_enabled;
 	int	 have_header = (s->fetch_attrs & (MBOX_FETCH_BODY_HEADER |
 	    MBOX_FETCH_HEADER_FIELDS)) && s->pending_header_found;
 	int	 have_body = (s->fetch_attrs &
@@ -642,11 +655,12 @@ session_send_fetch_response(struct session *s,
 
 	fetch_append(buf, sizeof(buf), &len, "%u FETCH (", meta->seqno);
 
-	if (s->fetch_attrs & MBOX_FETCH_FLAGS) {
+	/* RFC 9051 SS6.4.5, RFC 7162 SS3.2.4: report a \Seen this set */
+	if ((s->fetch_attrs & MBOX_FETCH_FLAGS) || meta->seen_set) {
 		fetch_append(buf, sizeof(buf), &len, "FLAGS (%s)", meta->flags);
 		need_sp = 1;
 	}
-	if (s->fetch_attrs & MBOX_FETCH_UID) {
+	if ((s->fetch_attrs & MBOX_FETCH_UID) || cs) {
 		fetch_append(buf, sizeof(buf), &len, "%sUID %u",
 		    need_sp ? " " : "", meta->uid);
 		need_sp = 1;
@@ -662,7 +676,7 @@ session_send_fetch_response(struct session *s,
 		    need_sp ? " " : "", (unsigned long long)meta->size);
 		need_sp = 1;
 	}
-	if (s->fetch_attrs & MBOX_FETCH_MODSEQ) {
+	if ((s->fetch_attrs & MBOX_FETCH_MODSEQ) || cs) {
 		/* RFC 7162 SS3.1.4.2 fetch-mod-resp */
 		fetch_append(buf, sizeof(buf), &len, "%sMODSEQ (%llu)",
 		    need_sp ? " " : "", (unsigned long long)meta->modseq);
@@ -1022,12 +1036,14 @@ fetch_dispatch(struct session *s, const char *tag, cha
 		session_reply(s, tag, "NO", errmsg);
 		return (1);
 	}
+	/* RFC 9051 SS6.3.3: EXAMINE permits no change, \Seen included */
+	if (s->mbox_readonly)
+		attrs &= ~MBOX_FETCH_SET_SEEN;
 	if (degraded)
 		log_debug("session %u: %s: one or more unsupported message "
-		    "data items silently dropped (plain BODY[...]/BODY[<part>]"
-		    "/BODY.PEEK[<part>] with MESSAGE/RFC822|GLOBAL or MULTIPART"
-		    " nested numbering/RFC822[.HEADER/.TEXT]), answering "
-		    "with whatever was recognized", s->id, cmdname);
+		    "data items not returned (a part number before "
+		    "HEADER/TEXT/MIME, RFC822[.HEADER/.TEXT]), "
+		    "answering the rest, then NO", s->id, cmdname);
 
 	memset(&req, 0, sizeof(req));
 	req.attrs = attrs;
@@ -1136,7 +1152,8 @@ fetch_dispatch(struct session *s, const char *tag, cha
 	}
 	s->fetch_attrs = req.attrs;
 	s->cmd_by_uid = by_uid;
-	s->fetch_incomplete = 0;
+	/* RFC 9051 SS6.4.5: "NO - fetch error: can't fetch that data" */
+	s->fetch_incomplete = degraded;
 	s->state = SESSION_FETCHING;
 
 	if (!send_mbox_request(s, IMSG_MBOX_FETCH, cmdname, "IMSG_MBOX_FETCH",
blob - e17940a4005eba802bafef76c6836b6948719c4f
blob + 89cc5d2c5094633ba2e50057c592a262ca6ef0ec
--- 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 27 2026 $
+.Dd $Mdocdate: September 28 2026 $
 .Dt IMAPD 8
 .Os
 .Sh NAME
blob - 8b8d979bbea17a86332be12e7642d65a0584705f
blob + 6eb1a0411a611331b59b79c7139120c4135ecfc8
--- src/imapd.h
+++ src/imapd.h
@@ -28,8 +28,12 @@
 #include <imsg.h>
 #include <stdint.h>
 
-#define IMAPD_VERSION	"0.1.7"
+#define IMAPD_VERSION	"0.1.8"
 
+#define IMAPD_USER	"_imapd"
+#define IMAPD_AUTH_USER	"_imapauth"
+#define IMAPD_KEY_USER	"_imapkey"
+
 enum openimap_proc_type {
 	PROC_PARENT,
 	PROC_LISTENER,
@@ -121,10 +125,11 @@ enum imsg_type {
 	IMSG_MBOX_SELECT_VANISHED,
 	IMSG_MBOX_STORE_MODIFIED,
 
-	/* RFC 9051 SS6.3.13 (IDLE) */
+	/* RFC 9051 SS6.3.13 (IDLE); the VIEW ones go with any command */
 	IMSG_MBOX_IDLE_REFRESH,
-	IMSG_MBOX_IDLE_EXPUNGE,
-	IMSG_MBOX_IDLE_FETCH,
+	IMSG_MBOX_VIEW_EXPUNGE,
+	IMSG_MBOX_VIEW_FETCH,
+	IMSG_MBOX_VIEW_EXISTS,
 	IMSG_MBOX_IDLE_REFRESHED,
 
 	/* RFC 9051 SS6.3.9 (LIST) */
@@ -260,6 +265,7 @@ enum mbox_op_error {
 	MBOX_OP_ERR_NO_SUCH_MAILBOX,
 	MBOX_OP_ERR_ALREADY_EXISTS,
 	MBOX_OP_ERR_BUSY,
+	MBOX_OP_ERR_EXPUNGEISSUED,
 };
 
 /* QRESYNC select-param (RFC 7162 SS3.2.5). */
@@ -331,6 +337,7 @@ struct imsg_mbox_select_vanished {
 #define MBOX_FETCH_ENVELOPE		(1U << 9)
 #define MBOX_FETCH_BODYSTRUCTURE	(1U << 10) /* and bare BODY */
 #define MBOX_FETCH_BODY_PART		(1U << 11)
+#define MBOX_FETCH_SET_SEEN		(1U << 12)
 
 #define SECTION_PART_MAX	40
 
@@ -351,6 +358,7 @@ struct imsg_mbox_select_vanished {
 
 /* relayd's RELAY_TLS_PRIV_TIMEOUT bounds a similar wait */
 #define PARSER_REPLY_TIMEOUT_SEC	10
+#define KEYMGR_REPLY_TIMEOUT_SEC	10
 
 /* SIGXCPU at soft, SIGKILL at hard (sys/kern/kern_resource.c) */
 #define PARSER_CPU_SOFT_SEC	8
@@ -462,6 +470,7 @@ struct imsg_mbox_fetch_meta {
 	int64_t		internaldate;	/* from the basename, not mtime */
 	char		flags[MBOX_FLAGS_MAX];
 	uint64_t	modseq;
+	int		seen_set;
 };
 
 /* sent just before the same message's IMSG_MBOX_FETCH_META */
@@ -577,7 +586,6 @@ struct imsg_mbox_appended {
 	enum mbox_op_error error;	/* NO_SUCH_MAILBOX gets [TRYCREATE] */
 	uint32_t	uidvalidity;
 	uint32_t	uid;		/* APPENDUID, with uidvalidity */
-	uint32_t	exists;
 };
 
 /* RFC 9051 SS6.4.4 search-program, as a postfix array of nodes */
@@ -662,25 +670,17 @@ struct imsg_mbox_search_match {
 	uint64_t	modseq;		/* RFC 7162 SS3.1.6 */
 };
 
-/* RFC 9051 SS6.3.13 IDLE: seeded once, then a diff per poll */
-struct imsg_mbox_idle_refresh {
-	/* adopt the current state as baseline, report nothing */
-	int		seed;
-};
-
-struct imsg_mbox_idle_expunge {
-	uint32_t	seqno;
-};
-
+/* RFC 9051 SS6.3.13 IDLE, SS6.1.2 NOOP; EXPUNGE is imsg_mbox_expunged */
 struct imsg_mbox_idle_refreshed {
 	int		ok;
-	/* the probe saw no change; the fields below are zero */
-	int		unchanged;
-	int		exists_changed;
-	uint32_t	exists;
+	int		unchanged;	/* the probe saw no change */
 	int		busy;	/* lock held elsewhere; ok says if it seeded */
 };
 
+struct imsg_mbox_view_exists {
+	uint32_t	exists;
+};
+
 /* RFC 9051 SS6.3.4/SS6.3.5; store.c re-validates the name */
 struct imsg_mbox_create {
 	char		mailbox[MBOX_NAME_MAX];
blob - cf71d95cf2cc0880c154fda71df483e955db9cae
blob + fe42950141044244657ac834101c6d2d9009749c
--- src/index.c
+++ src/index.c
@@ -873,53 +873,177 @@ idle_baseline_reset(struct store_session *ss)
 	base->uids = NULL;
 	base->n = 0;
 	base->modseq = 0;
+	base->ghosts = 0;
 	base->valid = 0;
 }
 
+static size_t
+uid_find(const uint32_t *list, size_t n, uint32_t uid)
+{
+	size_t	lo = 0, hi = n, mid;
+
+	while (lo < hi) {
+		mid = lo + (hi - lo) / 2;
+		if (list[mid] < uid)
+			lo = mid + 1;
+		else
+			hi = mid;
+	}
+	return ((lo < n && list[lo] == uid) ? lo : n);
+}
+
+/* RFC 9051 SS7.5.1: once told, never again, so drop it from the baseline */
+int
+send_expunged(struct store_session *ss, uint32_t seqno, uint32_t uid)
+{
+	struct store_idle_baseline	*base = &ss->idle_baseline;
+	struct imsg_mbox_expunged	 exp;
+	size_t				 at;
+
+	at = uid_find(base->uids, base->n, uid);
+	memset(&exp, 0, sizeof(exp));
+	exp.seqno = at < base->n ? (uint32_t)at + 1 : seqno;
+	exp.uid = uid;
+	if (imsg_compose(&ss->iev.ibuf, IMSG_MBOX_EXPUNGED, 0, 0, -1, &exp,
+	    sizeof(exp)) == -1) {
+		log_warn("session %u: imsg_compose IMSG_MBOX_EXPUNGED",
+		    session_id);
+		return (-1);
+	}
+	if (at < base->n) {
+		memmove(&base->uids[at], &base->uids[at + 1],
+		    (base->n - at - 1) * sizeof(*base->uids));
+		base->n--;
+	}
+	return (0);
+}
+
+uint32_t
+view_seqno(const struct store_session *ss, uint32_t uid, uint32_t fallback)
+{
+	const struct store_idle_baseline	*base = &ss->idle_baseline;
+	size_t					 at;
+
+	if (!base->valid)
+		return (fallback);
+	at = uid_find(base->uids, base->n, uid);
+	return (at < base->n ? (uint32_t)at + 1 : fallback);
+}
+
+/* a sequence set, read through the view, as UIDs; *by_uid then set */
+void
+view_resolve(const struct store_session *ss, const struct seq_range *in,
+    uint32_t nin, struct seq_range *out, uint32_t *nout, int *by_uid)
+{
+	const struct store_idle_baseline	*base = &ss->idle_baseline;
+	uint32_t				 i, lo, hi, tmp, n = 0;
+
+	if (*by_uid || !base->valid) {
+		for (i = 0; i < nin; i++)
+			out[i] = in[i];
+		*nout = nin;
+		return;
+	}
+	for (i = 0; i < nin; i++) {
+		lo = in[i].lo_is_star ? (uint32_t)base->n : in[i].lo;
+		hi = in[i].hi_is_star ? (uint32_t)base->n : in[i].hi;
+		if (lo > hi) {
+			tmp = lo;
+			lo = hi;
+			hi = tmp;
+		}
+		if (lo < 1)
+			lo = 1;
+		if (hi > base->n)
+			hi = (uint32_t)base->n;
+		if (lo > hi)
+			continue;
+		memset(&out[n], 0, sizeof(out[n]));
+		out[n].lo = base->uids[lo - 1];
+		out[n].hi = base->uids[hi - 1];
+		n++;
+	}
+	*nout = n;
+	*by_uid = 1;
+}
+
+/* RFC 2180 SS4.1.2: does the set name a message expunged, not yet told? */
+int
+view_names_ghost(const struct store_session *ss, const struct mbox_index *idx,
+    const struct seq_range *uids, uint32_t nuids)
+{
+	const struct store_idle_baseline	*base = &ss->idle_baseline;
+	struct index_rec			 rec;
+	size_t					 i, j = 0;
+
+	if (!base->valid || base->ghosts == 0)
+		return (0);
+	memset(&rec, 0, sizeof(rec));
+	for (i = 0; i < base->n; i++) {
+		if (!seqset_contains(uids, nuids, base->uids[i]))
+			continue;
+		for (; j < idx->nlines; j++)
+			if (index_parse_line(idx->lines[j], &rec) == 0 &&
+			    rec.uid >= base->uids[i])
+				break;
+		if (j == idx->nlines || rec.uid != base->uids[i])
+			return (1);
+	}
+	return (0);
+}
+
 /* 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)
+view_merge(const uint32_t *old, size_t oldn, const uint32_t *cur,
+    size_t curn, int expunge_ok, uint32_t *view, size_t *viewn,
+    struct imsgev *iev)
 {
-	struct imsg_mbox_idle_expunge	 item;
-	size_t				 i, j = 0, gone = 0;
-	uint32_t			 seqno = 1;
+	struct imsg_mbox_expunged	 item;
+	size_t				 i = 0, j = 0, n = 0, gone = 0;
 
-	for (i = 0; i < oldn; i++) {
-		while (j < curn && cur[j] < old[i])
-			j++;
+	while (i < oldn || j < curn) {
+		if (j < curn && (i == oldn || cur[j] < old[i])) {
+			view[n++] = cur[j++];
+			continue;
+		}
 		if (j < curn && cur[j] == old[i]) {
+			view[n++] = old[i++];
 			j++;
-			seqno++;
 			continue;
 		}
+		if (!expunge_ok) {
+			view[n++] = old[i++];
+			continue;
+		}
 		memset(&item, 0, sizeof(item));
-		item.seqno = seqno;
+		item.seqno = (uint32_t)n + 1;
+		item.uid = old[i++];
 		gone++;
-		if (imsg_compose(&iev->ibuf, IMSG_MBOX_IDLE_EXPUNGE, 0, 0, -1,
+		if (imsg_compose(&iev->ibuf, IMSG_MBOX_VIEW_EXPUNGE, 0, 0, -1,
 		    &item, sizeof(item)) == -1)
 			log_warn("session %u: imsg_compose "
-			    "IMSG_MBOX_IDLE_EXPUNGE", session_id);
+			    "IMSG_MBOX_VIEW_EXPUNGE", session_id);
 	}
+	*viewn = n;
 	return (gone);
 }
 
 /* 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 store_session *ss)
+view_send_flag_fetches(const struct mbox_index *idx, const uint32_t *old,
+    size_t oldn, uint64_t since, const uint32_t *view, size_t viewn,
+    struct store_session *ss)
 {
 	struct imsgev			*iev = &ss->iev;
 	struct imsg_mbox_fetch_meta	 meta;
 	struct index_rec		 rec;
 	char				 suffix[64];
 	off_t				 size;
-	size_t				 i, j = 0, seqno = 0, sent = 0;
+	size_t				 i, j = 0, sent = 0;
 
 	for (i = 0; i < idx->nlines; i++) {
 		if (index_parse_line(idx->lines[i], &rec) == -1)
 			continue;	/* malformed, skipped as everywhere */
-		seqno++;		/* position after the EXPUNGEs above */
 		if (rec.modseq <= since)
 			continue;
 		while (j < oldn && old[j] < rec.uid)
@@ -934,16 +1058,16 @@ idle_send_flag_fetches(const struct mbox_index *idx, c
 			continue;
 		}
 		memset(&meta, 0, sizeof(meta));
-		meta.seqno = (uint32_t)seqno;
+		meta.seqno = (uint32_t)uid_find(view, viewn, rec.uid) + 1;
 		meta.uid = rec.uid;
 		meta.modseq = rec.modseq;
 		build_flags_string(suffix, rec.keywords, meta.flags,
 		    sizeof(meta.flags));
 		sent++;
-		if (imsg_compose(&iev->ibuf, IMSG_MBOX_IDLE_FETCH, 0, 0, -1,
+		if (imsg_compose(&iev->ibuf, IMSG_MBOX_VIEW_FETCH, 0, 0, -1,
 		    &meta, sizeof(meta)) == -1)
 			log_warn("session %u: imsg_compose "
-			    "IMSG_MBOX_IDLE_FETCH", session_id);
+			    "IMSG_MBOX_VIEW_FETCH", session_id);
 	}
 	return (sent);
 }
@@ -1015,10 +1139,75 @@ idle_uid_list(const struct mbox_index *idx, uint32_t *
 	return (0);
 }
 
+void
+idle_baseline_seed(struct store_session *ss, const struct mbox_index *idx)
+{
+	struct store_idle_baseline	*base = &ss->idle_baseline;
+	uint32_t			*list;
+	uint64_t			 modseq;
+	size_t				 n;
+
+	idle_baseline_reset(ss);
+	if (idle_uid_list(idx, &list, &n, &modseq) == -1)
+		return;
+	base->uids = list;
+	base->n = n;
+	base->modseq = modseq;
+	base->valid = 1;
+}
+
+/* RFC 9051 SS5.2, SS7.5.1: what changed; EXPUNGE only if expunge_ok */
+int
+view_sync(struct store_session *ss, const struct mbox_index *idx,
+    int expunge_ok)
+{
+	struct store_idle_baseline	*base = &ss->idle_baseline;
+	struct imsg_mbox_view_exists	 ve;
+	uint32_t			*cur, *view;
+	uint64_t			 seen;
+	size_t				 curn, viewn, gone;
+
+	if (!base->valid) {
+		idle_baseline_seed(ss, idx);
+		return (base->valid ? 0 : -1);
+	}
+	/* index_append() and every removal raise HIGHESTMODSEQ */
+	if (idx->nlines == base->n && idx->highestmodseq <= base->modseq)
+		return (0);
+	if (idle_uid_list(idx, &cur, &curn, &seen) == -1)
+		return (-1);
+	if ((view = reallocarray(NULL, base->n + curn + 1,
+	    sizeof(*view))) == NULL) {
+		log_warn("session %u: view: reallocarray", session_id);
+		free(cur);
+		return (-1);
+	}
+	gone = view_merge(base->uids, base->n, cur, curn, expunge_ok, view,
+	    &viewn, &ss->iev);
+	(void)view_send_flag_fetches(idx, base->uids, base->n, base->modseq,
+	    view, viewn, ss);
+	/* after EXPUNGEs too, as RFC 9051 SS6.3.13's example does */
+	if (gone > 0 || viewn != base->n) {
+		memset(&ve, 0, sizeof(ve));
+		ve.exists = (uint32_t)viewn;
+		if (imsg_compose(&ss->iev.ibuf, IMSG_MBOX_VIEW_EXISTS, 0, 0,
+		    -1, &ve, sizeof(ve)) == -1)
+			log_warn("session %u: imsg_compose "
+			    "IMSG_MBOX_VIEW_EXISTS", session_id);
+	}
+	free(cur);
+	free(base->uids);
+	base->uids = view;
+	base->n = viewn;
+	base->ghosts = viewn - curn;
+	if (seen > base->modseq)
+		base->modseq = seen;
+	return (0);
+}
+
 /* lockless: index_save() only ever replaces the index by rename(2) */
 static int
-idle_seed_unlocked(struct store_session *ss,
-    struct imsg_mbox_idle_refreshed *reply)
+idle_seed_unlocked(struct store_session *ss)
 {
 	struct store_idle_baseline	*base = &ss->idle_baseline;
 	struct mbox_index		 idx;
@@ -1049,28 +1238,26 @@ idle_seed_unlocked(struct store_session *ss,
 	base->uids = list;
 	base->n = n;
 	base->modseq = modseq;
+	base->ghosts = 0;
 	base->valid = 1;
-	reply->exists = (uint32_t)n;
 	return (0);
 }
 
 void
-handle_mbox_idle_refresh(const struct imsg_mbox_idle_refresh *req,
-    struct store_session *ss)
+handle_mbox_idle_refresh(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;
-	size_t				 newn = 0, gone = 0, changed = 0;
 	int				 pending = 0, seeded, locked;
-	uint32_t			*newlist;
-	uint64_t			 seen_modseq = 0;
 
 	memset(&reply, 0, sizeof(reply));
+	if (!ss->mailbox_selected)
+		goto send;
 	/* a seed adopts what it finds rather than reporting it */
-	seeded = req->seed || !base->valid;
+	seeded = !base->valid;
 
 	if (idle_probe_unchanged(ss)) {
 		reply.ok = 1;
@@ -1110,31 +1297,12 @@ handle_mbox_idle_refresh(const struct imsg_mbox_idle_r
 		}
 	}
 
-	if (idle_uid_list(&idx, &newlist, &newn, &seen_modseq) == -1) {
-		index_free(&idx);
-		index_lock_release(&il);
-		goto send;
-	}
+	if (view_sync(ss, &idx, 1) == 0)
+		reply.ok = 1;
 
-	if (!seeded) {
-		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(base->uids);
-	base->uids = newlist;
-	base->n = newn;
-	base->modseq = seen_modseq;
-	base->valid = 1;
-
-	reply.ok = 1;
-	reply.exists = (uint32_t)newn;
-
-	log_debug("session %u: idle refresh: %zu uid(s), highestmodseq %llu, "
-	    "%zu expunge(s), %zu flag change(s)%s%s", session_id, newn,
-	    (unsigned long long)idx.highestmodseq, gone, changed,
+	log_debug("session %u: idle refresh: %zu uid(s) in view, "
+	    "highestmodseq %llu%s%s", session_id, base->n,
+	    (unsigned long long)idx.highestmodseq,
 	    seeded ? " (baseline seeded)" : "",
 	    pending ? " (escalated to LOCK_EX, indexed new delivery)" : "");
 
@@ -1145,7 +1313,7 @@ handle_mbox_idle_refresh(const struct imsg_mbox_idle_r
 busy:
 	/* RFC 9051 SS7.5.1: a seed cannot skip, or EXPUNGEs repeat */
 	reply.busy = 1;
-	if (seeded && idle_seed_unlocked(ss, &reply) == 0) {
+	if (seeded && idle_seed_unlocked(ss) == 0) {
 		reply.ok = 1;
 		/* so the next poll reads the index, not the probe */
 		idle_probe_reset(ss);
blob - 2c1312d3accc2cbb04903f56af503731f6550e94
blob + d1f56a65c1e211d40c9520058aff64700ffd3ce8
--- src/keymgr.c
+++ src/keymgr.c
@@ -54,6 +54,7 @@
 #include <openssl/x509.h>
 
 #include <event.h>
+#include <fcntl.h>
 #include <grp.h>
 #include <imsg.h>
 #include <pwd.h>
@@ -179,9 +180,8 @@ keymgr_main(void)
 	explicit_bzero(key_buf, sizeof(key_buf));
 	keymgr_got_init = 1;
 
-	if ((pw = getpwnam("_imapkey")) == NULL)
-		fatalx("getpwnam _imapkey: no such user "
-		    "(expected, not yet provisioned by an install script)");
+	if ((pw = getpwnam(IMAPD_KEY_USER)) == NULL)
+		fatalx("unknown user %s", IMAPD_KEY_USER);
 
 	/* the key arrives over imsg; nothing is read from disk */
 	if (chroot("/var/empty") == -1)
@@ -367,11 +367,20 @@ keymgr_dispatch_parent(int fd, short event, void *arg)
 			uint32_t		 sess_id = imsg_get_id(&imsg);
 			int			 peer_fd = imsg_get_fd(&imsg);
 			struct keymgr_peer	*kp;
+			int			 flags;
 
 			if (peer_fd == -1) {
 				log_warnx("IMSG_SETUP_PEER carried no fd");
 				break;
 			}
+			/* a listener that stops reading must not stop keymgr */
+			if ((flags = fcntl(peer_fd, F_GETFL)) == -1 ||
+			    fcntl(peer_fd, F_SETFL, flags | O_NONBLOCK) == -1) {
+				log_warn("session %u: fcntl O_NONBLOCK",
+				    sess_id);
+				close(peer_fd);
+				break;
+			}
 			if ((kp = calloc(1, sizeof(*kp))) == NULL) {
 				log_warn("calloc");
 				close(peer_fd);
blob - b83f6daf82d8b6e6b1b0d41ecc8fb9295a45c899
blob + 3726d921cf0beb10b9d3633754337174daf18e4a
--- src/listener.c
+++ src/listener.c
@@ -168,6 +168,57 @@ static const EC_KEY_METHOD	*keymgr_ecdsa_default;
 static EC_KEY_METHOD		*keymgr_ecdsae_method;
 
 static int
+keymgr_wait_reply(uint32_t type, uint32_t id, const char *op,
+    struct imsg *imsg)
+{
+	struct timespec	 start, now;
+	struct pollfd	 pfd;
+	ssize_t		 n;
+	int		 ms;
+
+	if (clock_gettime(CLOCK_MONOTONIC, &start) == -1)
+		fatal("%s: clock_gettime", op);
+	for (;;) {
+		if ((n = imsgbuf_get(&keymgr_ibuf, imsg)) == -1)
+			fatal("%s: imsg_get", op);
+		if (n != 0) {
+			if (imsg_get_type(imsg) == type &&
+			    imsg_get_id(imsg) == id)
+				return (0);
+			log_warnx("%s: unexpected reply type %u id %u "
+			    "(wanted %u/%u)", op, imsg_get_type(imsg),
+			    imsg_get_id(imsg), type, id);
+			imsg_free(imsg);
+			continue;
+		}
+		if (clock_gettime(CLOCK_MONOTONIC, &now) == -1)
+			fatal("%s: clock_gettime", op);
+		ms = KEYMGR_REPLY_TIMEOUT_SEC * 1000 -
+		    (int)((now.tv_sec - start.tv_sec) * 1000 +
+		    (now.tv_nsec - start.tv_nsec) / 1000000);
+		if (ms <= 0) {
+			log_warnx("%s: keymgr did not answer within %d "
+			    "seconds", op, KEYMGR_REPLY_TIMEOUT_SEC);
+			return (-1);
+		}
+		pfd.fd = keymgr_ibuf.fd;
+		pfd.events = POLLIN;
+		if ((n = poll(&pfd, 1, ms)) == -1) {
+			if (errno == EINTR)
+				continue;
+			fatal("%s: poll", op);
+		}
+		if (n == 0)
+			continue;
+		if ((n = imsgbuf_read(&keymgr_ibuf)) == -1)
+			fatal("%s: imsgbuf_read", op);
+		if (n == 0)
+			fatalx("%s: keymgr closed channel", op);
+	}
+}
+
+/* RSA_private_encrypt(3): -1 on error */
+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)
 {
@@ -177,19 +228,18 @@ keymgr_forward_rsa(uint32_t type, const char *hash, co
 	struct imsg	 imsg;
 	static uint32_t	 reqid;
 	uint32_t	 id;
-	ssize_t		 n;
-	int		 done, ret;
+	int		 ret;
 
 	if (fromlen < 0 || (size_t)fromlen > KEYMGR_DATA_MAX) {
 		log_warnx("keymgr_forward_rsa: %d bytes over KEYMGR_DATA_MAX",
 		    fromlen);
-		return (0);
+		return (-1);
 	}
 
 	memset(&req, 0, sizeof(req));
 	if (strlcpy(req.hash, hash, sizeof(req.hash)) >= sizeof(req.hash)) {
 		log_warnx("keymgr_forward_rsa: pubkey hash too long");
-		return (0);
+		return (-1);
 	}
 	req.padding = (uint32_t)padding;
 	req.fromlen = (uint32_t)fromlen;
@@ -201,50 +251,27 @@ keymgr_forward_rsa(uint32_t type, const char *hash, co
 	if (imsg_compose(&keymgr_ibuf, type, id, 0, -1, combined,
 	    sizeof(req) + (size_t)fromlen) == -1) {
 		log_warnx("keymgr_forward_rsa: imsg_compose");
-		return (0);
+		return (-1);
 	}
 	if (imsgbuf_flush(&keymgr_ibuf) == -1)
 		fatal("keymgr_forward_rsa: imsgbuf_flush");
 
-	ret = 0;
-	done = 0;
-	while (!done) {
-		if ((n = imsgbuf_get(&keymgr_ibuf, &imsg)) == -1)
-			fatal("keymgr_forward_rsa: imsg_get");
-		if (n == 0) {
-			if ((n = imsgbuf_read(&keymgr_ibuf)) == -1)
-				fatal("keymgr_forward_rsa: imsgbuf_read");
-			if (n == 0)
-				fatalx("keymgr_forward_rsa: keymgr closed "
-				    "channel");
-			continue;
-		}
-		if (imsg_get_type(&imsg) != type ||
-		    imsg_get_id(&imsg) != id) {
-			log_warnx("keymgr_forward_rsa: unexpected reply "
-			    "type %u id %u (wanted %u/%u)",
-			    imsg_get_type(&imsg), imsg_get_id(&imsg), type,
-			    id);
-			imsg_free(&imsg);
-			continue;
-		}
-		if (imsg_get_buf(&imsg, &rep, sizeof(rep)) == -1) {
-			log_warnx("keymgr_forward_rsa: bad reply header");
-			imsg_free(&imsg);
-			break;
-		}
-		/* 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)
-				log_warnx("keymgr_forward_rsa: bad reply "
-				    "data");
-			else
-				ret = (int)rep.tolen;
-		}
+	if (keymgr_wait_reply(type, id, "keymgr_forward_rsa", &imsg) == -1)
+		return (-1);
+	if (imsg_get_buf(&imsg, &rep, sizeof(rep)) == -1) {
+		log_warnx("keymgr_forward_rsa: bad reply header");
 		imsg_free(&imsg);
-		done = 1;
+		return (-1);
 	}
+	ret = -1;
+	if (rep.ok && rep.tolen <= tosize &&
+	    imsg_get_len(&imsg) == rep.tolen) {
+		if (imsg_get_buf(&imsg, to, rep.tolen) == -1)
+			log_warnx("keymgr_forward_rsa: bad reply data");
+		else
+			ret = (int)rep.tolen;
+	}
+	imsg_free(&imsg);
 
 	return (ret);
 }
@@ -261,8 +288,6 @@ keymgr_forward_ecdsa(const char *hash, const unsigned 
 	ECDSA_SIG	*sig = NULL;
 	static uint32_t	 reqid;
 	uint32_t	 id;
-	ssize_t		 n;
-	int		 done;
 	const unsigned char *sigp;
 
 	if (dgst_len < 0 || (size_t)dgst_len > KEYMGR_DATA_MAX) {
@@ -290,45 +315,24 @@ keymgr_forward_ecdsa(const char *hash, const unsigned 
 	if (imsgbuf_flush(&keymgr_ibuf) == -1)
 		fatal("keymgr_forward_ecdsa: imsgbuf_flush");
 
-	done = 0;
-	while (!done) {
-		if ((n = imsgbuf_get(&keymgr_ibuf, &imsg)) == -1)
-			fatal("keymgr_forward_ecdsa: imsg_get");
-		if (n == 0) {
-			if ((n = imsgbuf_read(&keymgr_ibuf)) == -1)
-				fatal("keymgr_forward_ecdsa: imsgbuf_read");
-			if (n == 0)
-				fatalx("keymgr_forward_ecdsa: keymgr closed "
-				    "channel");
-			continue;
-		}
-		if (imsg_get_type(&imsg) != IMSG_KEYMGR_ECDSA_SIGN ||
-		    imsg_get_id(&imsg) != id) {
-			log_warnx("keymgr_forward_ecdsa: unexpected reply "
-			    "type %u id %u (wanted %u/%u)",
-			    imsg_get_type(&imsg), imsg_get_id(&imsg),
-			    IMSG_KEYMGR_ECDSA_SIGN, id);
-			imsg_free(&imsg);
-			continue;
-		}
-		if (imsg_get_buf(&imsg, &rep, sizeof(rep)) == -1) {
-			log_warnx("keymgr_forward_ecdsa: bad reply header");
-			imsg_free(&imsg);
-			break;
-		}
-		if (rep.ok && rep.tolen <= KEYMGR_DATA_MAX &&
-		    imsg_get_len(&imsg) == rep.tolen) {
-			if (imsg_get_buf(&imsg, sigbuf, rep.tolen) == -1)
-				log_warnx("keymgr_forward_ecdsa: bad reply "
-				    "data");
-			else {
-				sigp = sigbuf;
-				d2i_ECDSA_SIG(&sig, &sigp, (long)rep.tolen);
-			}
-		}
+	if (keymgr_wait_reply(IMSG_KEYMGR_ECDSA_SIGN, id,
+	    "keymgr_forward_ecdsa", &imsg) == -1)
+		return (NULL);
+	if (imsg_get_buf(&imsg, &rep, sizeof(rep)) == -1) {
+		log_warnx("keymgr_forward_ecdsa: bad reply header");
 		imsg_free(&imsg);
-		done = 1;
+		return (NULL);
 	}
+	if (rep.ok && rep.tolen <= KEYMGR_DATA_MAX &&
+	    imsg_get_len(&imsg) == rep.tolen) {
+		if (imsg_get_buf(&imsg, sigbuf, rep.tolen) == -1)
+			log_warnx("keymgr_forward_ecdsa: bad reply data");
+		else {
+			sigp = sigbuf;
+			d2i_ECDSA_SIG(&sig, &sigp, (long)rep.tolen);
+		}
+	}
+	imsg_free(&imsg);
 
 	return (sig);
 }
@@ -547,9 +551,8 @@ listener_main(void)
 		fatalx("listener: IMSG_LISTENER_SESSION_INIT carried no "
 		    "client fd");
 
-	if ((pw = getpwnam("_imapd")) == NULL)
-		fatalx("getpwnam _imapd: no such user "
-		    "(expected, not yet provisioned by an install script)");
+	if ((pw = getpwnam(IMAPD_USER)) == NULL)
+		fatalx("unknown user %s", IMAPD_USER);
 
 	if (chroot("/var/empty") == -1)
 		fatal("chroot /var/empty");
@@ -1504,6 +1507,8 @@ parse_command_line(char *line, char **tag, char **name
 static int
 session_is_busy(const struct session *s)
 {
+	if (s->refresh_status != NULL)
+		return (1);
 	switch (s->state) {
 	case SESSION_SELECTING:
 	case SESSION_FETCHING:
blob - f33cba5aa3d76a8ba44c5945fc1a086bc3970e2d
blob + 272fe1eb376922247b102eb04ff8e4fc7e165ebc
--- src/listener.h
+++ src/listener.h
@@ -186,7 +186,9 @@ struct session {
 	/* 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;
+	/* the tagged reply IDLE or NOOP owes once the refresh is in */
+	const char		*refresh_status;
+	const char		*refresh_text;
 	struct event		 idle_ev;
 	struct event		 grace_ev;
 
@@ -312,13 +314,11 @@ void	 session_handle_select_fetch(struct session *,
 		    const struct imsg_mbox_fetch_meta *);
 void	 session_handle_store_modified(struct session *,
 		    struct imsg_mbox_store_modified *);
-void	 session_handle_idle_expunge(struct session *,
-		    const struct imsg_mbox_idle_expunge *);
-void	 session_handle_idle_fetch(struct session *,
+void	 session_handle_view_fetch(struct session *,
 		    const struct imsg_mbox_fetch_meta *);
 void	 session_handle_idle_refreshed(struct session *,
 		    const struct imsg_mbox_idle_refreshed *);
-void	 session_request_idle_refresh(struct session *, int);
+int	 session_request_idle_refresh(struct session *);
 void	 session_idle_poll_init(struct session *);
 void	 session_login_grace_init(struct session *);
 void	 session_login_grace_disarm(struct session *);
blob - 07161abf280fd522a6ce6a00dc337ee613c5a0b9
blob + 328b47f988740d0a5fe9f0f6576e7df5ddcd1803
--- src/main.c
+++ src/main.c
@@ -19,6 +19,7 @@
 #include <sys/types.h>
 
 #include <err.h>
+#include <pwd.h>
 #include <signal.h>
 #include <stdio.h>
 #include <stdlib.h>
@@ -136,6 +137,12 @@ main(int argc, char *argv[])
 		if (geteuid() != 0)
 			fatalx("parent must start as root (running as "
 			    "uid %u)", (unsigned int)geteuid());
+		if (getpwnam(IMAPD_USER) == NULL)
+			fatalx("unknown user %s", IMAPD_USER);
+		if (getpwnam(IMAPD_AUTH_USER) == NULL)
+			fatalx("unknown user %s", IMAPD_AUTH_USER);
+		if (getpwnam(IMAPD_KEY_USER) == NULL)
+			fatalx("unknown user %s", IMAPD_KEY_USER);
 		if (config_load(conffile, &conf) == -1)
 			fatalx("config_load: %s", conffile);
 		parent_main(conffile, argc, argv, &conf);
blob - 458f5c3e1ebc8d703f72a9e02f98902b7b6a33c1
blob + c64b5e7d6cf102bfad49200a205fe00d009d82b6
--- src/mbox_copy.c
+++ src/mbox_copy.c
@@ -468,6 +468,8 @@ handle_mbox_copy(struct imsg_mbox_copy *req, const str
 	size_t				 nstaged = 0, i;
 	struct index_lock		 il_a = INDEX_LOCK_INIT;
 	struct index_lock		 il_b = INDEX_LOCK_INIT;
+	struct seq_range		 vr[SEQSET_MAX_RANGES];
+	uint32_t			 nvr;
 	int				 ok = 1;
 	char				 desttarget[MBOX_NAME_MAX];
 	int				 cross_mailbox;
@@ -487,8 +489,11 @@ handle_mbox_copy(struct imsg_mbox_copy *req, const str
 		goto done;
 	}
 	dfd = cross_mailbox ? destfd : ss->mailbox_dir_fd;
+	/* RFC 2180 SS4.4.2: the numbers are those before the EXPUNGEs */
+	view_resolve(ss, ranges, nranges, vr, &nvr, &req->by_uid);
+	(void)view_sync(ss, srcidx, 1);
 
-	if (!stage_copy_messages(ss, srcidx, req, ranges, nranges, &staged,
+	if (!stage_copy_messages(ss, srcidx, req, vr, nvr, &staged,
 	    &nstaged)) {
 		ok = 0;
 		goto close_dest;
@@ -684,16 +689,8 @@ move_same_mailbox(struct imsg_mbox_copy *req, const st
 	}
 	/* then EXPUNGE (RFC 9051 SS6.4.8) */
 	for (i = 0; i < nmoved; i++) {
-		struct imsg_mbox_expunged	 exp;
-
 		pcache_drop_uid(ss->selected_mailbox, moved[i].old_uid);
-		memset(&exp, 0, sizeof(exp));
-		exp.seqno = moved[i].old_seqno;
-		exp.uid = moved[i].old_uid;
-		if (imsg_compose(&iev->ibuf, IMSG_MBOX_EXPUNGED, 0, 0, -1,
-		    &exp, sizeof(exp)) == -1)
-			log_warn("session %u: imsg_compose IMSG_MBOX_EXPUNGED",
-			    session_id);
+		(void)send_expunged(ss, moved[i].old_seqno, moved[i].old_uid);
 	}
 
 done:
@@ -709,7 +706,6 @@ move_cross_mailbox(struct imsg_mbox_copy *req, const s
     uint32_t nranges, struct mbox_index *srcidx, struct mbox_index *destidx,
     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;
@@ -767,17 +763,7 @@ move_cross_mailbox(struct imsg_mbox_copy *req, const s
 				unlinkat(ss->mailbox_dir_fd, path, 0);
 		}
 
-		{
-			struct imsg_mbox_expunged	exp;
-
-			memset(&exp, 0, sizeof(exp));
-			exp.seqno = old_seqno;
-			exp.uid = staged[i].src_uid;
-			if (imsg_compose(&iev->ibuf, IMSG_MBOX_EXPUNGED, 0, 0,
-			    -1, &exp, sizeof(exp)) == -1)
-				log_warn("session %u: imsg_compose "
-				    "IMSG_MBOX_EXPUNGED", session_id);
-		}
+		(void)send_expunged(ss, old_seqno, staged[i].src_uid);
 	}
 
 	/* RFC 7162 SS3.1 */
@@ -818,6 +804,8 @@ handle_mbox_move(struct imsg_mbox_copy *req, const str
 	char				 desttarget[MBOX_NAME_MAX];
 	int				 cross_mailbox;
 	int				 destfd = -1, got;
+	struct seq_range		 vr[SEQSET_MAX_RANGES];
+	uint32_t			 nvr;
 
 	memset(&idx_a, 0, sizeof(idx_a));
 	memset(&idx_b, 0, sizeof(idx_b));
@@ -833,12 +821,13 @@ handle_mbox_move(struct imsg_mbox_copy *req, const str
 		goto done;
 	}
 
+	view_resolve(ss, ranges, nranges, vr, &nvr, &req->by_uid);
+	(void)view_sync(ss, srcidx, 1);
 	if (!cross_mailbox) {
-		if (!move_same_mailbox(req, ranges, nranges, srcidx, &nmoved,
-		    ss))
+		if (!move_same_mailbox(req, vr, nvr, srcidx, &nmoved, ss))
 			ok = 0;
 	} else {
-		if (!move_cross_mailbox(req, ranges, nranges, srcidx, destidx,
+		if (!move_cross_mailbox(req, vr, nvr, srcidx, destidx,
 		    destfd, &nmoved, ss))
 			ok = 0;
 	}
blob - 0b694205c1d879f3ca71a8c24865436b4b7da1e7
blob + 3088dce4bc163aa0100848cfe302e99edd45c2d5
--- src/mbox_fetch.c
+++ src/mbox_fetch.c
@@ -64,6 +64,34 @@ fetch_send_part(struct store_session *ss, int imsg_typ
 	free(combined);
 }
 
+/* RFC 9051 SS6.4.5: BODY[...] sets \Seen, with STORE's own code */
+static int
+fetch_set_seen(struct store_session *ss, int synced)
+{
+	struct store_fetch_walk	*fw = &ss->fetch;
+	struct imsg_mbox_store	 sreq;
+	struct store_step	*steps = NULL;
+	size_t			 nsteps = 0;
+	uint64_t		 before = fw->idx.highestmodseq;
+	int			 ok;
+
+	memset(&sreq, 0, sizeof(sreq));
+	sreq.mode = MBOX_STORE_ADD;
+	sreq.sysflags = MBOX_FLAG_SEEN;
+	ok = store_apply(ss, &sreq, &fw->idx, fw->resolved, fw->nresolved,
+	    fw->req.by_uid, synced, fw->req.has_changedsince ?
+	    &fw->req.changedsince : NULL, &steps, &nsteps);
+	free(steps);
+	if (!ok)
+		return (-1);
+	if (fw->idx.highestmodseq != before) {
+		fw->seen_modseq = fw->idx.highestmodseq;
+		/* its renames left cur/'s snapshot naming the old files */
+		cur_snapshot_discard(&ss->cur_snap);
+	}
+	return (0);
+}
+
 /* returns 1, unanswered, if the index lock is busy */
 int
 handle_mbox_fetch(struct imsg_mbox_fetch *req, const struct seq_range *ranges,
@@ -73,11 +101,14 @@ handle_mbox_fetch(struct imsg_mbox_fetch *req, const s
 	struct imsgev		*iev = &ss->iev;
 	struct mbox_index	*idx = &fw->idx;
 	struct index_lock	 il = INDEX_LOCK_INIT;
-	uint32_t		 i;
-	int			 locked;
+	struct seq_range	 vr[SEQSET_MAX_RANGES];
+	uint32_t		 i, nvr;
+	int			 locked, synced, rc, set_seen;
 
+	set_seen = (req->attrs & MBOX_FETCH_SET_SEEN) != 0;
 	/* Before the reset below, so that a busy return changes nothing. */
-	locked = index_lock_acquire(ss->mailbox_dir_fd, &il, LOCK_SH | LOCK_NB);
+	locked = index_lock_acquire(ss->mailbox_dir_fd, &il,
+	    (set_seen ? LOCK_EX : LOCK_SH) | LOCK_NB);
 	if (locked == 1)
 		return (1);
 
@@ -99,15 +130,29 @@ handle_mbox_fetch(struct imsg_mbox_fetch *req, const s
 		fetch_walk_finish(ss, 0);
 		return (0);
 	}
-	index_lock_release(&il);
+	if (!set_seen)
+		index_lock_release(&il);
 	pcache_check_mailbox(ss->selected_mailbox, idx->uidvalidity);
 
+	synced = view_sync(ss, idx, req->by_uid) == 0;
+	view_resolve(ss, ranges, nranges, vr, &nvr, &fw->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,
+	fw->nresolved = seqset_resolve(vr, nvr, fw->req.by_uid ?
+	    index_max_uid(idx) : (uint32_t)idx->nlines, !fw->req.by_uid,
 	    fw->resolved);
 	fw->max_hi = seqset_max_hi(fw->resolved, fw->nresolved);
+	fw->ghost = !req->by_uid &&
+	    view_names_ghost(ss, idx, fw->resolved, fw->nresolved);
 
+	if (set_seen) {
+		rc = fetch_set_seen(ss, synced);
+		index_lock_release(&il);
+		if (rc == -1) {
+			fetch_walk_finish(ss, 0);
+			return (0);
+		}
+	}
+
 	/* RFC 7162 SS3.2.6: VANISHED (EARLIER) first */
 	if (req->by_uid && req->want_vanished) {
 		for (i = 0; i < fw->nresolved; i++)
@@ -205,11 +250,14 @@ fetch_walk_step(struct store_session *ss)
 		}
 
 		memset(&meta, 0, sizeof(meta));
-		meta.seqno = i;
+		meta.seqno = view_seqno(ss, rec.uid, i);
 		meta.uid = rec.uid;
 		meta.modseq = rec.modseq;
+		meta.seen_set = fw->seen_modseq != 0 &&
+		    rec.modseq == fw->seen_modseq;
 
-		if (req->attrs & (MBOX_FETCH_RFC822_SIZE | MBOX_FETCH_FLAGS)) {
+		if (meta.seen_set || (req->attrs &
+		    (MBOX_FETCH_RFC822_SIZE | MBOX_FETCH_FLAGS))) {
 			if (locate_message_file(&ss->cur_snap,
 			    ss->mailbox_dir_fd, rec.basename, &size, suffix,
 			    sizeof(suffix)) == -1) {
@@ -223,7 +271,7 @@ fetch_walk_step(struct store_session *ss)
 
 		if (req->attrs & MBOX_FETCH_RFC822_SIZE)
 			meta.size = (uint64_t)size;
-		if (req->attrs & MBOX_FETCH_FLAGS)
+		if ((req->attrs & MBOX_FETCH_FLAGS) || meta.seen_set)
 			build_flags_string(have_file ? suffix : "",
 			    rec.keywords, meta.flags, sizeof(meta.flags));
 		if (req->attrs & MBOX_FETCH_INTERNALDATE)
@@ -241,7 +289,7 @@ fetch_walk_step(struct store_session *ss)
 			int				 mfd, rc;
 
 			memset(&hdrmeta, 0, sizeof(hdrmeta));
-			hdrmeta.seqno = i;
+			hdrmeta.seqno = view_seqno(ss, rec.uid, i);
 			hdrmeta.uid = rec.uid;
 			if (req->attrs & MBOX_FETCH_BODY_HEADER)
 				rc = read_message_header(&ss->cur_snap,
@@ -290,7 +338,7 @@ fetch_walk_step(struct store_session *ss)
 			    !want_part;
 
 			memset(&bodymeta, 0, sizeof(bodymeta));
-			bodymeta.seqno = i;
+			bodymeta.seqno = view_seqno(ss, rec.uid, i);
 			bodymeta.uid = rec.uid;
 
 			if (want_part) {
@@ -367,7 +415,7 @@ fetch_walk_step(struct store_session *ss)
 			int					 mfd;
 
 			memset(&envmeta, 0, sizeof(envmeta));
-			envmeta.seqno = i;
+			envmeta.seqno = view_seqno(ss, rec.uid, i);
 			envmeta.uid = rec.uid;
 			if ((env = fetch_cached(ss, &rec, PCACHE_ENVELOPE,
 			    &have_file, &envmeta.envlen)) != NULL)
@@ -403,7 +451,7 @@ fetch_walk_step(struct store_session *ss)
 			int					 mfd;
 
 			memset(&bsmeta, 0, sizeof(bsmeta));
-			bsmeta.seqno = i;
+			bsmeta.seqno = view_seqno(ss, rec.uid, i);
 			bsmeta.uid = rec.uid;
 			if ((bs = fetch_cached(ss, &rec, PCACHE_BODYSTRUCTURE,
 			    &have_file, &bsmeta.bslen)) != NULL)
@@ -461,7 +509,8 @@ fetch_walk_finish(struct store_session *ss, int ok)
 	cur_snapshot_discard(&ss->cur_snap);
 
 	memset(&result, 0, sizeof(result));
-	result.error = ok ? MBOX_OP_OK : MBOX_OP_ERR_GENERIC;
+	result.error = !ok ? MBOX_OP_ERR_GENERIC : fw->ghost ?
+	    MBOX_OP_ERR_EXPUNGEISSUED : MBOX_OP_OK;
 	result.count = fw->sent;
 	if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
 	    sizeof(result)) == -1)
blob - 63953515d197c7229551665b077361c892fc219a
blob + db797c1b29abb06e90a0be290a8fd944fd711c7f
--- src/mbox_manage.c
+++ src/mbox_manage.c
@@ -116,6 +116,7 @@ handle_mbox_select(struct imsg_mbox_select *req,
 
 	if (req->qresync && req->qresync_uidvalidity == idx.uidvalidity)
 		qresync_send_resync(req, ranges, nranges, &idx, ss);
+	idle_baseline_seed(ss, &idx);
 
 	index_free(&idx);
 	index_lock_release(&il);
@@ -1198,7 +1199,9 @@ handle_mbox_append_end(struct store_session *ss)
 		goto done_index;
 
 	reply.uidvalidity = idx.uidvalidity;
-	reply.exists = (uint32_t)idx.nlines;
+	if (ss->mailbox_selected && strcmp(ss->selected_mailbox,
+	    mailbox_name_is_inbox(ap->req.mailbox) ? "" : ap->req.mailbox) == 0)
+		(void)view_sync(ss, &idx, 1);
 	index_lock_release(&il);
 	index_free(&idx);
 
blob - 3f440822a5f3f8e4528335336b76cdb09c52bb88
blob + fb8ba5603a5446fd95335c1e1ef8654a5f73daf2
--- src/mbox_search.c
+++ src/mbox_search.c
@@ -278,8 +278,8 @@ handle_mbox_search(struct imsg_mbox_search *req, struc
 	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;
+	uint32_t			 max_uid, i, nout;
+	int				 locked, by_uid;
 
 	/* Before the reset below, so that a busy return changes nothing. */
 	locked = index_lock_acquire(ss->mailbox_dir_fd, &il, LOCK_SH | LOCK_NB);
@@ -311,6 +311,7 @@ handle_mbox_search(struct imsg_mbox_search *req, struc
 	sw->nnodes = nnodes;
 
 	max_uid = index_max_uid(&sw->idx);
+	(void)view_sync(ss, &sw->idx, 0);
 
 	for (i = 0; i < nnodes; i++) {
 		struct search_node	*n = &sw->nodes[i];
@@ -340,6 +341,15 @@ handle_mbox_search(struct imsg_mbox_search *req, struc
 		in.lo_is_star = n->lo_is_star;
 		in.hi_is_star = n->hi_is_star;
 
+		by_uid = n->op == SEARCH_OP_UIDSET;
+		view_resolve(ss, &in, 1, &out, &nout, &by_uid);
+		if (n->op == SEARCH_OP_SEQSET && by_uid) {
+			n->op = SEARCH_OP_UIDSET;
+			n->seq_lo = nout > 0 ? out.lo : 0;
+			n->seq_hi = nout > 0 ? out.hi : 0;
+			n->lo_is_star = n->hi_is_star = 0;
+			continue;
+		}
 		(void)seqset_resolve(&in, 1, max, n->op == SEARCH_OP_SEQSET,
 		    &out);
 		n->seq_lo = out.lo;
@@ -421,7 +431,7 @@ search_walk_step(struct store_session *ss)
 			continue;
 
 		memset(&m, 0, sizeof(m));
-		m.seqno = i;
+		m.seqno = view_seqno(ss, rec.uid, i);
 		m.uid = rec.uid;
 		m.modseq = rec.modseq;
 
@@ -465,7 +475,7 @@ search_walk_step(struct store_session *ss)
 			struct imsg_mbox_search_match	 match;
 
 			memset(&match, 0, sizeof(match));
-			match.seqno = i;
+			match.seqno = m.seqno;
 			match.uid = m.uid;
 			match.modseq = m.modseq;
 			if (imsg_compose(&iev->ibuf, IMSG_MBOX_SEARCH_MATCH,
blob - ffb4cf902c1fc490f7cdf4d038efe4efab3c46f3
blob + 4061bcf4257fbef8e964f7f25980a2576a5360c8
--- src/mbox_store.c
+++ src/mbox_store.c
@@ -150,55 +150,22 @@ store_undo(struct store_session *ss, const struct mbox
 	}
 }
 
-/* RFC 9051 SS6.4.6 STORE: plan every message, then change */
+/* RFC 9051 SS6.4.6 STORE: plan every message, then change; LOCK_EX held */
 int
-handle_mbox_store(struct imsg_mbox_store *req, const struct seq_range *ranges,
-    uint32_t nranges, struct store_session *ss)
+store_apply(struct store_session *ss, const struct imsg_mbox_store *req,
+    struct mbox_index *idx, const struct seq_range *resolved,
+    uint32_t nresolved, int by_uid, int synced, const uint64_t *since,
+    struct store_step **stepsp, size_t *nstepsp)
 {
-	struct imsgev		*iev = &ss->iev;
-	int			 locked;
-	struct mbox_index	 idx;
-	struct imsg_mbox_result	 result;
-	struct index_lock	 il = INDEX_LOCK_INIT;
-	struct seq_range	 resolved[SEQSET_MAX_RANGES];
 	struct store_step	*steps = NULL, *grown;
-	size_t			 nsteps = 0, maxsteps = 0, k;
-	uint32_t		 i, nresolved, max_hi, sent = 0;
-	uint64_t		 new_modseq, reported = 0;
+	size_t			 nsteps = 0, maxsteps = 0;
+	uint32_t		 i, max_hi;
+	uint64_t		 new_modseq = idx->highestmodseq + 1;
 	int			 ok = 1, changed = 0, pass;
 
-	memset(&idx, 0, sizeof(idx));
-
-	if (!index_field_valid(req->keywords)) {
-		log_warnx("session %u: STORE: refusing unsafe keywords field",
-		    session_id);
-		ok = 0;
-		goto done;
-	}
-	/* 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) {
-		ok = 0;
-		goto done;
-	}
-	if (index_load(il.fd, &idx) == -1) {
-		ok = 0;
-		goto done;
-	}
-
-	reported = idx.highestmodseq;
-	new_modseq = idx.highestmodseq + 1;
-
-	/* RFC 9051 SS6.4.9 */
-	nresolved = seqset_resolve(ranges, nranges, req->by_uid ?
-	    index_max_uid(&idx) : (uint32_t)idx.nlines, !req->by_uid,
-	    resolved);
 	max_hi = seqset_max_hi(resolved, nresolved);
-
 	for (pass = 0; pass < 2 && ok; pass++) {
-		for (i = 1; i <= (uint32_t)idx.nlines; i++) {
+		for (i = 1; i <= (uint32_t)idx->nlines; i++) {
 			struct index_rec	 rec;
 			struct store_step	 st;
 			enum seqset_pos		 pos;
@@ -207,13 +174,14 @@ handle_mbox_store(struct imsg_mbox_store *req, const s
 			char			*dup;
 			int			 rc;
 
-			if (index_parse_line(idx.lines[i - 1], &rec) == -1)
+			if (index_parse_line(idx->lines[i - 1], &rec) == -1)
 				continue;
 			pos = seqset_position(resolved, nresolved, max_hi,
-			    req->by_uid, rec.uid, i);
+			    by_uid, rec.uid, i);
 			if (pos == SEQSET_PAST_END)
 				break;
-			if (pos == SEQSET_SKIP)
+			if (pos == SEQSET_SKIP ||
+			    (since != NULL && rec.modseq <= *since))
 				continue;
 
 			memset(&st, 0, sizeof(st));
@@ -270,24 +238,85 @@ handle_mbox_store(struct imsg_mbox_store *req, const s
 				break;
 			}
 			steps[nsteps++] = st;
-			free(idx.lines[i - 1]);
-			idx.lines[i - 1] = dup;
+			free(idx->lines[i - 1]);
+			idx->lines[i - 1] = dup;
 			if (st.changed)
 				changed = 1;
 		}
 	}
 
 	if (ok && changed) {
-		idx.highestmodseq = new_modseq;
-		if (index_save(ss->mailbox_dir_fd, &idx) == -1)
+		idx->highestmodseq = new_modseq;
+		if (index_save(ss->mailbox_dir_fd, idx) == -1)
 			ok = 0;
 	}
-	if (!ok) {
-		store_undo(ss, &idx, steps, nsteps);
+	if (!ok)
+		store_undo(ss, idx, steps, nsteps);
+	else if (synced && changed && new_modseq > ss->idle_baseline.modseq)
+		/* LOCK_EX held since the sync: new_modseq is ours alone */
+		ss->idle_baseline.modseq = new_modseq;
+	*stepsp = steps;
+	*nstepsp = nsteps;
+	return (ok);
+}
+
+int
+handle_mbox_store(struct imsg_mbox_store *req, const struct seq_range *ranges,
+    uint32_t nranges, struct store_session *ss)
+{
+	struct imsgev		*iev = &ss->iev;
+	int			 locked;
+	struct mbox_index	 idx;
+	struct imsg_mbox_result	 result;
+	struct index_lock	 il = INDEX_LOCK_INIT;
+	struct seq_range	 resolved[SEQSET_MAX_RANGES];
+	struct seq_range	 vr[SEQSET_MAX_RANGES];
+	struct store_step	*steps = NULL;
+	size_t			 nsteps = 0, k;
+	uint32_t		 nresolved, nvr, sent = 0;
+	uint64_t		 reported = 0;
+	int			 ok = 1, by_uid, synced;
+	int			 ghost;
+
+	memset(&idx, 0, sizeof(idx));
+
+	if (!index_field_valid(req->keywords)) {
+		log_warnx("session %u: STORE: refusing unsafe keywords field",
+		    session_id);
+		ok = 0;
 		goto done;
 	}
+	/* 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) {
+		ok = 0;
+		goto done;
+	}
+	if (index_load(il.fd, &idx) == -1) {
+		ok = 0;
+		goto done;
+	}
+
+	synced = view_sync(ss, &idx, req->by_uid) == 0;
+	by_uid = req->by_uid;
+	view_resolve(ss, ranges, nranges, vr, &nvr, &by_uid);
+
 	reported = idx.highestmodseq;
 
+	/* RFC 9051 SS6.4.9 */
+	nresolved = seqset_resolve(vr, nvr, by_uid ?
+	    index_max_uid(&idx) : (uint32_t)idx.nlines, !by_uid, resolved);
+	ghost = !req->by_uid && view_names_ghost(ss, &idx, resolved,
+	    nresolved);
+
+	ok = store_apply(ss, req, &idx, resolved, nresolved, by_uid, synced,
+	    NULL, &steps, &nsteps);
+	if (!ok)
+		goto done;
+	reported = idx.highestmodseq;
+
 	for (k = 0; k < nsteps; k++) {
 		const struct store_step		*st = &steps[k];
 		struct index_rec			 rec;
@@ -298,7 +327,7 @@ handle_mbox_store(struct imsg_mbox_store *req, const s
 			struct imsg_mbox_store_modified	 mod;
 
 			memset(&mod, 0, sizeof(mod));
-			mod.seqno = st->seqno;
+			mod.seqno = view_seqno(ss, rec.uid, st->seqno);
 			mod.uid = rec.uid;
 			if (imsg_compose(&iev->ibuf, IMSG_MBOX_STORE_MODIFIED,
 			    0, 0, -1, &mod, sizeof(mod)) == -1)
@@ -313,7 +342,7 @@ handle_mbox_store(struct imsg_mbox_store *req, const s
 			char				 newsuffix[16];
 
 			memset(&meta, 0, sizeof(meta));
-			meta.seqno = st->seqno;
+			meta.seqno = view_seqno(ss, rec.uid, st->seqno);
 			meta.uid = rec.uid;
 			meta.modseq = st->modseq;
 			(void)snprintf(newsuffix, sizeof(newsuffix), "2,%s",
@@ -334,7 +363,9 @@ done:
 	free(steps);
 
 	memset(&result, 0, sizeof(result));
-	result.error = ok ? MBOX_OP_OK : MBOX_OP_ERR_GENERIC;
+	/* RFC 2180 SS4.2.1-SS4.2.3 */
+	result.error = !ok ? MBOX_OP_ERR_GENERIC : ghost && !req->silent ?
+	    MBOX_OP_ERR_EXPUNGEISSUED : MBOX_OP_OK;
 	result.count = sent;
 	/* never a value the index does not hold */
 	result.highestmodseq = reported;
@@ -382,6 +413,8 @@ handle_mbox_expunge(struct imsg_mbox_expunge *req,
 		goto done;
 	}
 	reported = idx.highestmodseq;
+	if (!req->silent)
+		(void)view_sync(ss, &idx, 1);
 
 	if (req->by_uid)
 		nresolved = seqset_resolve(ranges, nranges,
@@ -471,19 +504,9 @@ handle_mbox_expunge(struct imsg_mbox_expunge *req,
 			log_warn("session %u: unlink %s, left on disk with no "
 			    "index entry", session_id, path);
 
-		if (!req->silent) {
-			struct imsg_mbox_expunged	 exp;
-
-			memset(&exp, 0, sizeof(exp));
-			exp.seqno = gone[k].seqno;
-			exp.uid = rec.uid;
-			if (imsg_compose(&iev->ibuf, IMSG_MBOX_EXPUNGED, 0, 0,
-			    -1, &exp, sizeof(exp)) == -1)
-				log_warn("session %u: imsg_compose "
-				    "IMSG_MBOX_EXPUNGED", session_id);
-			else
-				sent++;
-		}
+		if (!req->silent &&
+		    send_expunged(ss, gone[k].seqno, rec.uid) == 0)
+			sent++;
 	}
 
 done:
blob - b27d4cefa382bca517e4f751af19673567f77a9d
blob + 0efcbefdf39129fc1141c56af54fa50edb9a656c
--- src/mime.c
+++ src/mime.c
@@ -1001,8 +1001,8 @@ find_mime_part(int depth, const char *hdr, size_t hdrl
 	int	has_boundary;
 	size_t	part_starts[MIME_MAX_PARTS], part_ends[MIME_MAX_PARTS];
 	int	n, want;
-	const char	*pbuf;
-	size_t		 plen, phdrend;
+	const char	*pbuf, *pbody;
+	size_t		 plen, phdrend, pbodylen, ih;
 
 	if (depth > MIME_MAX_DEPTH)
 		return (-1);
@@ -1029,44 +1029,52 @@ find_mime_part(int depth, const char *hdr, size_t hdrl
 	else if (find_header_body_split(pbuf, plen, &phdrend) == -1)
 		return (-1);
 
+	params_fmt[0] = '\0';
+	if (parse_content_type(pbuf, phdrend, type, sizeof(type), subtype,
+	    sizeof(subtype), params_fmt, sizeof(params_fmt), boundary,
+	    sizeof(boundary), &has_boundary) == -1)
+		return (-1);
+	pbody = pbuf + phdrend;
+	pbodylen = plen - phdrend;
+
 	if (pathlen == 1) {
-		/* a leaf only: no "combined children" of a multipart */
-		char	 ctype[64], csub[64], cparams[600];
-		char	 cboundary[70 + 1];
-		int	 chb;
-
-		cparams[0] = '\0';
-		if (parse_content_type(pbuf, phdrend, ctype, sizeof(ctype),
-		    csub, sizeof(csub), cparams, sizeof(cparams), cboundary,
-		    sizeof(cboundary), &chb) == -1)
+		/* a leaf or a message: no "combined children" of a multipart */
+		if (strcasecmp(type, "MULTIPART") == 0)
 			return (-1);
-		if (strcasecmp(ctype, "MULTIPART") == 0 ||
-		    (strcasecmp(ctype, "MESSAGE") == 0 &&
-		    (strcasecmp(csub, "RFC822") == 0 ||
-		    strcasecmp(csub, "GLOBAL") == 0)))
-			return (-1);
-
-		*part_out = pbuf + phdrend;
-		*partlen_out = plen - phdrend;
+		*part_out = pbody;
+		*partlen_out = pbodylen;
 		return (0);
 	}
 
-	return (find_mime_part(depth + 1, pbuf, phdrend, pbuf + phdrend,
-	    plen - phdrend, path + 1, pathlen - 1, part_out, partlen_out));
+	/* RFC 9051 SS6.4.5.1: a message's parts are numbered within it */
+	if (strcasecmp(type, "MESSAGE") == 0 &&
+	    (strcasecmp(subtype, "RFC822") == 0 ||
+	    strcasecmp(subtype, "GLOBAL") == 0)) {
+		if (pbodylen == 0 ||
+		    find_header_body_split(pbody, pbodylen, &ih) == -1)
+			return (-1);
+		return (locate_mime_part(depth + 1, pbody, ih, pbody + ih,
+		    pbodylen - ih, path + 1, pathlen - 1, part_out,
+		    partlen_out));
+	}
+
+	return (find_mime_part(depth + 1, pbuf, phdrend, pbody, pbodylen,
+	    path + 1, pathlen - 1, part_out, partlen_out));
 }
 
 /* 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,
-    size_t *partlen_out)
+locate_mime_part(int depth, const char *hdr, size_t hdrlen,
+    const char *body, size_t bodylen, const int *path, int pathlen,
+    const char **part_out, size_t *partlen_out)
 {
 	char	type[64], subtype[64];
 	char	params_fmt[600];
 	char	boundary[70 + 1];
 	int	has_boundary;
+	size_t	ih;
 
-	if (pathlen < 1)
+	if (depth > MIME_MAX_DEPTH || pathlen < 1)
 		return (-1);
 
 	params_fmt[0] = '\0';
@@ -1076,14 +1084,23 @@ locate_mime_part(const char *hdr, size_t hdrlen, const
 		return (-1);
 
 	if (strcasecmp(type, "MULTIPART") == 0)
-		return (find_mime_part(1, hdr, hdrlen, body, bodylen, path,
-		    pathlen, part_out, partlen_out));
+		return (find_mime_part(depth + 1, hdr, hdrlen, body, bodylen,
+		    path, pathlen, part_out, partlen_out));
 
-	if (pathlen != 1 || path[0] != 1)
+	if (path[0] != 1)
 		return (-1);
-	*part_out = body;
-	*partlen_out = bodylen;
-	return (0);
+	if (pathlen == 1) {
+		*part_out = body;
+		*partlen_out = bodylen;
+		return (0);
+	}
+	if (strcasecmp(type, "MESSAGE") != 0 ||
+	    (strcasecmp(subtype, "RFC822") != 0 &&
+	    strcasecmp(subtype, "GLOBAL") != 0) || bodylen == 0 ||
+	    find_header_body_split(body, bodylen, &ih) == -1)
+		return (-1);
+	return (locate_mime_part(depth + 1, body, ih, body + ih,
+	    bodylen - ih, path + 1, pathlen - 1, part_out, partlen_out));
 }
 
 /* RFC 9051 SS6.4.5 "<start.count>" applied to a range; past its end is empty */
@@ -1127,7 +1144,7 @@ extract_mime_part(int fd, const char *basename, const 
 	}
 
 	/* RFC 9051 SS6.4.5: a missing part is an empty item */
-	if (locate_mime_part(wholebuf, hdrend, wholebuf + hdrend,
+	if (locate_mime_part(0, wholebuf, hdrend, wholebuf + hdrend,
 	    wholelen - hdrend, path, pathlen, &part, &partlen) == 0) {
 		*off_out = (uint64_t)(part - wholebuf);
 		*len_out = (uint64_t)partlen;
blob - 108e7ceb1611d9fc797ea19eeae6596f60131974
blob + e861c44b599d3f715f3058d691d8bba81707ce65
--- src/store.c
+++ src/store.c
@@ -1236,19 +1236,16 @@ store_dispatch(int fd, short event, void *arg)
 			free(ranges);
 			break;
 		}
-		case IMSG_MBOX_IDLE_REFRESH: {
-			struct imsg_mbox_idle_refresh	 req;
-
-			if (imsg_get_data(&imsg, &req, sizeof(req)) == -1) {
+		case IMSG_MBOX_IDLE_REFRESH:
+			if (imsg_get_len(&imsg) != 0) {
 				log_warnx("bad IMSG_MBOX_IDLE_REFRESH");
 				break;
 			}
-			if (!require_mailbox_selected(ss,
-			    "IMSG_MBOX_IDLE_REFRESH"))
-				break;
-			handle_mbox_idle_refresh(&req, ss);
+			/* answered even if refused: the listener waits */
+			(void)require_mailbox_selected(ss,
+			    "IMSG_MBOX_IDLE_REFRESH");
+			handle_mbox_idle_refresh(ss);
 			break;
-		}
 		case IMSG_MBOX_APPEND: {
 			struct imsg_mbox_append	 req;
 
blob - bba0601daaaca5fdca6c7ef25b28c96d2e6f1a03
blob + 6ea1823a6c6737cef6a9d3176be27be5ab7ac21d
--- src/store_cmd.c
+++ src/store_cmd.c
@@ -56,7 +56,7 @@ cmd_idle(struct session *s, const char *tag, char *arg
 	session_write(s, "+ idling\r\n", 10);
 
 	if (s->state == SESSION_SELECTED) {
-		session_request_idle_refresh(s, 1);	/* seeds the baseline */
+		(void)session_request_idle_refresh(s);
 		session_idle_poll_arm(s);
 	}
 
blob - a37ae3887f2d08276ba8a3bbe156d22bf670f4e9
blob + 3c83389b2eb5b76d0b40c888d57458d13bb30233
--- src/store_internal.h
+++ src/store_internal.h
@@ -70,6 +70,8 @@ struct store_fetch_walk {
 	int			 fds;
 	int			 wait_parser;	/* paused until one comes */
 	int			 no_parser;	/* none to be had: go without */
+	int			 ghost;
+	uint64_t		 seen_modseq;
 };
 
 /* RFC 9051 SS6.4.4 SEARCH, yielding every SEARCH_YIELD_REQUESTS */
@@ -106,6 +108,7 @@ struct store_idle_baseline {
 	uint32_t	*uids;
 	size_t		 n;
 	uint64_t	 modseq;	/* highest reported; above it is news */
+	size_t		 ghosts;	/* expunged, not yet told */
 	int		 valid;
 };
 
@@ -207,6 +210,11 @@ int	 handle_mbox_fetch(struct imsg_mbox_fetch *,
 		    const struct seq_range *, uint32_t, struct store_session *);
 int	 handle_mbox_store(struct imsg_mbox_store *,
 		    const struct seq_range *, uint32_t, struct store_session *);
+struct store_step;
+int	 store_apply(struct store_session *,
+		    const struct imsg_mbox_store *, struct mbox_index *,
+		    const struct seq_range *, uint32_t, int, int,
+		    const uint64_t *, struct store_step **, size_t *);
 int	 handle_mbox_expunge(struct imsg_mbox_expunge *,
 		    const struct seq_range *, uint32_t, struct store_session *);
 void	 handle_mbox_append_begin(struct store_session *,
@@ -279,7 +287,7 @@ int	 envbuf_append_one_address(char *, size_t, size_t 
 int	 envbuf_append_address_list(char *, size_t, size_t *, char *,
 		    size_t);
 int	 append_field_nstring(char *, size_t, size_t *, const char *,
-		    uint32_t, const char *);
+		    size_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,
@@ -299,7 +307,7 @@ int	 build_bodystructure(int, const char *, char **, u
 int	 parse_section_part(const char *, int *, int);
 int	 find_mime_part(int, const char *, size_t, const char *,
 		    size_t, const int *, int, const char **, size_t *);
-int	 locate_mime_part(const char *, size_t, const char *, size_t,
+int	 locate_mime_part(int, const char *, size_t, const char *, size_t,
 		    const int *, int, const char **, size_t *);
 void	 partial_range(int, uint32_t, uint32_t, uint64_t *, uint64_t *);
 int	 extract_mime_part(int, const char *, const int *, int,
@@ -365,8 +373,16 @@ 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 store_session *);
+void	 handle_mbox_idle_refresh(struct store_session *);
+void	 idle_baseline_seed(struct store_session *, const struct mbox_index *);
+int	 view_sync(struct store_session *, const struct mbox_index *, int);
+void	 view_resolve(const struct store_session *, const struct seq_range *,
+		    uint32_t, struct seq_range *, uint32_t *, int *);
+uint32_t view_seqno(const struct store_session *, uint32_t, uint32_t);
+int	 view_names_ghost(const struct store_session *,
+		    const struct mbox_index *, const struct seq_range *,
+		    uint32_t);
+int	 send_expunged(struct store_session *, uint32_t, uint32_t);
 void	 qresync_send_resync(const struct imsg_mbox_select *,
 		    const struct seq_range *, uint32_t, struct mbox_index *,
 		    struct store_session *);
blob - 760d3f6ac69ae23a6461fef09899118f0e047e3d
blob + 9a98af9d87a28cfac11ea767354ca2ceeecbbafd
--- src/store_ipc.c
+++ src/store_ipc.c
@@ -311,28 +311,40 @@ session_store_dispatch(int fd, short event, void *arg)
 			session_handle_store_modified(s, &m);
 			break;
 		}
-		case IMSG_MBOX_IDLE_EXPUNGE: {
-			struct imsg_mbox_idle_expunge	 item;
+		case IMSG_MBOX_VIEW_EXPUNGE: {
+			struct imsg_mbox_expunged	 item;
 
 			if (imsg_get_data(&imsg, &item, sizeof(item)) == -1) {
-				log_warnx("bad IMSG_MBOX_IDLE_EXPUNGE");
+				log_warnx("bad IMSG_MBOX_VIEW_EXPUNGE");
 				break;
 			}
-			session_handle_idle_expunge(s, &item);
+			session_send_expunge_response(s, &item);
 			break;
 		}
-		case IMSG_MBOX_IDLE_FETCH: {
+		case IMSG_MBOX_VIEW_FETCH: {
 			struct imsg_mbox_fetch_meta	 meta;
 
 			if (imsg_get_data(&imsg, &meta, sizeof(meta)) == -1) {
-				log_warnx("bad IMSG_MBOX_IDLE_FETCH");
+				log_warnx("bad IMSG_MBOX_VIEW_FETCH");
 				break;
 			}
 			/* same risk as IMSG_MBOX_FETCH_META's flags above */
 			meta.flags[sizeof(meta.flags) - 1] = '\0';
-			session_handle_idle_fetch(s, &meta);
+			session_handle_view_fetch(s, &meta);
 			break;
 		}
+		case IMSG_MBOX_VIEW_EXISTS: {
+			struct imsg_mbox_view_exists	 ve;
+			char				 buf[32];
+
+			if (imsg_get_data(&imsg, &ve, sizeof(ve)) == -1) {
+				log_warnx("bad IMSG_MBOX_VIEW_EXISTS");
+				break;
+			}
+			snprintf(buf, sizeof(buf), "%u EXISTS", ve.exists);
+			session_untagged(s, buf);
+			break;
+		}
 		case IMSG_MBOX_IDLE_REFRESHED: {
 			struct imsg_mbox_idle_refreshed	 res;
 
@@ -778,31 +790,13 @@ session_handle_select_fetch(struct session *s,
 	s->qresync_fetches[s->qresync_nfetches++] = *m;
 }
 
-void
-session_handle_idle_expunge(struct session *s,
-    const struct imsg_mbox_idle_expunge *item)
-{
-	char	buf[32];
-
-	/* DONE may have arrived while the refresh was in flight. */
-	if (!s->idling || s->state != SESSION_SELECTED)
-		return;
-
-	snprintf(buf, sizeof(buf), "%u EXPUNGE", item->seqno);
-	session_untagged(s, buf);
-}
-
 /* RFC 9051 SS6.3.13: an unsolicited FETCH carries UID */
 void
-session_handle_idle_fetch(struct session *s,
+session_handle_view_fetch(struct session *s,
     const struct imsg_mbox_fetch_meta *meta)
 {
 	char	buf[MBOX_FLAGS_MAX + 96];
 
-	/* DONE may have arrived while the refresh was in flight. */
-	if (!s->idling || s->state != SESSION_SELECTED)
-		return;
-
 	if (s->condstore_enabled)
 		snprintf(buf, sizeof(buf),
 		    "%u FETCH (UID %u FLAGS (%s) MODSEQ (%llu))", meta->seqno,
@@ -817,8 +811,6 @@ void
 session_handle_idle_refreshed(struct session *s,
     const struct imsg_mbox_idle_refreshed *res)
 {
-	char	buf[32];
-
 	s->idle_refresh_pending = 0;
 
 	if (!res->ok && res->busy)
@@ -827,42 +819,35 @@ session_handle_idle_refreshed(struct session *s,
 	else if (!res->ok)
 		log_warnx("session %u: IDLE refresh failed, keeping last "
 		    "known state", s->id);
-	else if (res->exists_changed && s->idling &&
-	    s->state == SESSION_SELECTED) {
-		snprintf(buf, sizeof(buf), "%u EXISTS", res->exists);
-		session_untagged(s, buf);
-	}
 
-	if (s->idle_refresh_again) {
-		int	seed = s->idle_refresh_again_seed;
-
+	if (s->refresh_status != NULL) {
+		session_reply(s, s->pending_tag, s->refresh_status,
+		    s->refresh_text);
+		s->refresh_status = NULL;
+	} else if (s->idle_refresh_again && s->idling) {
 		s->idle_refresh_again = 0;
-		s->idle_refresh_again_seed = 0;
-		session_request_idle_refresh(s, seed);
+		(void)session_request_idle_refresh(s);
 	}
 }
 
-void
-session_request_idle_refresh(struct session *s, int seed)
+int
+session_request_idle_refresh(struct session *s)
 {
-	struct imsg_mbox_idle_refresh	 req;
-
 	if (s->idle_refresh_pending) {
 		s->idle_refresh_again = 1;
-		if (seed)
-			s->idle_refresh_again_seed = 1;
-		return;
+		return (0);
 	}
 	if (s->store_iev == NULL)
-		return;		/* no store child wired, nothing to ask */
+		return (-1);
 
-	memset(&req, 0, sizeof(req));
-	req.seed = seed;
-	s->idle_refresh_pending = 1;
 	if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_IDLE_REFRESH, 0, 0,
-	    -1, &req, sizeof(req)) == -1)
+	    -1, NULL, 0) == -1) {
 		log_warn("session %u: imsg_compose IMSG_MBOX_IDLE_REFRESH",
 		    s->id);
+		return (-1);
+	}
+	s->idle_refresh_pending = 1;
+	return (0);
 }
 
 static void
@@ -880,7 +865,7 @@ session_idle_poll(int fd, short event, void *arg)
 		return;
 	}
 
-	session_request_idle_refresh(s, 0);
+	(void)session_request_idle_refresh(s);
 	session_idle_poll_arm(s);	/* an evtimer is one-shot */
 }
 
@@ -974,7 +959,7 @@ session_handle_mbox_result(struct session *s, struct i
 	    SESSION_AUTHENTICATED : SESSION_SELECTED;
 
 	if (res->error != MBOX_OP_OK) {
-		char	text[32];
+		char	text[64];
 
 		free(s->store_modified);
 		s->store_modified = NULL;
@@ -987,7 +972,9 @@ session_handle_mbox_result(struct session *s, struct i
 			    IMAP_BUSY_TEXT);
 			return;
 		}
-		snprintf(text, sizeof(text), "%s failed", cmdname);
+		snprintf(text, sizeof(text), "%s%s failed",
+		    res->error == MBOX_OP_ERR_EXPUNGEISSUED ?
+		    "[EXPUNGEISSUED] " : "", cmdname);
 		session_reply(s, s->pending_tag, "NO", text);
 		return;
 	}