Commit Diff


commit - be72be56ebed8f5a27d7258f8e2c6b5ba106083e
commit + 2bd90b642f5b0f2eee6ef497269e1e983b8966c5
blob - c59aa80793e00780ac0a04bc9ce5f96d57d19f3a
blob + 56d10fc49bb84b6df9a0a994afdcfd4b8338a295
--- README.md
+++ README.md
@@ -2,7 +2,7 @@
 
 A from-scratch IMAP4rev2 ([RFC 9051](https://www.rfc-editor.org/rfc/rfc9051)) server for OpenBSD, written in C in the privilege-separated tradition of `smtpd(8)`, `httpd(8)`, and `ntpd(8)`. No third-party IMAP library.
 
-**Status:** 0.1.9 Pre-release, actively developed. Not a port. See [Getting the source](#getting-the-source) below for the repository.
+**Status:** 0.2.0 Pre-release, actively developed. Not a port. See [Getting the source](#getting-the-source) below for the repository.
 
 ## What it is
 
@@ -12,7 +12,7 @@ A from-scratch IMAP4rev2 ([RFC 9051](https://www.rfc-e
 
 ## 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`, `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.
+`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` (including `RETURN (SAVE)` and the `$` saved-result-set marker, [RFC 5182](https://www.rfc-editor.org/rfc/rfc5182), folded into RFC 9051), `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.
 
 ACL/shared-mailbox support is deliberately left out.
 
@@ -34,7 +34,7 @@ make
 doas make install
 ```
 
-Installs the daemon to `/usr/local/sbin/imapd`, man pages to `/usr/local/man/man8`, the `imapduser` account-provisioning tool alongside the daemon, and a sample config to `/usr/local/share/examples/imapd/imapd.conf`.
+Installs the daemon to `/usr/local/sbin/imapd`, man pages to `/usr/local/man/man5` and `/usr/local/man/man8`, the `imapduser` account-provisioning tool alongside the daemon, and a sample config to `/usr/local/share/examples/imapd/imapd.conf`.
 
 The `rc.d(8)` script is not installed automatically. `install(1)`, not `cp(1)`, so the installed copy is executable regardless of the source tree's own permission bits:
 
@@ -51,7 +51,7 @@ doas install -o root -g wheel -m 600 \
     /usr/local/share/examples/imapd/imapd.conf /etc/imapd.conf
 ```
 
-Every directive is documented inline in the sample file; the full reference is in `imapd(8)`'s FILES section.
+Every directive is documented inline in the sample file; the full reference is `imapd.conf(5)`.
 
 ## Creating the daemon accounts
 
@@ -86,11 +86,11 @@ doas rcctl start imapd
 
 Beyond the deliberate protocol-scope decisions covered in `imapd(8)`'s CAVEATS:
 
-- If the listener or auth process exits unexpectedly after startup, it is not automatically restarted. Recovery is `rcctl restart imapd`. See `imapd(8)`.
+- If the keymgr process, which holds the TLS private key, exits unexpectedly, it is not restarted and new TLS handshakes fail. Recovery is `rcctl restart imapd`. 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)`.
+`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.conf(5)`.
 
-IPv6 is supported (`listen on ::` or `listen on *` for dual-stack) but not the default, see `imapd(8)`'s `listen on` directive.
+IPv6 is supported (`listen on ::` or `listen on *` for dual-stack) but not the default, see the `listen on` directive in `imapd.conf(5)`.
 
 ## Getting the source
 
@@ -116,4 +116,4 @@ ISC. See the copyright header in each source file.
 
 ## More
 
-`imapd(8)` and `imapduser(8)` are the authoritative technical reference.
+`imapd(8)`, `imapd.conf(5)` and `imapduser(8)` are the authoritative technical reference.
blob - 38f2e42d3962909ce6815417a407d690a860f0f2
blob + a30d9db8820ab2924812e137fded1dd535a0e58e
--- contrib/imapd-teardown
+++ contrib/imapd-teardown
@@ -30,6 +30,7 @@
 set -e
 
 BINDIR=/usr/local/sbin
+MAN5DIR=/usr/local/man/man5
 MAN8DIR=/usr/local/man/man8
 EXAMPLEDIR=/usr/local/share/examples/imapd
 RELINKDIR=/usr/share/relink/usr/local/sbin/imapd
@@ -114,6 +115,7 @@ list_targets() {
 	do_rm "installed daemon binary" "$BINDIR/imapd" f
 	do_rm "installed imapduser tool" "$BINDIR/imapduser" f
 	do_rm "imapd.8 man page" "$MAN8DIR/imapd.8" f
+	do_rm "imapd.conf.5 man page" "$MAN5DIR/imapd.conf.5" f
 	do_rm "imapduser.8 man page" "$MAN8DIR/imapduser.8" f
 	do_rm "installed sample config directory" "$EXAMPLEDIR" r
 	do_rm "rc.d script" "/etc/rc.d/imapd" f
blob - 291af09dff0964c50e04f656d1205a7a788e523e
blob + cba2e724253c971c0d0706a3c356dd098acd931c
--- contrib/imapduser.8
+++ contrib/imapduser.8
@@ -2,7 +2,7 @@
 .\"
 .\" Written for the OpenIMAPD project. Public domain / no rights reserved.
 .\"
-.Dd $Mdocdate: September 29 2026 $
+.Dd $Mdocdate: September 30 2026 $
 .Dt IMAPDUSER 8
 .Os
 .Sh NAME
@@ -35,7 +35,7 @@ end users are not real system accounts:
 .Xr imapd 8 Ns 's
 .Em auth
 child reads
-.Sy username : passwordhash : uid : gid : maildir
+.Ql username:passwordhash:uid:gid:maildir
 lines directly out of a credentials file rather than calling
 .Xr getpwnam 3 ,
 and its
@@ -62,11 +62,19 @@ The options are as follows:
 .Bl -tag -width Ds
 .It Fl a
 Add
-.Ar username .
-Creates its maildir under
+.Ar username ,
+which may contain only letters, digits,
+.Sq \&. ,
+.Sq _
+and
+.Sq - .
+Creates its maildir, named
+.Ar username ,
+under
 .Ar spool-root ,
 owned by
 .Ar uid : Ns Ar gid ,
+mode 0700,
 and appends a line to the credentials file, prompting for a password
 via
 .Xr encrypt 1 .
@@ -74,9 +82,11 @@ If
 .Fl u
 and
 .Fl g
-are both omitted, the same free id is chosen automatically and used
-for both.
-Fails without effect if
+are both omitted, the lowest id from 2000 up that is not used in
+.Pa /etc/passwd ,
+.Pa /etc/group
+or the credentials file is chosen and used for both.
+Fails without adding anything if
 .Ar username
 already has a credentials-file line, or if its maildir already exists.
 .It Fl d
@@ -92,11 +102,12 @@ and
 does not conflate them.
 The maildir's path is printed on success so it can be removed by hand
 if that is actually what is wanted.
-.Fl s ,
-.Fl u ,
+.Fl u
 and
 .Fl g
-are ignored in this mode.
+are ignored in this mode, and
+.Fl s
+only changes the path printed.
 Fails without effect if
 .Ar username
 has no credentials-file line.
@@ -124,6 +135,9 @@ User and group id to own
 maildir and to record in its credentials-file line, in
 .Fl a
 mode.
+If only one is given, the other takes the same value.
+.Xr imapd 8
+ignores an entry whose uid or gid is 0.
 .El
 .Pp
 Must be run as root: adding an account
@@ -132,9 +146,10 @@ a maildir to an arbitrary
 .Ar uid : Ns Ar gid ,
 and both modes write to a file that must stay root-owned.
 .Pp
-Every write to the credentials file, whether creating it for the
-first account or rewriting it after a deletion, resets its ownership
-and mode to
+When
+.Nm
+creates the credentials file, or rewrites it after a deletion, it sets
+its ownership and mode to
 .Sy root : Ns Sy _imapauth ,
 mode 0640: the
 .Em auth
@@ -148,11 +163,9 @@ group does not exist yet,
 .Nm
 leaves the file group-owned by
 .Sy wheel
-and prints a warning rather than failing silently; re-run
-.Nm ,
-or run
+and prints a warning rather than failing silently; run
 .Xr chgrp 1
-by hand, once that account is provisioned.
+by hand once that account is provisioned.
 .Sh FILES
 .Bl -tag -width "/etc/imapd/credentialsXXX" -compact
 .It Pa /etc/imapd/credentials
@@ -167,6 +180,7 @@ Default spool root; see
 .Sh SEE ALSO
 .Xr encrypt 1 ,
 .Xr crypt_checkpass 3 ,
+.Xr imapd.conf 5 ,
 .Xr imapd 8
 .Sh HISTORY
 .Nm
blob - 4bf7b96ec5aa1ffb1dbf3308e5cf4a0445e3b7a0
blob + 28d70578cab1d0af11363ec0d4b50101db9f8375
--- src/Makefile
+++ src/Makefile
@@ -37,7 +37,7 @@ DPADD+=		${LIBEVENT}
 LDADD+=		-ltls -lssl -lcrypto
 DPADD+=		${LIBTLS} ${LIBSSL} ${LIBCRYPTO}
 
-MAN=		imapd.8
+MAN=		imapd.8 imapd.conf.5
 
 CFLAGS+=	-Wall -Wextra -Wstrict-prototypes -Wmissing-prototypes
 CFLAGS+=	-Wmissing-declarations -Wshadow -Wpointer-arith
blob - a9edf8444f61eb110fa9d62bde920efa5aa13ed5
blob + c89f711d6103856cce71d763e5eb1631cb7d9d37
--- src/auth.c
+++ src/auth.c
@@ -46,7 +46,8 @@ static struct imsgev	 iev_parent;
 static char		 cred_file_basename[256];
 
 static int	 cred_lookup(const char *, const char *username,
-		    struct cred_entry *);
+		    struct cred_entry *, int *);
+static int	 cred_cost(const char *);
 static void	 auth_verify(struct imsg_auth_request *,
 		    struct imsg_auth_result *);
 static void	 auth_dispatch(int, short, void *);
@@ -332,13 +333,14 @@ auth_safe_name(const char *in, char *out, size_t outsi
 	out[i] = '\0';
 }
 
-/* unknown users still pay for crypt_checkpass(), against timing */
+/* an unknown user pays one hash at the file's highest cost, like a real one */
 static void
 auth_verify(struct imsg_auth_request *req, struct imsg_auth_result *res)
 {
 	struct cred_entry	 ce;
 	const char		*hash = NULL;
-	int			 found;
+	char			 dummy[_PASSWORD_LEN];
+	int			 found, maxcost;
 	char			 safename[AUTH_USERNAME_MAX];
 
 	if (auth_failures >= AUTH_MAX_TRIES) {
@@ -352,9 +354,16 @@ auth_verify(struct imsg_auth_request *req, struct imsg
 		return;
 	}
 
-	found = (cred_lookup(cred_file_basename, req->username, &ce) == 0);
+	found = (cred_lookup(cred_file_basename, req->username, &ce,
+	    &maxcost) == 0);
 	if (found)
 		hash = ce.passwordhash;
+	else if (maxcost > 0) {
+		(void)snprintf(dummy, 8, "$2b$%02d$", maxcost);
+		memset(dummy + 7, '.', 53);
+		dummy[60] = '\0';
+		hash = dummy;
+	}
 
 	if (crypt_checkpass(req->password, hash) == 0 && found &&
 	    strlcpy(res->maildir, ce.maildir, sizeof(res->maildir)) <
@@ -391,12 +400,27 @@ cred_file_secure(const struct stat *st)
 }
 
 static int
-cred_lookup(const char *path, const char *username, struct cred_entry *out)
+cred_cost(const char *h)
 {
-	FILE	*fp;
-	char	 line[1024];
-	int	 found = 0;
+	int	 cost;
 
+	if (h[2] == '\0' || h[3] != '$' || !isdigit((unsigned char)h[4]) ||
+	    !isdigit((unsigned char)h[5]) || h[6] != '$')
+		return (0);
+	cost = (h[4] - '0') * 10 + (h[5] - '0');
+	return (cost >= 4 && cost <= 31 ? cost : 0);
+}
+
+static int
+cred_lookup(const char *path, const char *username, struct cred_entry *out,
+    int *maxcost)
+{
+	FILE			*fp;
+	char			 line[1024];
+	struct cred_entry	 ce;
+	int			 found = 0, cost;
+
+	*maxcost = 0;
 	if ((fp = fopen(path, "r")) == NULL) {
 		log_warn("fopen %s", path);
 		return (-1);
@@ -436,6 +460,7 @@ cred_lookup(const char *path, const char *username, st
 			log_warnx("%s: over-long line, refusing to parse the "
 			    "credential file", path);
 			explicit_bzero(line, sizeof(line));
+			explicit_bzero(&ce, sizeof(ce));
 			fclose(fp);
 			return (-1);
 		}
@@ -454,14 +479,11 @@ cred_lookup(const char *path, const char *username, st
 		if (i != 5)
 			continue;	/* malformed line, skip */
 
-		if (strcmp(fields[0], username) != 0)
+		if (strlcpy(ce.username, fields[0], sizeof(ce.username))
+		    >= sizeof(ce.username) ||
+		    strlcpy(ce.passwordhash, fields[1],
+		    sizeof(ce.passwordhash)) >= sizeof(ce.passwordhash))
 			continue;
-
-		if (strlcpy(out->username, fields[0], sizeof(out->username))
-		    >= sizeof(out->username) ||
-		    strlcpy(out->passwordhash, fields[1],
-		    sizeof(out->passwordhash)) >= sizeof(out->passwordhash))
-			continue;
 		/* crypt_checkpass(3) passes an empty hash and password */
 		if (fields[1][0] != '$' || fields[1][1] != '2')
 			continue;
@@ -474,22 +496,28 @@ cred_lookup(const char *path, const char *username, st
 		if (*ep != '\0' || errno != 0 || ulval == 0 ||
 		    ulval >= (unsigned long)(uid_t)-1)
 			continue;
-		out->uid = (uid_t)ulval;
+		ce.uid = (uid_t)ulval;
 		errno = 0;
 		ulval = strtoul(fields[3], &ep, 10);
 		if (*ep != '\0' || errno != 0 || ulval == 0 ||
 		    ulval >= (unsigned long)(gid_t)-1)
 			continue;
-		out->gid = (gid_t)ulval;
-		if (strlcpy(out->maildir, fields[4], sizeof(out->maildir)) >=
-		    sizeof(out->maildir))
+		ce.gid = (gid_t)ulval;
+		if (strlcpy(ce.maildir, fields[4], sizeof(ce.maildir)) >=
+		    sizeof(ce.maildir))
 			continue;
-		found = 1;
-		break;
+		if ((cost = cred_cost(ce.passwordhash)) > *maxcost)
+			*maxcost = cost;
+		/* the whole file is read, found or not */
+		if (!found && strcmp(ce.username, username) == 0) {
+			*out = ce;
+			found = 1;
+		}
 	}
 
-	/* line[] held credential records */
+	/* line[] and ce held credential records */
 	explicit_bzero(line, sizeof(line));
+	explicit_bzero(&ce, sizeof(ce));
 	fclose(fp);
 	return (found ? 0 : -1);
 }
blob - d2ffc23c6531d1c39bc449b58c991c54b69674d8
blob + e04d21fe95064d78b9b7c40c63a0569221f683f7
--- src/fetch_cmd.c
+++ src/fetch_cmd.c
@@ -134,6 +134,10 @@ parse_sequence_set(const char *text, struct seq_range 
 		memcpy(tok, start, len);
 		tok[len] = '\0';
 
+		if (strcmp(tok, "$") == 0 && *p == '\0') {
+			memset(&ranges[n++], 0, sizeof(ranges[0]));
+			break;
+		}
 		if (parse_one_seq_range(tok, &ranges[n]) == -1) {
 			*errmsg = "invalid sequence set";
 			return (-1);
@@ -820,43 +824,6 @@ session_send_store_fetch_response(struct session *s,
 	session_untagged(s, buf);
 }
 
-/* RFC 4466 modifiers */
-char *
-split_trailing_modifiers(char *spec)
-{
-	char	*p = spec;
-
-	if (*p == '(') {
-		int	depth = 0;
-
-		for (;;) {
-			if (*p == '(')
-				depth++;
-			else if (*p == ')') {
-				depth--;
-				if (depth == 0) {
-					p++;
-					break;
-				}
-			} else if (*p == '\0')
-				return (NULL);
-			p++;
-		}
-	} else {
-		while (*p != '\0' && *p != ' ')
-			p++;
-	}
-
-	if (*p == '\0')
-		return (NULL);
-	*p++ = '\0';
-	while (*p == ' ')
-		p++;
-	if (*p == '\0')
-		return (NULL);
-	return (p);
-}
-
 /* RFC 4466, RFC 7162 SS3.1.4.1 and SS3.2.6 modifiers */
 int
 parse_fetch_modifiers(char *modspec, struct imsg_mbox_fetch *req,
@@ -1021,7 +988,11 @@ fetch_dispatch(struct session *s, const char *tag, cha
 		return (session_arg_error(s, tag, errmsg));
 	attspec = unlit;
 
-	modspec = split_trailing_modifiers(attspec);
+	fetch_att_tok(attspec, &modspec);
+	while (*modspec == ' ')
+		modspec++;
+	if (*modspec == '\0')
+		modspec = NULL;
 
 	rc = parse_fetch_atts(attspec, &attrs, &degraded, &header_fields_not,
 	    header_fields, sizeof(header_fields), header_fields_label,
blob - 53289376fe16e21ab8f9917fe0f74f8ccbb29f0b
blob + 8bd071198b174dfe4b1d2aaa8c6426cdfbae4646
--- 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 29 2026 $
+.Dd $Mdocdate: September 30 2026 $
 .Dt IMAPD 8
 .Os
 .Sh NAME
@@ -18,62 +18,34 @@
 .Nm
 is an Internet Message Access Protocol
 .Pq IMAP
-daemon implementing the subset of RFC 9051
-.Pq IMAP4rev2
-described below.
-It is privilege-separated in the style of
-.Xr smtpd 8 :
-a root
-.Em parent
-process reads configuration, binds the listening sockets, and
-re-executes unprivileged
-.Em listener
-and
-.Em auth
-children over
-.Xr imsg_init 3
-control channels.
-One
-.Em store
-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
-meant for direct operator use.
+daemon implementing a subset of IMAP4rev2, as defined by RFC 9051,
+and the RFC 7162 CONDSTORE and QRESYNC extensions.
+It serves mail stored in maildir format.
 .Pp
+By default,
 .Nm
-listens on port 143
-.Pq cleartext, upgradable via STARTTLS
-and port 993
-.Pq implicit TLS, per RFC 8314 ,
-both via
-.Xr tls_init 3 .
-Those are the defaults, used when
-.Pa /etc/imapd.conf
-contains no
-.Ic listen
-directive at all.
-A file that contains any
-.Ic listen
-directive selects listeners as well as configuring them: only the
-listeners it names are bound.
+listens on port 143, where clients may upgrade to TLS with
+.Li STARTTLS ,
+and on port 993 with implicit TLS, as recommended by RFC 8314.
 Authentication is
 .Li AUTH=PLAIN
-only, and is refused before TLS is established;
-.Li LOGINDISABLED
-is advertised on the cleartext, pre-TLS connection.
+only, and is refused until TLS is established;
+.Li LOGIN
+is always refused.
+The listening addresses and ports are set in
+.Xr imapd.conf 5 .
 .Pp
-The current implementation is intentionally minimal: each user's
-mailboxes form a single flat namespace
-.Pq no nested hierarchy , Li INBOX
-plus zero or more sibling mailboxes created via
-.Li CREATE ,
-and no shared or multi-user mailboxes
-.Pq no Li ACL support .
-Within that scope it implements
+Each user's mailboxes form a single flat namespace:
+.Li INBOX
+and zero or more sibling mailboxes, with no hierarchy and no shared
+mailboxes.
+.Li INBOX
+is the account's maildir itself, and each other mailbox is a maildir
+in a subdirectory of it with the mailbox's name.
+The commands implemented are
 .Li CAPABILITY ,
+.Li NOOP ,
+.Li LOGOUT ,
 .Li STARTTLS ,
 .Li AUTHENTICATE ,
 .Li ID ,
@@ -98,729 +70,172 @@ Within that scope it implements
 .Li EXPUNGE ,
 .Li UNSELECT ,
 .Li CLOSE ,
-.Li UID ,
-.Li IDLE ,
-and the RFC 7162 CONDSTORE and QRESYNC extensions
-.Pq mod-sequence tracking, conditional STORE, VANISHED responses .
-.Li LIST
-accepts the
-.Li SUBSCRIBED
-selection option of RFC 9051 Section 6.3.9.1.
-.Li LSUB ,
-which RFC 9051 deprecates in favour of that option, is retained and
-reports the same set of names.
-An account that has never issued
-.Li UNSUBSCRIBE
-has every mailbox subscribed; see
-.Pa imapd.subscriptions
-under FILES.
+.Li UID
+and
+.Li IDLE .
 .Pp
 .Nm
-logs to
+is privilege separated.
+A root parent process reads the configuration, binds the listening
+sockets and accepts connections.
+Each connection is handled by unprivileged processes that are
+.Xr chroot 2 Ns ed
+and restricted with
+.Xr pledge 2 .
+The TLS private key is held by a separate process and never enters
+the process that speaks to the network.
+Mailbox access for each logged-in account is performed by one process,
+shared by all of that account's sessions, which is
+.Xr chroot 2 Ns ed
+into the mail spool and runs as the account's own user.
+.Nm
+requires the
+.Sy _imapd ,
+.Sy _imapauth
+and
+.Sy _imapkey
+users to exist.
+.Pp
+.Nm
+does not detach from its controlling terminal and is expected to be
+started by
+.Xr rc.d 8 .
+It logs to
 .Xr syslogd 8
 with the
 .Dv LOG_MAIL
-facility, the same one
-.Xr smtpd 8
-uses, so its messages go wherever
-.Xr syslog.conf 5
-directs the mail facility.
-Releases before 0.1.5 used
-.Dv LOG_DAEMON .
+facility.
 .Pp
+.Nm
+rereads its configuration file when it receives
+.Dv SIGHUP ;
+see
+.Xr imapd.conf 5
+for the settings that require a restart instead.
+.Pp
 The options are as follows:
 .Bl -tag -width Ds
+.It Fl D Ar macro Ns = Ns Ar value
+Define
+.Ar macro
+to be set to
+.Ar value
+on the command line.
+Overrides the definition of
+.Ar macro
+in the configuration file.
 .It Fl d
 Log to
 .Em stderr
 instead of
 .Xr syslogd 8 .
-.Nm
-does not detach from its controlling terminal or otherwise
-background itself in either mode; an
-.Xr rc.d 8
-script or equivalent supervisor is expected to do so.
-An
-.Xr rc.d 8
-script is provided in
-.Pa rc.d/imapd
-in the source tree; it is not installed automatically by
-.Ic make install
-and must be installed to
-.Pa /etc/rc.d/imapd
-by hand, owned by
-.Sy root : Ns Sy wheel ,
-mode 555
-.Pq matching every base rc.d 8 script's own installed permissions :
-.Bd -literal -offset indent
-doas install -o root -g wheel -m 555 rc.d/imapd /etc/rc.d/imapd
-.Ed
-.Pp
-Using
-.Xr install 1
-rather than
-.Xr cp 1
-matters here: a plain
-.Xr cp 1
-preserves the source file's own permission bits, so a source tree
-where
-.Pa rc.d/imapd
-happens to be non-executable produces a non-executable, and therefore
-invisible to
-.Xr rcctl 8 ,
-copy at
-.Pa /etc/rc.d/imapd
---
-.Xr rcctl 8
-considers a service to not exist at all unless its script is
-executable.
-.Xr install 1
-sets the destination's mode explicitly instead, independent of
-whatever the source happened to be.
-It also applies any pending boot-time relink
-(see
-.Fl V
-below)
-before starting the daemon.
-.It Fl D Ar macro Ns = Ns Ar value
-Define
-.Ar macro
-to
-.Ar value ,
-overriding any definition of the same macro inside the configuration
-file, for use with
-.Ic $ Ns Ar macro
-references there.
 .It Fl f Ar file
-Specify an alternative configuration file.
-Defaults to
+Use
+.Ar file
+as the configuration file, instead of the default
 .Pa /etc/imapd.conf .
 .It Fl V
 Print the version and exit.
-Performs no other action
-.Pq no configuration file is read, no privileged setup occurs ;
-this is also the command
-.Xr make 1 Ns 's
-.Ic RELINK
-step in the Makefile uses to smoke-test a relinked binary before
-installing it, and what
-.Pa rc.d/imapd Ns 's
-boot-time relink hook relies on to confirm a relink succeeded.
 .It Fl v
 Produce more verbose logging.
 .El
-.Pp
-Sending
+.Sh AUTHENTICATION
+Users of
 .Nm
-.Dv SIGHUP
-rereads
-.Pa /etc/imapd.conf
-.Pq or the file given via Fl f
-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 ,
-.Ic startups ,
-.Ic tls certificate ,
+are not system accounts.
+They are listed in a credentials file, by default
+.Pa /etc/imapd/credentials ,
+one per line in the form:
+.Bd -literal -offset indent
+username:passwordhash:uid:gid:maildir
+.Ed
+.Pp
+The password hash is a bcrypt hash as accepted by
+.Xr crypt_checkpass 3 .
+.Ar uid
 and
-.Ic tls key
-directives without disrupting sessions already connected, matching
-.Xr httpd 8 Ns 's
-own documented SIGHUP behavior.
-.Ic listen on
-and
-.Ic credentials
-cannot be changed this way: the listening sockets are already bound and
-the
-.Em auth
-child is already
-.Xr chroot 2 Ns d
-to the original credentials path by the time SIGHUP arrives, so a change
-to either is logged as a warning and otherwise ignored until
-.Nm
-is restarted.
-A malformed configuration file logs a warning and leaves the running
-daemon on its current configuration rather than exiting.
+.Ar gid
+are the user and group the account's mailbox process runs as, and
+.Ar maildir
+is the account's maildir, relative to the spool set in
+.Xr imapd.conf 5 .
+Entries that share all three name the same account.
 .Pp
-If the
-.Em listener
+Empty lines and lines beginning with
+.Sq #
+are ignored, and malformed lines are skipped.
+A line is also skipped if its hash is not a bcrypt hash or its
+.Ar uid
 or
-.Em auth
-process exits unexpectedly after startup, it is not automatically
-restarted, matching
-.Xr smtpd 8 Ns 's
-own treatment of its equivalent core processes and
-.Xr httpd 8 Ns 's
-even more hands-off one.
-The
-.Em listener Ns 's
-death makes
-.Nm
-unreachable entirely, since it owns every listening socket;
-.Em auth Ns 's
-death leaves already-authenticated sessions unaffected but new
-.Ic AUTHENTICATE
-attempts will fail.
-Either is logged as a warning; recovery is
-.Ic rcctl restart imapd .
+.Ar gid
+is 0.
+The first valid line naming a user is the one used.
+A login is refused if the
+.Ar maildir
+is empty, is an absolute path, or has a
+.Pa \&.
+or
+.Pa ..
+component.
+A line of 1023 or more characters causes every login to be refused.
+.Xr imapduser 8
+adds and removes entries.
+.Pp
+The file is read by a process running as
+.Sy _imapauth ,
+so it must be readable by that user.
+It must be owned by root or
+.Sy _imapauth
+and must not be group writable or executable, or accessible by
+others; otherwise every login is refused.
+.Xr imapduser 8
+creates it owned by root, group
+.Sy _imapauth ,
+mode 0640.
+A failed login for a name with no entry costs one hash at the highest
+cost in the file, so an entry hashed at a lower cost can be told from a
+missing one by timing; rehash such entries at the file's cost.
 .Sh FILES
-.Bl -tag -width "/etc/imapd/credentialsXXX" -compact
+.Bl -tag -width "/etc/ssl/private/imapd.keyXX" -compact
 .It Pa /etc/imapd.conf
-Default
-.Nm
-configuration file, read by the parent process only.
-Must be owned by root or the current user, and not group- or
-world-writable.
-Recognized directives, one per line:
-.Bl -tag -width Ds -compact
-.It Ic listen on Ar address Op Ic tls Ic port Ar port
-Bind a listener to
-.Ar address .
-Specify once without
-.Ic tls
-for the cleartext/STARTTLS listener (default port 143) and once with
-.Ic tls
-for the implicit-TLS listener (default port 993).
-Both listeners share one
-.Ar address ;
-a second
-.Ic listen
-line naming a different address is a configuration error.
-.Pp
-A
-.Ic listen
-directive also selects which listeners run.
-If the file contains no
-.Ic listen
-directive, both listeners are bound on their default ports.
-If it contains any, only the listeners it names are bound: a file whose
-only
-.Ic listen
-directive is
-.Pp
-.Dl listen on * tls port 993
-.Pp
-serves implicit TLS alone, with nothing on port 143, which is the
-deployment RFC 8314 Section 3 asks for.
-.Pp
-.Ar address
-must be a literal IPv4 address, a literal IPv6 address,
-.Ql ::
-(all IPv6 interfaces),
-.Ql 0.0.0.0
-(all IPv4 interfaces), or
-.Ql *
-(all IPv4 and IPv6 interfaces, matching
-.Xr httpd.conf 5 Ns 's
-own convention for the same character).
-Hostnames are not accepted: resolving one would add a DNS dependency to
-.Nm Ns 's
-boot path for no benefit a single-operator personal mail server actually
-needs.
-Since
-.Ox Ns 's
-IPv6 sockets are always IPv6-only
-.Pq no v4-mapped-address dual binding , unlike Linux ,
-.Ql *
-binds two sockets per listener, one
-.Dv AF_INET
-and one
-.Dv AF_INET6 ,
-rather than one dual-stack socket.
-.It Ic spool Ar path
-Mail spool root, chrooted into by the
-.Em store
-child.
-Defaults to
-.Pa /var/mail/imapd .
-.Ar path
-and everything directly under it
-.Pq one entry per mail user's maildir
-must be owned by
-.Sy root
-and not writable by mail users.
-Each user's maildir path under
-.Ar path
-is validated lexically only; if it names a symlink, the
-.Em store
-child's
-.Xr chroot 2
-follows it.
-That is contained:
-.Xr chroot 2
-resolves the target inside itself, and
-.Xr unveil 2
-in the
-.Em store
-child narrows the view further.
-A mail user able to create a symlink at their own maildir path would
-nonetheless choose that child's starting directory within the spool.
-The ownership requirement above is therefore a deployment assumption
-.Nm
-does not enforce in software.
-.It Ic credentials Ar path
-Credentials file (see below).
-Defaults to
-.Pa /etc/imapd/credentials .
-.It Ic tls certificate Ar path
-TLS certificate file.
-Defaults to
-.Pa /etc/ssl/imapd.crt .
-.It Ic tls key Ar path
-TLS private key file.
-Defaults to
-.Pa /etc/ssl/private/imapd.key .
-Must be owned by root, mode 0740 or stricter.
-.It Ic idle poll Ar seconds
-How often a session in
-.Li IDLE
-rechecks its selected mailbox.
-.Nm
-does not receive an event when mail arrives, so
-.Li IDLE
-is served by polling: every
-.Ar seconds ,
-the session asks its
-.Em store
-child whether anything has changed, and reports new messages and
-expunges if so.
-New mail is therefore announced within one interval rather than
-instantly, whether it was delivered by an external
-.Xr smtpd 8
-or by another IMAP session.
-.Pp
-Polling is cheap by design.
-The
-.Em store
-child first compares the modification times of the mailbox directory
-and its
-.Pa new
-subdirectory against the previous look; if neither has moved it answers
-immediately, taking no lock and reading nothing.
-Only a mailbox that has actually been touched costs an index read, and
-only one holding an unindexed delivery costs an exclusive lock.
-.Pp
-.Ar seconds
-must be between 1 and 300 inclusive, or 0 to disable polling
-altogether.
-With polling disabled a session in
-.Li IDLE
-is told nothing until it sends
-.Li DONE ,
-which is rarely what an operator wants.
-Defaults to 5.
-.It Ic startups begin Ar count Ic rate Ar percent Ic full Ar count
-Admission control on concurrent connections that have not yet
-authenticated, using
-.Xr sshd_config 5 Ns 's
-MaxStartups algorithm; see that manual for the canonical description.
-.Pp
-Below
-.Ic begin
-unauthenticated connections,
-.Nm
-accepts every new connection.
-Between
-.Ic begin
-and
-.Ic full
-it refuses new connections with a probability rising linearly from
-.Ic rate
-percent at
-.Ic begin
-to 100 percent at
-.Ic full .
-At or above
-.Ic full
-every new connection is refused.
-A connection stops counting the moment it authenticates, so the limit
-bounds unfinished logins rather than established sessions.
-A refused connection is answered as one past
-.Ic connections max
-is.
-.Pp
-Both counts must be between 0 and 1000000 inclusive,
-.Ar percent
-between 0 and 100 inclusive, and
-.Ic full
-must not be smaller than
-.Ic begin ;
-anything else is a configuration error.
-Setting
-.Ic full
-to 0 disables the throttle entirely and accepts unconditionally,
-which is the only way to turn it off:
-.Ic begin
-and
-.Ic rate
-have no such special value.
-Defaults to
-.Ic begin
-10
-.Ic rate
-30
-.Ic full
-100, matching
-.Xr sshd_config 5 Ns 's
-own default of 10:30:100.
-.It Ic 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 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
-limit and every later connection is refused, so the timer bounds that
-exposure.
-It covers a connection that never begins a TLS handshake on the implicit
-TLS port as well as one that never sends a command, since the former never
-reaches the command path at all.
-.Pp
-RFC 9051, section 5.4 permits this explicitly: servers
-.Qq are allowed to use a shortened pre-authentication timer to protect
-.Qq themselves from Denial-of-Service attacks .
-The timer is cancelled the moment a session authenticates and never applies
-to an established session.
-It is not the post-authentication autologout timer that the same section
-requires to be at least 30 minutes;
-.Nm
-has no such timer.
-.Pp
-Must be between 1 and 3600 seconds inclusive, or 0 to disable it, which
-reopens the denial of service described above.
-Defaults to 60.
-.It Ic lock timeout Ar seconds
-How long a command waits for 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
-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.
-A deadline shorter than an ordinary command's own duration would refuse
-ordinary concurrent use, and a client that does not retry
-.Li INUSE
-would discard the user's change with nothing shown on screen.
-Raise it for mailboxes much larger than a hundred thousand messages, where
-a single command can legitimately run longer than the default.
-.Pp
-Must be between 1 and 3600 seconds inclusive, or 0 to disable the bound,
-which restores an unbounded wait.
-Defaults to 120.
-.It Ic 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 .
-A larger one is refused with a
-.Li NO
-response carrying the RFC 5530
-.Li LIMIT
-code, and nothing is stored.
-.Ar bytes
-must be between 1 and 1073741824 (1 GiB) inclusive; values outside that
-range are a configuration error.
-Defaults to 36700160 (35 MiB), the same as the
-.Ic max-message-size
-default of
-.Xr smtpd.conf 5 .
-It may not exceed
-.Ic attachment max ,
-since a message larger than that could be stored but its structure
-could not be fetched; such a configuration is an error.
-.It Ic attachment max Ar bytes
-Largest message
-.Nm
-will read from disk while deriving
-.Li BODYSTRUCTURE
-or a MIME part-addressed
-.Li BODY Ns Bq Ar part
-fetch.
-A message larger than this is treated the same as any other reason its
-structure cannot be produced, not truncated.
-.Ar bytes
-must be between 12000 and 1073741824 (1 GiB) inclusive; values outside
-that range are a configuration error.
-It must be at least
-.Ic append max .
-Defaults to 41943040 (40 MiB).
-.It Ic include Ar path
-Parse
-.Ar path
-as though its contents appeared in place of this line.
-.It Ic macro Ns = Ns Ar value
-Define a macro, referenced elsewhere in the file as
-.Ic $ Ns Ar macro .
-See also
-.Fl D .
-.El
-.Pp
-A directive not given in the file falls back to its documented default;
-an empty or absent
-.Pa /etc/imapd.conf
-is equivalent to every directive using its default.
-Lines beginning with
-.Sq #
-are comments.
-A sample configuration file, with every directive documented inline, is
-installed at
-.Pa /usr/local/share/examples/imapd/imapd.conf .
-It is not read by
-.Nm
-itself; copy it to
-.Pa /etc/imapd.conf
-and edit as needed.
+Default configuration file.
 .It Pa /etc/imapd/credentials
-Default credentials file, read by the
-.Em auth
-child.
-One line per user, colon-delimited:
-.Sy username : passwordhash : uid : gid : maildir ,
-with the password hash in a
-.Xr crypt_checkpass 3 Ns Ns -compatible
-.Pq bcrypt
-form.
-Must be owned by
-.Sy root
-and group-owned by the
-.Sy _imapauth
-account, mode 0640 or stricter: the
-.Em auth
-child chroots and drops privileges to
-.Sy _imapauth
-before ever opening this file, so it must be group-readable by that
-account specifically, not just root-readable.
-.Xr imapduser 8
-sets this automatically.
+Default credentials file.
+.It Pa /etc/ssl/imapd.crt
+Default TLS certificate.
+.It Pa /etc/ssl/private/imapd.key
+Default TLS private key.
 .It Pa /var/mail/imapd
-Default spool root.
-The
-.Em store
-child
-.Xr chroot 2 Ns s
-into this directory before dropping privileges.
-.It Pa imapd.uidvalidity
-Per-user record, at the root of each maildir, of the highest
-.Li UIDVALIDITY
-ever issued to that user.
-A mailbox that is created, or recreated after a
-.Li DELETE ,
-is given a value greater than every value in this file rather than the
-current time alone, so that a mailbox recreated quickly cannot be handed
-a
-.Li UIDVALIDITY
-it has used before.
-RFC 9051 Section 2.3.1.1 requires that; a bare timestamp only
-approximates it, because two mailboxes created in the same second get
-the same value.
-.Pp
-The file is created on demand, holds one decimal number, and is its own
-lock.
-It is removed with the maildir and needs no separate administration.
-Note that
-.Xr imapduser 8 Fl d
-deliberately leaves a maildir in place, so an account removed and
-re-added keeps its history here, which is the desired behaviour;
-removing a maildir by hand and recreating it resets the record, and a
-client holding a cache from before that point could in principle be
-misled.
+Default mail spool.
+.It Pa imapd.index
+Index of a mailbox's messages, in each mailbox's maildir, with
+.Pa imapd.index.lock
+and
+.Pa imapd.index.tmp
+beside it.
 .It Pa imapd.subscriptions
-Per-user list of subscribed mailboxes, at the root of each maildir, one
-mailbox name per line.
-.Pp
-The file is created on demand by the first
-.Li UNSUBSCRIBE .
-While it is absent, every mailbox is subscribed.
-That rule is what lets an account upgraded from a version without
-subscriptions keep the mailbox list its client already had, rather than
-come back subscribed to nothing; the first
-.Li UNSUBSCRIBE
-therefore writes out every mailbox then present, less the one being
-removed.
-.Pp
-A name stays in the file after its mailbox is deleted, which RFC 9051
-Section 6.3.8 requires.
-.Li LIST
-with the
-.Li SUBSCRIBED
-option reports such a name with the
-.Li \eNonExistent
-attribute, and
-.Li LSUB
-reports it with
-.Li \eNoselect .
-.Li INBOX
-is never named in the file.
-.Pp
-Neither
-.Li CREATE
-nor
-.Li RENAME
-changes the file.
-A new mailbox is subscribed only when a client subscribes it, and renaming
-a subscribed mailbox leaves the old name in the file rather than moving it
-to the new one.
-RFC 9051 Section 6.3.8 tells a server not to remove a name from the
-subscription list because the mailbox by that name no longer exists, and
-moving it is exactly that, so what is subscribed is left to the client to
-say.
-A subscription stranded by a rename is repaired with one
-.Li SUBSCRIBE .
-.Pp
-A file that cannot be read is treated as absent, so damage shows too many
-mailboxes rather than too few, and the failure is logged.
-It is removed with the maildir and needs no separate administration.
+Subscribed mailboxes, one per line, at the root of each maildir, with
+.Pa imapd.subscriptions.lock
+and
+.Pa imapd.subscriptions.tmp
+beside it.
+Created by the first
+.Li UNSUBSCRIBE ;
+while it is absent, every mailbox is subscribed.
+.It Pa imapd.uidvalidity
+Highest
+.Li UIDVALIDITY
+issued, at the root of each maildir.
+Created on demand.
+.It Pa /usr/local/share/examples/imapd/imapd.conf
+Example configuration file.
 .El
-.Sh NETWORK
-.Nm
-binds
-.Pa 0.0.0.0
-.Pq IPv4 only
-port 143
-.Pq cleartext/STARTTLS
-and port 993
-.Pq implicit TLS
-by default; these, along with the listen address, are read from
-.Pa /etc/imapd.conf
-(or the file named by
-.Fl f )
-at startup.
-A file containing no
-.Ic listen
-directive leaves both listeners on those defaults; a file containing any
-.Ic listen
-directive binds only the listeners it names.
-IPv6 and dual-stack binding are available but not the default; see the
-.Ic listen on
-directive under FILES above.
 .Sh SEE ALSO
 .Xr crypt_checkpass 3 ,
-.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 imapd.conf 5 ,
 .Xr imapduser 8 ,
+.Xr rc.d 8 ,
 .Xr smtpd 8 ,
-.Xr sysctl 8 ,
 .Xr syslogd 8
 .Sh STANDARDS
 .Rs
@@ -848,12 +263,27 @@ directive under FILES above.
 .Re
 .Pp
 .Rs
+.%A K. Zeilenga
+.%D August 2006
+.%R RFC 4616
+.%T The PLAIN Simple Authentication and Security Layer (SASL) Mechanism
+.Re
+.Pp
+.Rs
 .%A J. Klensin
 .%A M. Padlipsky
 .%D March 2008
 .%R RFC 5198
 .%T Unicode Format for Network Interchange
 .Re
+.Pp
+.Rs
+.%A K. Moore
+.%A C. Newman
+.%D January 2018
+.%R RFC 8314
+.%T Cleartext Considered Obsolete: Use of Transport Layer Security (TLS) for Email Submission and Access
+.Re
 .Sh HISTORY
 .Nm
 is a from-scratch IMAP server written for the OpenIMAPD project, in
@@ -864,117 +294,14 @@ privilege-separation tradition of
 .Sh CAVEATS
 This implementation is under active development.
 .Pp
-.Li INBOX
-cannot be unsubscribed.
-RFC 9051 Section 5.1 guarantees that it always exists and that nothing
-can delete it, so it is treated as permanently subscribed:
-.Li SUBSCRIBE INBOX
-succeeds and changes nothing, and
-.Li UNSUBSCRIBE INBOX
-replies
-.Li NO .
-A subscription-filtered view therefore always includes it.
+.Li IDLE
+is served by polling the selected mailbox, so new mail is reported
+within one
+.Ic idle poll
+interval rather than at once.
 .Pp
-.Li LIST
-selection options other than
-.Li SUBSCRIBED
-are refused rather than ignored.
-.Li REMOTE
-and
-.Li RECURSIVEMATCH
-are not implemented, and accepting either silently would misreport which
-names the response contains.
-.Pp
-Shared or multi-user mailboxes
-.Pq no Li ACL support
-are deliberately out of scope.
-.Pp
-Mailbox names are required to be well-formed UTF-8.
-RFC 9051 Section 5.1 encodes them in Net-Unicode
-.Pq RFC 5198 ,
-and requires a server to prohibit the creation of 8-bit names that do not
-comply.
-.Nm
-enforces three of Net-Unicode's requirements and not the other three, which
-is worth stating plainly rather than leaving to be discovered:
-.Bl -bullet -offset indent -compact
-.It
-UTF-8 well-formedness per RFC 3629 is enforced.
-Overlong encodings, UTF-16 surrogates and anything above U+10FFFF are
-refused.
-.It
-The C1 controls U+0080 to U+009F are refused, as Net-Unicode requires.
-C0 and DEL were already refused, which RFC 9051 Section 5.1 permits.
-.It
-U+FEFF is refused anywhere in a name.
-Net-Unicode forbids it only at the beginning;
-.Nm
-is deliberately stricter, because a zero-width no-break space inside a name
-produces two mailboxes no user can tell apart.
-.It
-Normalization to NFC is
-.Em not
-performed and
-.Em not
-required.
-Two canonically equivalent spellings of the same name, such as U+00E9 and
-U+0065 U+0301, are therefore two distinct mailboxes with distinct
-.Li UIDVALIDITY
-values.
-A client that normalizes differently from another client used on the same
-account will see both.
-.It
-Names are
-.Em not
-checked against a Unicode version, so a name containing an unassigned code
-point is accepted.
-Net-Unicode forbids that, and honouring it would require a Unicode character
-database inside the daemon.
-.El
-.Pp
-A name that fails these checks is refused with
-.Li NO [CANNOT]
-by
-.Li CREATE ,
-.Li RENAME
-and
-.Li COPY Ns / Ns Li MOVE ,
-and reported as nonexistent by the commands that only ask whether a mailbox
-is there.
-A directory whose name fails them is skipped by
-.Li LIST
-and cannot be selected; the first such skip in a connection is logged, naming
-the directory.
-This last case can only arise for a mailbox created before these checks
-existed, or created outside
-.Nm
-altogether.
-.Pp
-RFC 9051 Section 6.3.5 does not say what becomes of a session that
-.Li DELETE Ns s
-the mailbox it currently has selected, and states no condition under which
-such a
-.Li DELETE
-must be refused.
-.Nm
-allows it and returns the session to the authenticated state, as
-.Li CLOSE
-does: the mailbox no longer exists, so nothing is selected.
-A client that issues
-.Li FETCH ,
-.Li STORE ,
-.Li SEARCH ,
-.Li COPY ,
-.Li MOVE
-or
-.Li EXPUNGE
-afterwards is refused until it selects a mailbox again, rather than being
-served
-.Li INBOX
-under the deleted mailbox's name.
-.Pp
-All sessions of one account are served by one process, which runs one
-command at a time.
+All sessions of one account are served by one process, one command
+at a time.
 .Li FETCH
 and
 .Li SEARCH
@@ -984,6 +311,85 @@ give way to the account's other sessions as they go;
 .Li COPY
 and
 .Li MOVE
-do not.
-One of these over a very large mailbox therefore delays every other
+do not, so one of these over a very large mailbox delays every other
 session of the same account until it completes.
+.Pp
+If the process holding the TLS private key exits, it is not restarted,
+and new TLS handshakes fail until
+.Nm
+is restarted.
+.Pp
+.Li INBOX
+cannot be unsubscribed:
+.Li UNSUBSCRIBE INBOX
+replies
+.Li NO .
+A subscription is kept when its mailbox is deleted, as RFC 9051
+section 6.3.7 recommends, and is not moved when its mailbox is renamed,
+as section 6.3.6 describes.
+.Pp
+.Li RENAME INBOX
+is refused, which RFC 9051 section 6.3.6 notes some servers do.
+.Pp
+.Li LIST
+selection options other than
+.Li SUBSCRIBED ,
+return options, and more than one mailbox pattern are refused.
+.Pp
+.Li FETCH
+does not support
+.Li BINARY ,
+.Li BINARY.PEEK
+or
+.Li BINARY.SIZE ,
+which are answered
+.Li BAD .
+It does not return a section that combines a part number with
+.Li HEADER ,
+.Li HEADER.FIELDS ,
+.Li TEXT
+or
+.Li MIME ,
+nor
+.Li RFC822 ,
+.Li RFC822.HEADER
+or
+.Li RFC822.TEXT ;
+it returns the other items asked for and answers
+.Li NO .
+.Pp
+A session that deletes its selected mailbox is returned to the
+authenticated state, as if by
+.Li CLOSE .
+.Pp
+Mailbox names must be well-formed UTF-8 per RFC 3629.
+Of the Net-Unicode
+.Pq RFC 5198
+requirements,
+.Nm
+refuses the C1 controls and refuses U+FEFF anywhere in a name, which is
+stricter than RFC 5198.
+It does not normalize names to NFC, so two canonically equivalent
+spellings are two distinct mailboxes, and it does not check names
+against a Unicode version.
+.Li CREATE ,
+.Li RENAME ,
+.Li SUBSCRIBE ,
+.Li UNSUBSCRIBE ,
+.Li COPY
+and
+.Li MOVE
+refuse a name that fails these checks with
+.Li NO [CANNOT] ,
+and other commands report it as nonexistent.
+An existing directory with such a name is not listed and cannot be
+selected.
+.Pp
+The names
+.Pa tmp ,
+.Pa new
+and
+.Pa cur ,
+and the names of the files listed under
+.Sx FILES
+that are kept in a maildir, cannot be used as mailbox names.
blob - ac8f63423c2f281213f976240a94b8fa2e18ebd3
blob + ba320d62cfd97a54d1625488669effc1319157c3
--- src/imapd.conf.example
+++ src/imapd.conf.example
@@ -1,5 +1,5 @@
 #
-# imapd.conf example, see imapd(8) for the full directive list.
+# imapd.conf example, see imapd.conf(5) for the full directive list.
 #
 # This file is installed read-only at /usr/local/share/examples/imapd/
 # imapd.conf (matching the OpenBSD ports convention for sample configs,
@@ -30,8 +30,8 @@ listen on 0.0.0.0 tls port 993
 # The address may also be "::" (all IPv6 interfaces) or "*" (both IPv4 and
 # IPv6, binds two sockets per listener, one of each family, matching
 # httpd.conf(5)'s own "*" convention), or a single literal IPv4/IPv6
-# address. Hostnames are not accepted, see imapd(8) for why. For example,
-# to listen on both address families:
+# address. Hostnames are not accepted. For example, to listen on both
+# address families:
 #listen on * port 143
 #listen on * tls port 993
 
@@ -40,7 +40,7 @@ listen on 0.0.0.0 tls port 993
 #spool "/var/mail/imapd"
 
 # Credentials file: one line per user, "username:passwordhash:uid:gid:
-# maildir", see imapduser(8) and imapd(8) FILES. Defaults to
+# maildir", see imapduser(8) and imapd(8). Defaults to
 # /etc/imapd/credentials.
 #credentials "/etc/imapd/credentials"
 
@@ -52,7 +52,7 @@ listen on 0.0.0.0 tls port 993
 #tls key "/etc/ssl/private/imapd.key"
 
 # Largest message imapd will read from disk while deriving BODYSTRUCTURE
-# or a MIME part-addressed BODY[<part>] fetch, see imapd(8). Must be
+# or a MIME part-addressed BODY[<part>] fetch, see imapd.conf(5). Must be
 # between 12000 and 1073741824 (1 GiB) bytes. Defaults to 41943040
 # (40 MiB, sized off Gmail's documented attachment limit plus base64
 # encoding overhead, see the BODYSTRUCTURE_READ_DEFAULT comment in
@@ -60,7 +60,7 @@ listen on 0.0.0.0 tls port 993
 # routinely carries larger attachments than that.
 attachment max 41943040
 
-# Largest message a client may upload with APPEND, see imapd(8). Must be
+# Largest message a client may upload with APPEND, see imapd.conf(5). Must be
 # between 1 and 1073741824 (1 GiB) bytes. Defaults to 36700160 (35 MiB),
 # the same as smtpd.conf(5)'s max-message-size default, so a message the
 # local MTA accepts can also be saved by a client. It may not exceed
blob - /dev/null
blob + f46a691dfe5110714c6b3d4809ba96583710a330 (mode 644)
--- /dev/null
+++ src/imapd.conf.5
@@ -0,0 +1,315 @@
+.\" $OpenIMAPD$
+.\"
+.\" Written for the OpenIMAPD project. Public domain / no rights reserved,
+.\" matching the project's ports-oriented, OpenBSD-base-inclusion goal.
+.\"
+.Dd $Mdocdate: September 30 2026 $
+.Dt IMAPD.CONF 5
+.Os
+.Sh NAME
+.Nm imapd.conf
+.Nd IMAP daemon configuration file
+.Sh DESCRIPTION
+.Nm
+is the configuration file for the IMAP daemon,
+.Xr imapd 8 .
+Every setting has a default, so an empty file is a valid configuration.
+.Xr imapd 8
+refuses to start if the file does not exist.
+.Pp
+The file must be owned by root or by the user running
+.Xr imapd 8 ,
+and must not be group writable, group executable or accessible by
+others.
+.Pp
+The current line can be extended over multiple lines using a backslash
+.Pq Sq \e .
+Comments can be put anywhere in the file using a hash mark
+.Pq Sq # ,
+and extend to the end of the current line.
+Arguments not beginning with a letter, digit, colon, underscore or
+asterisk must be quoted.
+.Pp
+Additional configuration files can be included with the
+.Ic include
+keyword, for example:
+.Bd -literal -offset indent
+include "/etc/imapd.conf.local"
+.Ed
+.Pp
+An included file must meet the same ownership and permission
+requirements.
+.Pp
+When
+.Xr imapd 8
+receives
+.Dv SIGHUP
+it rereads the file and applies the new settings to connections
+accepted afterwards, except for
+.Ic listen on
+and
+.Ic credentials ,
+which take effect only when
+.Xr imapd 8
+is restarted.
+Sessions already connected are not disturbed and keep the settings
+they started with.
+A file that fails to parse is logged and the running configuration is
+kept.
+.Ss Macros
+A macro is defined with a command of the form
+.Ar name Ns = Ns Ar value .
+The macro
+.Ar name
+cannot contain whitespace.
+Within unquoted arguments, the string
+.Pf $ Ar name
+is later expanded to
+.Ar value .
+Macros can also be defined on the command line with the
+.Fl D
+option of
+.Xr imapd 8 .
+.Ss Global configuration
+.Bl -tag -width Ds
+.It Ic account sessions Ar count
+The most sessions one account may have open at once, from all of its
+clients together.
+A login past the limit is answered
+.Li NO
+with the
+.Li LIMIT
+response code and the connection is closed.
+A client that vanishes without closing its connection holds its
+session until TCP keepalive notices, which by default is 2 hours 10
+minutes; see
+.Va net.inet.tcp.keepidle
+and
+.Va net.inet.tcp.keepintvl
+in
+.Xr sysctl 8 .
+Must be between 1 and 4096.
+The default is 8.
+.It Ic append max Ar bytes
+The largest message a client may upload with
+.Li APPEND .
+A larger one is refused with the
+.Li LIMIT
+response code and nothing is stored.
+Must be between 1 and 1073741824 and may not exceed
+.Ic attachment max .
+The default is 36700160 (35 MiB), the same as the
+.Ic smtp max-message-size
+default of
+.Xr smtpd.conf 5 .
+.It Ic attachment max Ar bytes
+The largest message
+.Xr imapd 8
+will read to produce its
+.Li BODYSTRUCTURE
+or one of its MIME parts.
+A larger message is treated as one whose structure cannot be produced;
+it is not truncated.
+The same limit bounds what
+.Li SEARCH
+reads of a message, so a search on
+.Li BODY
+or
+.Li TEXT
+that reaches a larger message fails with
+.Li NO .
+Must be between 12000 and 1073741824 and may not be less than
+.Ic append max .
+The default is 41943040 (40 MiB).
+.It Ic connections max Ar count
+The most connections open at once, logged in or not.
+Past it, a new connection on the cleartext port is answered with a
+.Li BYE
+carrying the
+.Li UNAVAILABLE
+response code, and one on the implicit TLS port is closed with nothing
+sent.
+.Pp
+C connections, L of them not yet logged in, for A accounts need up to
+C + L + 2A processes plus two, and the parent holds a descriptor to each.
+These count against the
+.Va kern.maxproc
+.Xr sysctl 8
+and the
+.Cm openfiles
+limit of the
+.Xr login.conf 5
+class
+.Xr imapd 8
+runs in; raise those before raising this.
+Must be between 1 and 4096.
+The default is 256, which fits the
+.Ox
+defaults.
+.It Ic credentials Ar path
+The credentials file; see
+.Xr imapd 8 .
+The default is
+.Pa /etc/imapd/credentials .
+.It Ic idle poll Ar seconds
+How often a session in
+.Li IDLE
+checks its selected mailbox for changes.
+Must be between 1 and 300, or 0 to disable polling, in which case a
+session in
+.Li IDLE
+is told nothing until it sends
+.Li DONE .
+The default is 5.
+.It Ic listen on Ar address Oo Ic tls Oc Ic port Ar port
+Listen on
+.Ar address
+and
+.Ar port .
+Without
+.Ic tls ,
+the listener is cleartext and offers
+.Li STARTTLS ;
+with
+.Ic tls ,
+it uses implicit TLS.
+Each may be given once, and both must use the same
+.Ar address .
+.Pp
+.Ar address
+is a literal IPv4 or IPv6 address,
+.Ar 0.0.0.0
+for all IPv4 addresses,
+.Ar ::
+for all IPv6 addresses, or
+.Sq *
+for all IPv4 and IPv6 addresses.
+Hostnames are not accepted.
+.Pp
+If no
+.Ic listen on
+is given,
+.Xr imapd 8
+listens on 0.0.0.0 port 143 and 0.0.0.0 tls port 993.
+If any is given, only the listeners named are bound.
+.It Ic lock timeout Ar seconds
+How long a command waits for a mailbox lock held by another process
+before answering
+.Li NO
+with the
+.Li INUSE
+response code.
+This bounds a holder that is stuck, not one that is slow, and should
+be raised for mailboxes much larger than a hundred thousand messages.
+Must be between 1 and 3600, or 0 to wait indefinitely.
+The default is 120.
+.It Ic login grace Ar seconds
+How long a connection may remain unauthenticated before it is closed,
+including one that never begins a TLS handshake.
+This is the pre-authentication timer permitted by RFC 9051 section 5.4;
+it does not apply once a session has authenticated.
+Must be between 1 and 3600, or 0 to disable it.
+The default is 60.
+.It Ic spool Ar path
+The mail spool.
+Each account's mailbox process is
+.Xr chroot 2 Ns ed
+into
+.Ar path ,
+and maildirs in the credentials file are relative to it.
+.Ar path
+and every entry directly under it must be owned by root and not be
+writable by mail users;
+.Xr imapd 8
+does not check this.
+The default is
+.Pa /var/mail/imapd .
+.It Ic startups begin Ar count Ic rate Ar percent Ic full Ar count
+Limit concurrent connections that have not yet authenticated, using
+the algorithm of
+.Cm MaxStartups
+in
+.Xr sshd_config 5 .
+Below
+.Ic begin
+such connections, every new connection is accepted.
+From
+.Ic begin ,
+new connections are refused with a probability rising linearly from
+.Ic rate
+percent to 100 percent at
+.Ic full .
+A refused connection is answered as for
+.Ic connections max .
+.Pp
+The counts must be between 0 and 1000000,
+.Ar percent
+between 0 and 100, and
+.Ic full
+may not be less than
+.Ic begin .
+Setting
+.Ic full
+to 0 disables the limit.
+The default is
+.Ic begin
+10
+.Ic rate
+30
+.Ic full
+100.
+.It Ic startups per-source Ar count | Ic none
+The most connections that have not yet authenticated from any one
+address, as
+.Cm PerSourceMaxStartups
+in
+.Xr sshd_config 5 .
+Each IPv6 address counts separately.
+A refused connection is answered as for
+.Ic connections max .
+Must be between 1 and 1000000, or
+.Ic none
+to disable the limit.
+The default is 5.
+.It Ic tls certificate Ar path
+The TLS certificate.
+The default is
+.Pa /etc/ssl/imapd.crt .
+.It Ic tls key Ar path
+The TLS private key, which must be owned by root and have no
+permissions beyond 0740.
+A key that fails this check is not loaded, and a warning is logged.
+The default is
+.Pa /etc/ssl/private/imapd.key .
+.El
+.Sh FILES
+.Bl -tag -width "/usr/local/share/examples/imapd/imapd.conf" -compact
+.It Pa /etc/imapd.conf
+.Xr imapd 8
+configuration file.
+.It Pa /usr/local/share/examples/imapd/imapd.conf
+Example configuration file.
+.El
+.Sh EXAMPLES
+Listen with implicit TLS only, as RFC 8314 section 3 recommends, on all
+IPv4 and IPv6 addresses:
+.Bd -literal -offset indent
+listen on * tls port 993
+.Ed
+.Pp
+Use a certificate and key named for the host, and allow each account
+more concurrent sessions:
+.Bd -literal -offset indent
+tls certificate "/etc/ssl/mail.example.com.fullchain.pem"
+tls key "/etc/ssl/private/mail.example.com.key"
+account sessions 16
+.Ed
+.Sh SEE ALSO
+.Xr login.conf 5 ,
+.Xr smtpd.conf 5 ,
+.Xr sshd_config 5 ,
+.Xr imapd 8 ,
+.Xr sysctl 8
+.Sh HISTORY
+.Nm
+was written for the OpenIMAPD project.
blob - 39657eccd76572179da9fd1272ca3f9e4935ea00
blob + 1399571babfb3228b192fa93d49b66a9bbe29e86
--- src/imapd.h
+++ src/imapd.h
@@ -28,7 +28,7 @@
 #include <imsg.h>
 #include <stdint.h>
 
-#define IMAPD_VERSION	"0.1.9"
+#define IMAPD_VERSION	"0.2.0"
 
 #define IMAPD_USER	"_imapd"
 #define IMAPD_AUTH_USER	"_imapauth"
@@ -137,7 +137,8 @@ enum imsg_type {
 
 	/* RFC 9051 SS6.3.7/SS6.3.8 (SUBSCRIBE/UNSUBSCRIBE) */
 	IMSG_MBOX_SUBSCRIBE,
-	IMSG_MBOX_UNSUBSCRIBE
+	IMSG_MBOX_UNSUBSCRIBE,
+	IMSG_MBOX_SAVED_CLEAR
 };
 
 struct imsgev {
@@ -257,7 +258,7 @@ struct imsg_store_init {
 
 #define MBOX_NAME_MAX	256
 
-/* RFC 5530 NONEXISTENT/ALREADYEXISTS, RFC 9051 SS7.1 INUSE */
+/* RFC 5530 NONEXISTENT/ALREADYEXISTS/LIMIT; RFC 9051 INUSE, NOTSAVED */
 enum mbox_op_error {
 	MBOX_ERR_UNSET = 0,
 	MBOX_OP_OK,
@@ -266,6 +267,8 @@ enum mbox_op_error {
 	MBOX_OP_ERR_ALREADY_EXISTS,
 	MBOX_OP_ERR_BUSY,
 	MBOX_OP_ERR_EXPUNGEISSUED,
+	MBOX_OP_ERR_LIMIT,
+	MBOX_OP_ERR_NOTSAVED,
 };
 
 /* QRESYNC select-param (RFC 7162 SS3.2.5). */
@@ -438,6 +441,9 @@ struct seq_range {
 	int		hi_is_star;
 };
 
+/* RFC 9051 SS9 seq-last-command, "$": the all-zero range */
+#define SEQ_RANGE_SAVED(r)	((r)->lo == 0 && !(r)->lo_is_star)
+
 /* keeps the trailing array under MAX_IMSGSIZE */
 #define SEQSET_MAX_RANGES	500
 
@@ -630,6 +636,7 @@ struct imsg_mbox_appended {
 #define SEARCH_OP_BCC		32
 #define SEARCH_OP_BODY		33
 #define SEARCH_OP_TEXT		34
+#define SEARCH_OP_SAVED		35
 
 struct search_node {
 	int		op;
@@ -645,9 +652,17 @@ struct search_node {
 	uint32_t	name_len;
 };
 
+/* RFC 9051 SS6.4.4 search-return-opt */
+#define SEARCH_RETURN_MIN	(1U << 0)
+#define SEARCH_RETURN_MAX	(1U << 1)
+#define SEARCH_RETURN_ALL	(1U << 2)
+#define SEARCH_RETURN_COUNT	(1U << 3)
+#define SEARCH_RETURN_SAVE	(1U << 4)
+
 struct imsg_mbox_search {
 	uint32_t	nnodes;
 	uint32_t	poollen;
+	uint32_t	return_opts;
 	char		pool[SEARCH_OPERANDS_MAX];
 };
 
blob - fe42950141044244657ac834101c6d2d9009749c
blob + 2a1b2706eaad3081c7d655ba21ff1fcba647e5ee
--- src/index.c
+++ src/index.c
@@ -583,19 +583,25 @@ index_free(struct mbox_index *idx)
 	memset(idx, 0, sizeof(*idx));
 }
 
-/* RFC 7162 SS3.2.5.1 QRESYNC resync */
+/* RFC 7162 SS3.2.5.1 QRESYNC resync; "$" is the one before SELECT */
 void
 qresync_send_resync(const struct imsg_mbox_select *req,
     const struct seq_range *ranges, uint32_t nranges,
     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;
+	struct seq_range	resolved[2 * SEQSET_MAX_RANGES];
+	uint32_t		nresolved, max_hi, i, used;
 
 	if (req->qresync_has_uids) {
-		nresolved = seqset_resolve(ranges, nranges,
+		used = nranges > 0 && SEQ_RANGE_SAVED(&ranges[nranges - 1]);
+		nresolved = seqset_resolve(ranges, nranges - used,
 		    index_max_uid(idx), 0, resolved);
+		if (used) {
+			memcpy(resolved + nresolved, ss->saved,
+			    ss->nsaved * sizeof(*resolved));
+			nresolved += ss->nsaved;
+		}
 	} else {
 		/* RFC 7162 SS3.2.5.1: no known-uids means 1:uidnext-1 */
 		if (idx->uidnext <= 1)
@@ -967,6 +973,30 @@ view_resolve(const struct store_session *ss, const str
 	*by_uid = 1;
 }
 
+/* view_resolve(), a last "$" appended as UIDs: -1 over the cap */
+int
+saved_resolve(const struct store_session *ss, const struct seq_range *in,
+    uint32_t nin, struct seq_range *out, uint32_t *nout, int *by_uid,
+    int *used)
+{
+	int	u;
+
+	u = *used = nin > 0 && SEQ_RANGE_SAVED(&in[nin - 1]);
+	if (u)
+		nin--;
+	view_resolve(ss, in, nin, out, nout, by_uid);
+	if (!u)
+		return (0);
+	if (*by_uid == 0 && nin > 0)
+		return (-2);
+	*by_uid = 1;
+	if (*nout + ss->nsaved > SEQSET_MAX_RANGES)
+		return (-1);
+	memcpy(out + *nout, ss->saved, ss->nsaved * sizeof(*out));
+	*nout += ss->nsaved;
+	return (0);
+}
+
 /* 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,
blob - 3726d921cf0beb10b9d3633754337174daf18e4a
blob + 0741e76249dd96e3781a2f87114e35397b1d0fcd
--- src/listener.c
+++ src/listener.c
@@ -104,6 +104,9 @@ struct imap_cmd_entry {
 	 (1U << SESSION_DELETING) | (1U << SESSION_RENAMING) | \
 	 (1U << SESSION_LISTING))
 #define ST_NOTAUTH	(1U << SESSION_NOT_AUTH)
+#define ST_PREAUTH \
+	(ST_NOTAUTH | (1U << SESSION_AUTHENTICATING) | \
+	 (1U << SESSION_STORE_PENDING))
 
 /* RFC 9051 SS9; excludes transient states, which have their own pending_tag. */
 #define ST_AUTH \
@@ -1072,7 +1075,7 @@ session_dispatch_client(int fd, short event, void *arg
 	for (;;) {
 		uint64_t	nonsync_len;
 		size_t		consumed, linelen;
-		int		alive;
+		int		alive, resume;
 
 		if (s->literal_discard > 0) {
 			uint64_t	take;
@@ -1086,7 +1089,7 @@ session_dispatch_client(int fd, short event, void *arg
 				s->literal_discard -= take;
 			}
 			if (s->literal_discard > 0)
-				break;	/* need more data */
+				break;
 			s->literal_skipline = 1;
 			continue;
 		}
@@ -1115,7 +1118,7 @@ session_dispatch_client(int fd, short event, void *arg
 			}
 
 			if (s->literal_remaining > 0)
-				break;	/* need more data */
+				break;
 
 			if (s->inbuflen < 2)
 				break;
@@ -1142,7 +1145,7 @@ session_dispatch_client(int fd, short event, void *arg
 			take = s->inbuflen < s->cmd_octets ?
 			    s->inbuflen : s->cmd_octets;
 			if (take == 0)
-				break;	/* need more data */
+				break;
 			if (memchr(s->inbuf, '\0', take) != NULL) {
 				/* RFC 9051 SS9: CHAR8 excludes NUL */
 				session_cmd_reply(s, "BAD", "NUL in a literal");
@@ -1198,9 +1201,11 @@ session_dispatch_client(int fd, short event, void *arg
 			return;
 		}
 
+		resume = 0;
 		if (s->literal_skipline) {
 			s->literal_skipline = 0;
 			alive = 1;
+			resume = 1;
 		} else if (s->cmd_gather) {
 			s->cmd_gather = 0;
 			alive = 1;
@@ -1212,16 +1217,15 @@ session_dispatch_client(int fd, short event, void *arg
 				    linelen + 1);
 				if (!s->cmd_queued)
 					alive = session_run_cmd(s, s->cmdbuf);
-				else if (nonsync_len > 0)
+				else if (nonsync_len > 0) {
+					s->cmdlen += linelen;
 					(void)session_gather(s, nonsync_len);
-				else {
+				} else {
 					s->cmd_queued = 0;
 					alive = session_queue(s, s->cmdbuf);
 				}
 			}
-			if (alive && nonsync_len == 0 && !s->cmd_gather &&
-			    s->cmd_queue_n > 0 && !session_is_busy(s))
-				alive = session_dequeue_next(s);
+			resume = 1;
 		} else if (s->auth_cont) {
 			/* the line is the user's base64 password, see below */
 			s->scrub_inbuf = 1;
@@ -1242,6 +1246,8 @@ session_dispatch_client(int fd, short event, void *arg
 		} else {
 			alive = session_run_cmd(s, s->inbuf);
 		}
+		if (alive && resume && nonsync_len == 0)
+			alive = session_dequeue_next(s);
 		if (alive == 0)
 			return;	/* s was torn down (LOGOUT), do not touch */
 
@@ -1280,15 +1286,20 @@ session_dispatch_client(int fd, short event, void *arg
 		event_active(&s->client_ev, EV_READ, 1);
 }
 
-#define SESSION_WRITE_POLL_TIMEOUT_MS	5000
+/* RFC 9051 SS5.4: a short timer before login, 30 minutes after */
+#define SESSION_WRITE_PREAUTH_MS	5000
+#define SESSION_WRITE_AUTH_MS		(1800 * 1000)
 
 void
 session_write(struct session *s, const char *buf, size_t len)
 {
 	size_t	sent = 0;
+	int	timeout;
 
 	if (s->write_failed)
 		return;
+	timeout = (ST_PREAUTH & (1U << s->state)) ?
+	    SESSION_WRITE_PREAUTH_MS : SESSION_WRITE_AUTH_MS;
 
 	if (log_getverbose() > 0) {
 		char	dbuf[301];
@@ -1316,20 +1327,16 @@ session_write(struct session *s, const char *buf, size
 				if (errno == EAGAIN || errno == EWOULDBLOCK) {
 					pfd.fd = s->client_fd;
 					pfd.events = POLLOUT;
-					if (poll(&pfd, 1,
-					    SESSION_WRITE_POLL_TIMEOUT_MS)
-					    <= 0) {
+					if (poll(&pfd, 1, timeout) <= 0) {
 						log_warnx("session %u: write: "
 						    "timed out or poll error",
 						    s->id);
-						s->write_failed = 1;
-						return;
+						goto fail;
 					}
 					continue;
 				}
 				log_warn("session %u: write", s->id);
-				s->write_failed = 1;
-				return;
+				goto fail;
 			}
 			sent += (size_t)n;
 		}
@@ -1345,29 +1352,32 @@ session_write(struct session *s, const char *buf, size
 		if (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT) {
 			pfd.fd = s->client_fd;
 			pfd.events = (n == TLS_WANT_POLLIN) ? POLLIN : POLLOUT;
-			pret = poll(&pfd, 1, SESSION_WRITE_POLL_TIMEOUT_MS);
+			pret = poll(&pfd, 1, timeout);
 			if (pret == -1) {
 				log_warn("session %u: poll (tls_write retry)",
 				    s->id);
-				s->write_failed = 1;
-				return;
+				goto fail;
 			}
 			if (pret == 0) {
 				log_warnx("session %u: tls_write: timed out "
 				    "waiting for socket", s->id);
-				s->write_failed = 1;
-				return;
+				goto fail;
 			}
 			continue;
 		}
 		if (n == -1) {
 			log_warnx("session %u: tls_write: %s", s->id,
 			    tls_error(s->tls_ctx));
-			s->write_failed = 1;
-			return;
+			goto fail;
 		}
 		sent += (size_t)n;
 	}
+	return;
+
+fail:
+	s->write_failed = 1;
+	/* the client may never send again, so do not wait for it */
+	event_active(&s->client_ev, EV_READ, 1);
 }
 
 
blob - 272fe1eb376922247b102eb04ff8e4fc7e165ebc
blob + 900627b3d2989560a5d97bc7a6a83e9a5e7e8ed8
--- src/listener.h
+++ src/listener.h
@@ -68,12 +68,6 @@ enum session_state {
 /* RFC 7162 SS7: a mod-sequence is a positive 63-bit integer */
 #define MODSEQ_MAX		INT64_MAX
 
-/* 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)
-
 struct vanished_range {
 	uint32_t	lo;
 	uint32_t	hi;
@@ -331,7 +325,6 @@ int	 parse_select_params(char *, struct imsg_mbox_sele
 int	 parse_qresync_group(char *, struct imsg_mbox_select *,
 		    struct seq_range[SEQSET_MAX_RANGES], uint32_t *,
 		    const char **);
-char	*split_trailing_modifiers(char *);
 int	 parse_fetch_modifiers(char *, struct imsg_mbox_fetch *,
 		    const struct session *, int, int *, const char **);
 int	 parse_store_modifiers(char *, struct imsg_mbox_store *,
blob - c64b5e7d6cf102bfad49200a205fe00d009d82b6
blob + c91609ea6e57b9ca568497435bfef3cbb3f02b97
--- src/mbox_copy.c
+++ src/mbox_copy.c
@@ -443,7 +443,8 @@ finish_copy_move(struct mbox_index *idx_a, struct mbox
 	index_lock_release(il_a);
 	index_lock_release(il_b);
 
-	if (result->error != MBOX_OP_ERR_NO_SUCH_MAILBOX)
+	if (result->error != MBOX_OP_ERR_NO_SUCH_MAILBOX &&
+	    result->error != MBOX_OP_ERR_LIMIT)
 		result->error = ok ? MBOX_OP_OK : MBOX_OP_ERR_GENERIC;
 	index_free(idx_a);
 	index_free(idx_b);
@@ -473,7 +474,7 @@ handle_mbox_copy(struct imsg_mbox_copy *req, const str
 	int				 ok = 1;
 	char				 desttarget[MBOX_NAME_MAX];
 	int				 cross_mailbox;
-	int				 destfd = -1, dfd, got;
+	int				 destfd = -1, dfd, got, used;
 
 	memset(&idx_a, 0, sizeof(idx_a));
 	memset(&idx_b, 0, sizeof(idx_b));
@@ -490,7 +491,13 @@ handle_mbox_copy(struct imsg_mbox_copy *req, const str
 	}
 	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);
+	if ((got = saved_resolve(ss, ranges, nranges, vr, &nvr,
+	    &req->by_uid, &used)) != 0) {
+		if (got == -1)
+			result.error = MBOX_OP_ERR_LIMIT;
+		ok = 0;
+		goto close_dest;
+	}
 	(void)view_sync(ss, srcidx, 1);
 
 	if (!stage_copy_messages(ss, srcidx, req, vr, nvr, &staged,
@@ -803,7 +810,7 @@ handle_mbox_move(struct imsg_mbox_copy *req, const str
 	int				 ok = 1;
 	char				 desttarget[MBOX_NAME_MAX];
 	int				 cross_mailbox;
-	int				 destfd = -1, got;
+	int				 destfd = -1, got, used;
 	struct seq_range		 vr[SEQSET_MAX_RANGES];
 	uint32_t			 nvr;
 
@@ -821,7 +828,15 @@ handle_mbox_move(struct imsg_mbox_copy *req, const str
 		goto done;
 	}
 
-	view_resolve(ss, ranges, nranges, vr, &nvr, &req->by_uid);
+	if ((got = saved_resolve(ss, ranges, nranges, vr, &nvr,
+	    &req->by_uid, &used)) != 0) {
+		if (got == -1)
+			result.error = MBOX_OP_ERR_LIMIT;
+		ok = 0;
+		if (destfd != -1)
+			close(destfd);
+		goto done;
+	}
 	(void)view_sync(ss, srcidx, 1);
 	if (!cross_mailbox) {
 		if (!move_same_mailbox(req, vr, nvr, srcidx, &nmoved, ss))
blob - 24b638d4b47dfecd0463e58273e3821e1b60219a
blob + 8b2586daa09bc057f91e13740e82ca128037d069
--- src/mbox_fetch.c
+++ src/mbox_fetch.c
@@ -102,7 +102,7 @@ handle_mbox_fetch(struct imsg_mbox_fetch *req, const s
 	struct index_lock	 il = INDEX_LOCK_INIT;
 	struct seq_range	 vr[SEQSET_MAX_RANGES];
 	uint32_t		 i, nvr;
-	int			 locked, synced, rc, set_seen;
+	int			 locked, synced, rc, set_seen, used;
 
 	set_seen = (req->attrs & MBOX_FETCH_SET_SEEN) != 0;
 	/* Before the reset below, so that a busy return changes nothing. */
@@ -134,13 +134,20 @@ handle_mbox_fetch(struct imsg_mbox_fetch *req, const s
 	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);
+	if ((rc = saved_resolve(ss, ranges, nranges, vr, &nvr,
+	    &fw->req.by_uid, &used)) != 0) {
+		if (set_seen)
+			index_lock_release(&il);
+		fw->limit = rc == -1;
+		fetch_walk_finish(ss, 0);
+		return (0);
+	}
 	/* RFC 9051 SS6.4.9 */
 	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 &&
+	fw->ghost = !req->by_uid && !used &&
 	    view_names_ghost(ss, idx, fw->resolved, fw->nresolved);
 
 	if (set_seen) {
@@ -508,7 +515,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_ERR_GENERIC : fw->ghost ?
+	result.error = !ok ? (fw->limit ? MBOX_OP_ERR_LIMIT :
+	    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,
blob - db797c1b29abb06e90a0be290a8fd944fd711c7f
blob + 8e433d36a9774891a96bfabd88ce6621fbbabced
--- 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);
+	ss->nsaved = 0;
 	idle_baseline_seed(ss, &idx);
 
 	index_free(&idx);
blob - 40b33bfac7582be921b04b3c91cd3267464ec215
blob + f27afc00ad6efce70e76eb235576e2801e307357
--- src/mbox_search.c
+++ src/mbox_search.c
@@ -147,6 +147,9 @@ merge_keywords(int mode, const char *old_kws, const ch
 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 *);
+static void	 search_save_match(struct store_search_walk *, uint32_t,
+		    uint32_t);
+static int	 search_save_finish(struct store_session *, int);
 
 struct search_msg_ctx {
 	uint32_t	seqno;
@@ -156,6 +159,8 @@ struct search_msg_ctx {
 	int64_t		internaldate;
 	uint64_t	size;
 	uint64_t	modseq;		/* RFC 7162 SS3.1.5 MODSEQ search key */
+	const struct seq_range *saved;
+	uint32_t	nsaved;
 };
 
 static int
@@ -205,6 +210,8 @@ search_eval_leaf(const struct search_node *n, const st
 		return (m->uid >= n->seq_lo && m->uid <= n->seq_hi);
 	case SEARCH_OP_MODSEQ:
 		return (m->modseq >= (uint64_t)n->num);
+	case SEARCH_OP_SAVED:
+		return (seqset_contains(m->saved, m->nsaved, m->uid));
 	default:
 		return (0);
 	}
@@ -294,6 +301,7 @@ handle_mbox_search(struct imsg_mbox_search *req, struc
 		search_walk_abort(ss);
 	}
 	memset(sw, 0, sizeof(*sw));
+	sw->opts = req->return_opts;
 	evtimer_set(&sw->yield_ev, search_walk_yield, ss);
 
 	if (locked == -1) {
@@ -443,6 +451,8 @@ search_walk_step(struct store_session *ss)
 		m.seqno = view_seqno(ss, rec.uid, i);
 		m.uid = rec.uid;
 		m.modseq = rec.modseq;
+		m.saved = ss->saved;
+		m.nsaved = ss->nsaved;
 
 		if (locate_message_file(&ss->cur_snap, ss->mailbox_dir_fd,
 		    rec.basename, &size, suffix, sizeof(suffix)) == -1) {
@@ -483,6 +493,8 @@ search_walk_step(struct store_session *ss)
 		if (r == 1) {
 			struct imsg_mbox_search_match	 match;
 
+			if (sw->opts & SEARCH_RETURN_SAVE)
+				search_save_match(sw, i, m.uid);
 			memset(&match, 0, sizeof(match));
 			match.seqno = m.seqno;
 			match.uid = m.uid;
@@ -508,7 +520,8 @@ search_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 = !search_save_finish(ss, ok) ? MBOX_OP_ERR_NOTSAVED :
+	    ok ? MBOX_OP_OK : MBOX_OP_ERR_GENERIC;
 	result.count = sw->sent;
 	if (imsg_compose(&ss->iev.ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
 	    sizeof(result)) == -1)
@@ -517,7 +530,61 @@ search_walk_finish(struct store_session *ss, int ok)
 	sw->active = 0;
 }
 
+/* RFC 9051 SS6.4.4.1: matches on adjacent index lines share a range */
 static void
+search_save_match(struct store_search_walk *sw, uint32_t line, uint32_t uid)
+{
+	if (sw->sv_first == 0)
+		sw->sv_first = uid;
+	sw->sv_last = uid;
+	if (sw->nsv > 0 && sw->sv_line + 1 == line)
+		sw->sv[sw->nsv - 1].hi = uid;
+	else if (sw->nsv < SEQSET_MAX_RANGES) {
+		memset(&sw->sv[sw->nsv], 0, sizeof(sw->sv[0]));
+		sw->sv[sw->nsv].lo = sw->sv[sw->nsv].hi = uid;
+		sw->nsv++;
+	} else
+		sw->sv_full = 1;
+	sw->sv_line = line;
+}
+
+/* RFC 9051 SS6.4.4.1 and Table 4; 0 when refused, as NOTSAVED */
+static int
+search_save_finish(struct store_session *ss, int ok)
+{
+	struct store_search_walk	*sw = &ss->search;
+	uint32_t			 o = sw->opts, n = 0;
+
+	if (!(o & SEARCH_RETURN_SAVE))
+		return (1);
+	ss->nsaved = 0;
+	if (!ok)
+		return (1);
+	if ((o & (SEARCH_RETURN_MIN | SEARCH_RETURN_MAX)) &&
+	    !(o & (SEARCH_RETURN_ALL | SEARCH_RETURN_COUNT))) {
+		if (sw->sv_first == 0)
+			return (1);
+		memset(ss->saved, 0, 2 * sizeof(ss->saved[0]));
+		if (o & SEARCH_RETURN_MIN) {
+			ss->saved[n].lo = ss->saved[n].hi = sw->sv_first;
+			n++;
+		}
+		if ((o & SEARCH_RETURN_MAX) && (n == 0 ||
+		    sw->sv_last != sw->sv_first)) {
+			ss->saved[n].lo = ss->saved[n].hi = sw->sv_last;
+			n++;
+		}
+		ss->nsaved = n;
+		return (1);
+	}
+	if (sw->sv_full)
+		return (0);
+	memcpy(ss->saved, sw->sv, sw->nsv * sizeof(sw->sv[0]));
+	ss->nsaved = sw->nsv;
+	return (1);
+}
+
+static void
 search_walk_yield(int fd, short event, void *arg)
 {
 	struct store_session	*ss = arg;
blob - 4df03f1fb49ab72829be8f927238bad22709c4f4
blob + 7bb5732cb223f7c13c61e2c4dcfe219cd96454ab
--- src/mbox_store.c
+++ src/mbox_store.c
@@ -288,8 +288,8 @@ handle_mbox_store(struct imsg_mbox_store *req, const s
 	size_t			 nsteps = 0, k;
 	uint32_t		 nresolved, nvr, sent = 0;
 	uint64_t		 reported = 0;
-	int			 ok = 1, by_uid, synced;
-	int			 ghost;
+	int			 ok = 1, by_uid, synced, used, rc;
+	int			 ghost, limit = 0;
 
 	memset(&idx, 0, sizeof(idx));
 
@@ -314,15 +314,20 @@ handle_mbox_store(struct imsg_mbox_store *req, const s
 
 	synced = view_sync(ss, &idx, req->by_uid) == 0;
 	by_uid = req->by_uid;
-	view_resolve(ss, ranges, nranges, vr, &nvr, &by_uid);
+	if ((rc = saved_resolve(ss, ranges, nranges, vr, &nvr, &by_uid,
+	    &used)) != 0) {
+		limit = rc == -1;
+		ok = 0;
+		goto done;
+	}
 
 	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);
+	ghost = !req->by_uid && !used && view_names_ghost(ss, &idx,
+	    resolved, nresolved);
 
 	ok = store_apply(ss, req, &idx, resolved, nresolved, by_uid, synced,
 	    NULL, &steps, &nsteps);
@@ -377,7 +382,8 @@ done:
 
 	memset(&result, 0, sizeof(result));
 	/* RFC 2180 SS4.2.1-SS4.2.3 */
-	result.error = !ok ? MBOX_OP_ERR_GENERIC : ghost && !req->silent ?
+	result.error = !ok ? (limit ? MBOX_OP_ERR_LIMIT :
+	    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 */
@@ -405,11 +411,12 @@ handle_mbox_expunge(struct imsg_mbox_expunge *req,
 	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 expunged		*gone = NULL, *grown;
 	size_t			 in, out, ngone = 0, maxgone = 0, k;
-	uint32_t		 sent = 0, nresolved = 0;
+	uint32_t		 sent = 0, nresolved = 0, nvr;
 	uint64_t		 reported = 0;
-	int			 ok = 1, locked;
+	int			 ok = 1, locked, by_uid = 1, used, limit = 0;
 
 	memset(&idx, 0, sizeof(idx));
 
@@ -429,9 +436,16 @@ handle_mbox_expunge(struct imsg_mbox_expunge *req,
 	if (!req->silent)
 		(void)view_sync(ss, &idx, 1);
 
-	if (req->by_uid)
-		nresolved = seqset_resolve(ranges, nranges,
-		    index_max_uid(&idx), 0, resolved);
+	if (req->by_uid) {
+		if (saved_resolve(ss, ranges, nranges, vr, &nvr, &by_uid,
+		    &used) != 0) {
+			limit = 1;
+			ok = 0;
+			goto done;
+		}
+		nresolved = seqset_resolve(vr, nvr, index_max_uid(&idx), 0,
+		    resolved);
+	}
 
 	out = 0;
 	for (in = 0; in < idx.nlines; in++) {
@@ -529,7 +543,8 @@ done:
 	index_lock_release(&il);
 
 	memset(&result, 0, sizeof(result));
-	result.error = ok ? MBOX_OP_OK : MBOX_OP_ERR_GENERIC;
+	result.error = ok ? MBOX_OP_OK : limit ? MBOX_OP_ERR_LIMIT :
+	    MBOX_OP_ERR_GENERIC;
 	result.count = sent;
 	/* never a value the index does not hold */
 	result.highestmodseq = reported;
blob - 419823c5e4586ed3d978ce3412b312479e50daa4
blob + e6e6dcf7bba24a7986c91c5d690b1721c12eeb23
--- src/parent.c
+++ src/parent.c
@@ -5,6 +5,7 @@
  * Copyright (c) 2009 Jacek Masiulaniec <jacekm@dobremiasto.net>
  * Copyright (c) 2008 Gilles Chehade <gilles@poolp.org>
  * Copyright (c) 2008 Pierre-Yves Ritschard <pyr@openbsd.org>
+ * Copyright (c) 2008 Reyk Floeter <reyk@openbsd.org>
  *
  * This file's boot-time peer-wiring handshake -- setup_peer_send() and
  * setup_done_send(), and the IMSG_SETUP_PEER/IMSG_SETUP_DONE message
@@ -17,6 +18,12 @@
  * message protocol and handshake shape are smtpd's; see the inline
  * citations at this file's setup_peer_send() and setup_done_send().
  *
+ * send_keymgr_init()'s TLS key permission check -- fstat(2), then
+ * st_uid != 0 and st_mode & (S_IRWXU|S_IRWXG|S_IRWXO) & ~0740 -- reuses
+ * smtpd's ssl_load_key() check (usr.sbin/smtpd/ssl.c:112-140) verbatim
+ * for that mask and rejection order; only the fixed 0740 bound and the
+ * imsg delivery around it are this file's own.
+ *
  * 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.
blob - 7afa4a6273c203c4638a8f55ff478bdda2a2aeb0
blob + fa25214c72a54d8338fdec64ec0a4114adcfd77c
--- src/search_cmd.c
+++ src/search_cmd.c
@@ -135,7 +135,7 @@ parse_search_seqset(char **pp, struct search_parse_ctx
 		struct search_node	node;
 
 		memset(&node, 0, sizeof(node));
-		node.op = op;
+		node.op = SEQ_RANGE_SAVED(&ranges[i]) ? SEARCH_OP_SAVED : op;
 		node.seq_lo = ranges[i].lo;
 		node.seq_hi = ranges[i].hi;
 		node.lo_is_star = ranges[i].lo_is_star;
@@ -334,7 +334,7 @@ parse_search_key_inner(char **pp, struct search_parse_
 		return (0);
 	}
 
-	if (isdigit((unsigned char)*p) || *p == '*') {
+	if (isdigit((unsigned char)*p) || *p == '*' || *p == '$') {
 		if (parse_search_seqset(&p, ctx, SEARCH_OP_SEQSET, errmsg) ==
 		    -1)
 			return (-1);
@@ -741,7 +741,7 @@ search_program_parse(char *args, struct search_node *n
 	return (0);
 }
 
-/* RFC 9051 SS6.4.4 search-return-opts; SAVE is refused */
+/* RFC 9051 SS6.4.4 search-return-opts */
 int
 parse_search_return_opts(char **pp, uint32_t *opts_out, const char **errmsg)
 {
@@ -780,12 +780,9 @@ parse_search_return_opts(char **pp, uint32_t *opts_out
 			*opts_out |= SEARCH_RETURN_ALL;
 		else if (strcasecmp(word, "COUNT") == 0)
 			*opts_out |= SEARCH_RETURN_COUNT;
-		else if (strcasecmp(word, "SAVE") == 0) {
-			*errmsg = "SEARCH RETURN (SAVE), the \"$\" search "
-			    "result variable, is not supported in this "
-			    "pass";
-			return (-2);
-		} else {
+		else if (strcasecmp(word, "SAVE") == 0)
+			*opts_out |= SEARCH_RETURN_SAVE;
+		else {
 			*errmsg = "unsupported SEARCH RETURN option";
 			return (-1);
 		}
@@ -843,6 +840,15 @@ read_search_charset(char **pp, char *out, size_t outsi
 static void	 search_dispatch_finish(struct session *,
 		    const struct search_parse_result *, struct search_node *);
 
+/* RFC 9051 SS6.4.4.1: a SAVE answered NO here empties "$" too */
+static void
+search_saved_clear(struct session *s, uint32_t opts)
+{
+	if ((opts & SEARCH_RETURN_SAVE) && s->store_iev != NULL)
+		(void)send_mbox_request(s, IMSG_MBOX_SAVED_CLEAR, "SEARCH",
+		    "IMSG_MBOX_SAVED_CLEAR", NULL, 0, NULL, 0, 0);
+}
+
 /* RFC 9051 SS6.4.4 SEARCH; US-ASCII and UTF-8 only */
 int
 cmd_search(struct session *s, const char *tag, char *args)
@@ -882,10 +888,6 @@ search_dispatch(struct session *s, const char *tag, ch
 			session_reply(s, tag, "BAD", errmsg);
 			return (1);
 		}
-		if (rc == -2) {
-			session_reply(s, tag, "NO", errmsg);
-			return (1);
-		}
 		while (*p == ' ')
 			p++;
 	}
@@ -907,6 +909,7 @@ search_dispatch(struct session *s, const char *tag, ch
 
 		if (strcasecmp(charset, "US-ASCII") != 0 &&
 		    strcasecmp(charset, "UTF-8") != 0) {
+			search_saved_clear(s, return_opts);
 			/* RFC 9051 SS9 puts the charset list in parens */
 			session_reply(s, tag, "NO",
 			    "[BADCHARSET (US-ASCII UTF-8)] unsupported "
@@ -951,6 +954,7 @@ search_dispatch(struct session *s, const char *tag, ch
 
 		if (strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
 		    sizeof(s->pending_tag)) {
+			search_saved_clear(s, return_opts);
 			session_reply(s, tag, "NO",
 			    "[SERVERBUG] internal error");
 			return (1);
@@ -972,6 +976,7 @@ search_dispatch_finish(struct session *s,
 		return;
 	}
 	if (res->rc == -2) {
+		search_saved_clear(s, s->search_return_opts);
 		session_reply(s, s->pending_tag, "NO", res->errmsg);
 		s->state = SESSION_SELECTED;
 		return;
@@ -992,11 +997,13 @@ search_dispatch_finish(struct session *s,
 		memset(&req, 0, sizeof(req));
 		req.nnodes = res->nnodes;
 		req.poollen = res->poollen;
+		req.return_opts = s->search_return_opts;
 		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,
 		    sizeof(struct search_node))) {
+			search_saved_clear(s, s->search_return_opts);
 			session_reply(s, s->pending_tag, "NO",
 			    "[SERVERBUG] internal error");
 			s->state = SESSION_SELECTED;
@@ -1058,8 +1065,16 @@ session_finish_search(struct session *s, struct imsg_m
 		session_reply(s, s->pending_tag, "NO", IMAP_BUSY_TEXT);
 		goto cleanup;
 	}
+	if (res->error == MBOX_OP_ERR_NOTSAVED) {
+		session_reply(s, s->pending_tag, "NO",
+		    "[NOTSAVED] too many results to save");
+		goto cleanup;
+	}
 	if (res->error != MBOX_OP_OK || s->search_alloc_failed)
 		goto fail;
+	/* RFC 9051 SS6.4.4: SAVE alone suppresses ESEARCH */
+	if (s->search_return_opts == SEARCH_RETURN_SAVE)
+		goto done;
 
 	bufsize = SEARCH_RESP_PREFIX_MAX +
 	    (size_t)s->search_nmatches * SEARCH_ALL_PER_MATCH + 1;
@@ -1128,6 +1143,7 @@ session_finish_search(struct session *s, struct imsg_m
 	session_write(s, buf, len);
 	free(buf);
 
+done:
 	/* 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");
blob - e861c44b599d3f715f3058d691d8bba81707ce65
blob + 39c29d5483acbef9f5451c27c58821b90f75bc4e
--- src/store.c
+++ src/store.c
@@ -262,11 +262,19 @@ static void
 store_attach(uint32_t id, int fd)
 {
 	struct store_session	*ss;
+	int			 flags;
 
 	if (fd == -1) {
 		log_warnx("session %u: IMSG_SETUP_PEER carried no fd", id);
 		return;
 	}
+	/* a listener that stops reading must not stop the account */
+	if ((flags = fcntl(fd, F_GETFL)) == -1 ||
+	    fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1) {
+		log_warn("session %u: fcntl O_NONBLOCK", id);
+		close(fd);
+		return;
+	}
 	TAILQ_FOREACH(ss, &sessions, entry) {
 		if (ss->id == id)
 			break;
@@ -868,6 +876,10 @@ deferred_answer_busy_for(uint32_t type, struct store_s
 	size_t				 len = 0;
 	uint32_t			 rtype = 0;
 
+	/* RFC 9051 SS6.4.4.1: a SAVE answered NO empties "$" */
+	if (type == IMSG_MBOX_SEARCH &&
+	    (ss->deferred.req.search.return_opts & SEARCH_RETURN_SAVE))
+		ss->nsaved = 0;
 	switch (type) {
 	case IMSG_MBOX_STORE:
 	case IMSG_MBOX_EXPUNGE:
@@ -1311,6 +1323,13 @@ store_dispatch(int fd, short event, void *arg)
 			free(nodes);
 			break;
 		}
+		case IMSG_MBOX_SAVED_CLEAR:
+			if (imsg_get_len(&imsg) != 0) {
+				log_warnx("bad IMSG_MBOX_SAVED_CLEAR");
+				break;
+			}
+			ss->nsaved = 0;
+			break;
 		case IMSG_MBOX_STATUS: {
 			struct imsg_mbox_status	 req;
 
blob - 6ea1823a6c6737cef6a9d3176be27be5ab7ac21d
blob + 17234463d2693ea76eb2ecc26bc1cd5a72c89042
--- src/store_cmd.c
+++ src/store_cmd.c
@@ -801,6 +801,10 @@ session_finish_copy_or_move(struct session *s,
 		else if (!s->copy_alloc_failed &&
 		    res->error == MBOX_OP_ERR_BUSY)
 			snprintf(text, sizeof(text), "%s", IMAP_BUSY_TEXT);
+		else if (!s->copy_alloc_failed &&
+		    res->error == MBOX_OP_ERR_LIMIT)
+			snprintf(text, sizeof(text), "[LIMIT] %s failed",
+			    cmdname);
 		else
 			snprintf(text, sizeof(text), "%s failed", cmdname);
 		session_reply(s, s->pending_tag, "NO", text);
blob - 48f76022adc5e6e524a657288cc45ed786e32ccd
blob + 256654b5b1046de87793173ceb0b869e49e58304
--- src/store_internal.h
+++ src/store_internal.h
@@ -71,6 +71,7 @@ struct store_fetch_walk {
 	int			 wait_parser;	/* paused until one comes */
 	int			 no_parser;	/* none to be had: go without */
 	int			 ghost;
+	int			 limit;
 	uint64_t		 seen_modseq;
 };
 
@@ -93,6 +94,13 @@ struct store_search_walk {
 	uint32_t		 sent;
 	uint32_t		 asked;
 	uint32_t		 walked;
+	uint32_t		 opts;
+	struct seq_range	 sv[SEQSET_MAX_RANGES];
+	uint32_t		 nsv;
+	uint32_t		 sv_line;
+	uint32_t		 sv_first;
+	uint32_t		 sv_last;
+	int			 sv_full;
 };
 
 /* two stat(2) calls, no lock: "." and "new" */
@@ -194,6 +202,10 @@ struct store_session {
 	struct store_idle_probe		 idle_probe;
 	struct store_idle_baseline	 idle_baseline;
 	struct store_append		 append;
+
+	/* RFC 9051 SS6.4.4.1 "$", as UID ranges */
+	struct seq_range		 saved[SEQSET_MAX_RANGES];
+	uint32_t			 nsaved;
 };
 
 extern uint32_t	 session_id;
@@ -377,6 +389,9 @@ void	 idle_baseline_seed(struct store_session *, const
 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 *);
+int	 saved_resolve(const struct store_session *,
+		    const struct seq_range *, uint32_t, struct seq_range *,
+		    uint32_t *, int *, 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 *,
blob - 9a98af9d87a28cfac11ea767354ca2ceeecbbafd
blob + a7dcc836654c3556913a0bde6116b715907c581d
--- src/store_ipc.c
+++ src/store_ipc.c
@@ -974,7 +974,8 @@ session_handle_mbox_result(struct session *s, struct i
 		}
 		snprintf(text, sizeof(text), "%s%s failed",
 		    res->error == MBOX_OP_ERR_EXPUNGEISSUED ?
-		    "[EXPUNGEISSUED] " : "", cmdname);
+		    "[EXPUNGEISSUED] " : res->error == MBOX_OP_ERR_LIMIT ?
+		    "[LIMIT] " : "", cmdname);
 		session_reply(s, s->pending_tag, "NO", text);
 		return;
 	}