commit 2bd90b642f5b0f2eee6ef497269e1e983b8966c5 from: David Williams date: Thu Oct 1 16:20:12 2026 UTC Add SEARCH SAVE; fix FETCH, queue, login timing, slow clients; man pages Change 1 of 6: Find where FETCH's modifiers begin by parsing, not by counting RFC 9051 section 9 lets a single message data item be sent without parentheses, and a HEADER.FIELDS item holds a space inside its brackets. RFC 4466 section 3 puts the optional fetch-modifiers after the item or list, separated by one SP. imapd looked for that boundary in split_trailing_modifiers(), which cut an unparenthesized item at its first space and counted parentheses in a list without skipping quoted strings. So "FETCH 1 BODY.PEEK[HEADER.FIELDS (Subject)]" was answered BAD, and so was any list naming a field such as "a)b", which RFC 5322 section 3.6.8 allows in a field name. fetch_dispatch() now takes the first token from fetch_att_tok(), the scanner parse_fetch_atts() already uses, which skips quoted strings and counts brackets and parentheses together. Whatever follows is the modifier list. split_trailing_modifiers() is gone. Change 2 of 6: Run queued commands after a refused literal, and keep both literals A command that arrives while another is in flight is queued, and imapd runs the queue in order, as RFC 9051 section 5.5 requires when commands could affect each other. A queued command whose non-synchronizing literal was still being gathered could be refused there: for a NUL, which RFC 9051 section 9's CHAR8 excludes, or for passing the 8191-octet command cap, which a correct client can reach with two 4096-octet literals. The listener then discarded the rest of that command, as RFC 7888 section 3 requires, but did not run the queue. Commands queued before the refused one waited for the next store reply, and a command sent after it ran first. If no later command used the store, they were never answered. The loop now runs the queue once a refused command's last line has been skipped, as it already did after a gathered command's last line. A queued command with two non-synchronizing literals also lost the text between them: the line after the first literal was copied into the command buffer without moving its length, so the second literal overwrote it, and "SEARCH TEXT {5+} ... TEXT {5+} ..." pipelined behind another command was answered BAD. The length now moves past the line. Change 3 of 6: Support SEARCH RETURN (SAVE) and the "$" marker RFC 9051 folds SEARCHRES (RFC 5182) into IMAP4rev2, so a client may send SEARCH RETURN (SAVE) and then use "$" wherever a sequence set goes, without asking for a capability. imapd refused SAVE with NO and "$" as an invalid sequence set. The store now keeps "$" for each session, as UID ranges. A SAVE search records its matches as it walks the index, one range per run of adjacent index lines, so the ranges stay exact: new messages get higher UIDs and an expunge only removes, as RFC 9051 section 6.4.4.1 requires. MIN and MAX without ALL or COUNT save only those messages (Table 4). SAVE alone sends no ESEARCH (section 6.4.4). A result of more than 500 runs is refused with NO [NOTSAVED], and "$" is emptied (section 6.4.4.3), as it is after any SAVE answered NO, including the listener's own NO for an unsupported CHARSET and a SAVE that gives up waiting for the index lock. A successful SELECT or EXAMINE empties it. "$" is accepted as the last element of a sequence set, as the grammar allows, so "1,$" works. FETCH, STORE, COPY, MOVE and UID EXPUNGE append the saved UIDs to the rest of the set, and answer NO [LIMIT] when the two together pass 500 ranges. SEARCH matches "$" by UID, so it costs one node however large it is. In a SELECT's QRESYNC known-uids, "$" is the value from before the SELECT. A "$" naming a message another session expunged is not refused as RFC 2180 section 4.1.2 allows for message numbers: "$" is kept by UID. Change 4 of 6: Make a failed login cost the same whether or not the account exists A failed AUTHENTICATE for a name in the credentials file paid for crypt_checkpass(3) at that entry's bcrypt cost. For a name with no entry, auth.c passed a NULL hash, which libc fakes at cost 8, while "encrypt -b a", as imapduser uses it, picks a cost for the machine; it picked 9 on the test host. A failure for a missing name was answered in about half the time (35.8 against 65.8 ms measured), which told a client which names exist. RFC 9051 section 11.7 asks that a failing login not say whether the user name is the invalid part. cred_lookup() now reads the whole credentials file on every lookup, keeps the first valid match, and reports the highest bcrypt cost among valid entries. A name with no valid entry is checked against a well-formed dummy hash at that cost. An entry hashed at a lower cost than the file's highest can still be told apart by timing. An over-long line in the credentials file now refuses every login, not only logins for names that come after it. Change 5 of 6: Let a slow client be slow, without stalling its account A client whose socket stayed full for 5 seconds was cut off, logged in or not: session_write() gave up after one poll(2) of 5 seconds without progress. While the listener slept in that poll it read nothing from its store channel, which blocked at the store's end, so the account's store child slept in sendmsg(2) once a FETCH had queued more than the socket held, and every other session of the account waited with it. On the test host a second session's NOOP took 5.05 s while one session stopped reading a FETCH. The timeout is now 5 seconds before login and 30 minutes after, the floor RFC 9051 section 5.4 sets for an inactivity autologout. Before login a longer sleep would outlast the login grace timer, which cannot fire while the listener sleeps. The store sets O_NONBLOCK on each listener channel as it attaches one, as keymgr already does, so a listener that stops reading fills only its own queue. A session whose write fails is torn down at once, not when the client next sends. Change 6 of 6: Split imapd.conf(5) out of imapd(8), and correct the man pages imapd.8 had grown to 992 lines, about half of them the configuration grammar under FILES. Every base daemon it was compared with keeps that in a section 5 page: smtpd.conf(5), httpd.conf(5), ntpd.conf(5), ldapd.conf(5), relayd.conf(5), sshd_config(5). The directives now live in imapd.conf.5, laid out as httpd.conf.5 is, with an EXAMPLES section. imapd.8 is cut to 295 lines on the shape of ldapd.8, with the credentials file in an AUTHENTICATION section. The rc.d install steps, the Makefile's relink check and the reasoning behind each limit are gone from the pages; the install steps were already in README.md. The rewrite also corrects what the old page said against the code. A missing /etc/imapd.conf is fatal, not a default configuration (parse.y pushfile(), main.c config_load()). "listen on" requires "port". The configuration file is refused when group-executable or accessible by others, not only when writable (check_file_secrecy()). The parent accepts connections and starts listener and auth workers for each one, and the process that is not restarted when it exits is keymgr, not the listener or auth process (parent.c reap_child()). The Makefile installs both pages, and imapd-teardown removes imapd.conf.5. imapduser.8 cross-references the new page. A second pass checked every remaining claim in imapd.8, imapd.conf.5 and imapduser.8 against the code and the RFC texts. Wrong, and now corrected: - The credentials file rule. The page asked for root, group _imapauth, mode 0640 or stricter, which allowed modes the auth process cannot read (0600, 0400) and forbade files it accepts. It now states what cred_file_secure() checks: owned by root or _imapauth, not group writable or executable, not accessible by others, and readable by _imapauth. 0640 is what imapduser sets. - A kept subscription was cited to RFC 9051 section 6.3.8 as a requirement. It is a SHOULD NOT in section 6.3.7; renaming is in 6.3.6. - The command list omitted NOOP and LOGOUT, and did not say that LOGIN is always refused. - SIGHUP: sessions already connected keep their settings; new ones get the reloaded values, since each listener worker is given them once. - attachment max also bounds SEARCH, so a BODY or TEXT search that reaches a larger message fails with NO. - imapduser rewrites the credentials file's ownership only when it creates the file or after -d, not on every write; -s is not ignored by -d; re-running imapduser -a does not fix the group. - smtpd.conf(5)'s directive is "smtp max-message-size". Added: the credentials file's line rules (comments, skipped lines, bcrypt only, uid and gid not 0, maildir relative without "." or "..", first valid line wins); the on-disk layout, with imapd.index and the lock and temporary files beside it; that a TLS key failing its check is not loaded; imapduser's username characters, id selection from 2000, and maildir mode. CAVEATS now lists RENAME INBOX refused, LIST return options and multiple patterns refused, FETCH BINARY items answered BAD, part-number HEADER, TEXT and MIME sections and the RFC822 items not returned, the response code each command gives an invalid UTF-8 name, and the reserved mailbox names. STANDARDS adds RFC 4616 and RFC 8314. imapduser.8's credentials line rendered with a space after each colon and now uses Ql. 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[]`/`BODY.PEEK[]`), `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[]`/`BODY.PEEK[]`), `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, °raded, &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[] fetch, see imapd(8). Must be +# or a MIME part-addressed BODY[] 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 #include -#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 * Copyright (c) 2008 Gilles Chehade * Copyright (c) 2008 Pierre-Yves Ritschard + * Copyright (c) 2008 Reyk Floeter * * 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 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; }