Commit Diff


commit - 2bd90b642f5b0f2eee6ef497269e1e983b8966c5
commit + da1136e4275efd1152585f31dab183ab5089370d
blob - 56d10fc49bb84b6df9a0a994afdcfd4b8338a295
blob + 50638422e9585c4f4108abfb0fa57a2695ca3baa
--- 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.2.0 Pre-release, actively developed. Not a port. See [Getting the source](#getting-the-source) below for the repository.
+**Status:** 0.2.1 Pre-release, actively developed. Not a port. See [Getting the source](#getting-the-source) below for the repository.
 
 ## What it is
 
@@ -20,7 +20,7 @@ ACL/shared-mailbox support is deliberately left out.
 
 ## Requirements
 
-OpenBSD only. This depends on `<imsg.h>`, `pledge(2)`, `unveil(2)`, and libutil's `imsgbuf_*` API, none of which exist outside OpenBSD. Links against libevent, libtls/libssl/libcrypto, and libutil, all base-system libraries (see `src/Makefile`).
+OpenBSD only. This depends on `<imsg.h>`, `pledge(2)`, `unveil(2)`, and libutil's `imsgbuf_*` API, none of which exist outside OpenBSD. Links against libevent, libtls/libssl/libcrypto, and libutil, all base-system libraries.
 
 **imapd requires OpenBSD -current, and will not build on 7.9.**. Building on the most recent stable release is a goal for 1.0.
 
@@ -84,8 +84,6 @@ doas rcctl start imapd
 
 ## Known limitations
 
-Beyond the deliberate protocol-scope decisions covered in `imapd(8)`'s CAVEATS:
-
 - 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.conf(5)`.
blob - b2ac6c85f01332165cfe0472ac57af93f72ca3de
blob + 7802c5fd432765b12ff918d2c1566e1fdcd5a7db
--- contrib/imapduser
+++ contrib/imapduser
@@ -22,7 +22,7 @@
 #   imapduser -a [-c credentials-file] [-s spool-root] [-u uid] [-g gid] username
 #   imapduser -d [-c credentials-file] username
 #
-# -a adds a new mailbox account.
+# -a adds a new mailbox account and prints its smtpd(8) userbase line.
 #
 # -d removes a mailbox account's credentials-file line ONLY.
 #
@@ -167,6 +167,8 @@ do_add() {
 	echo "${USERNAME}:${HASH}:${NEWUID}:${NEWGID}:${MAILDIR}" >> "$CRED_FILE"
 
 	echo "${0##*/}: added $USERNAME (uid $NEWUID, gid $NEWGID, maildir $MAILDIR_PATH) to $CRED_FILE" 1>&2
+	echo "${0##*/}: its smtpd(8) userbase line follows; see imapd.conf(5)" 1>&2
+	printf '%s\t%s:%s:%s\n' "$USERNAME" "$NEWUID" "$NEWGID" "$MAILDIR_PATH"
 }
 
 do_delete() {
@@ -194,6 +196,8 @@ do_delete() {
 	echo "${0##*/}: removed $USERNAME from $CRED_FILE ($SPOOL_ROOT/$MAILDIR_FIELD" \
 	    "was left untouched -- remove it by hand if you also want the" \
 	    "mail data gone)" 1>&2
+	echo "${0##*/}: remove $USERNAME's line from smtpd(8)'s userbase" \
+	    "table too, or mail for it is still delivered" 1>&2
 }
 
 case "$MODE" in
blob - cba2e724253c971c0d0706a3c356dd098acd931c
blob + 2d6a88c2791ae4c79b6b30ed63f9c474427d2574
--- contrib/imapduser.8
+++ contrib/imapduser.8
@@ -2,7 +2,7 @@
 .\"
 .\" Written for the OpenIMAPD project. Public domain / no rights reserved.
 .\"
-.Dd $Mdocdate: September 30 2026 $
+.Dd $Mdocdate: October 3 2026 $
 .Dt IMAPDUSER 8
 .Os
 .Sh NAME
@@ -78,6 +78,10 @@ mode 0700,
 and appends a line to the credentials file, prompting for a password
 via
 .Xr encrypt 1 .
+It then prints the account's line for a
+.Xr smtpd 8
+userbase table on standard output; see
+.Xr imapd.conf 5 .
 If
 .Fl u
 and
@@ -102,6 +106,10 @@ 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.
+The account's line in a
+.Xr smtpd 8
+userbase table must also be removed by hand; until it is, mail for the
+account is still delivered.
 .Fl u
 and
 .Fl g
@@ -181,7 +189,9 @@ Default spool root; see
 .Xr encrypt 1 ,
 .Xr crypt_checkpass 3 ,
 .Xr imapd.conf 5 ,
-.Xr imapd 8
+.Xr smtpd.conf 5 ,
+.Xr imapd 8 ,
+.Xr smtpd 8
 .Sh HISTORY
 .Nm
 was written for the OpenIMAPD project.
blob - c89f711d6103856cce71d763e5eb1631cb7d9d37
blob + 21da97eeae47457e4c695fb195895f59f99d0393
--- src/auth.c
+++ src/auth.c
@@ -190,8 +190,14 @@ auth_dispatch(int fd, short event, void *arg)
 	ssize_t		 n;
 
 	if (event & EV_WRITE) {
-		if (imsgbuf_write(&iev->ibuf) == -1)
+		if (imsgbuf_write(&iev->ibuf) == -1) {
+			if (errno == EPIPE) {
+				log_debug("auth-worker: listener closed "
+				    "channel, exiting");
+				exit(0);
+			}
 			fatal("imsgbuf_write");
+		}
 	}
 
 	if (event & EV_READ) {
blob - 8bd071198b174dfe4b1d2aaa8c6426cdfbae4646
blob + 4e44803fb3a69f2c0741294274aac08ab9941e6a
--- 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 30 2026 $
+.Dd $Mdocdate: October 3 2026 $
 .Dt IMAPD 8
 .Os
 .Sh NAME
@@ -20,7 +20,12 @@ is an Internet Message Access Protocol
 .Pq IMAP
 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.
+It serves mail stored in maildir format, delivered by
+.Xr smtpd 8
+as described in
+.Xr imapd.conf 5 ,
+and rewrites each new message with the CRLF line endings RFC 5322
+requires before any client sees it.
 .Pp
 By default,
 .Nm
@@ -29,90 +34,30 @@ listens on port 143, where clients may upgrade to TLS 
 and on port 993 with implicit TLS, as recommended by RFC 8314.
 Authentication is
 .Li AUTH=PLAIN
-only, and is refused until TLS is established;
-.Li LOGIN
-is always refused.
-The listening addresses and ports are set in
+only, and is refused until TLS is established.
+Users are not system accounts; they are listed in a credentials file,
+described in
 .Xr imapd.conf 5 .
 .Pp
 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 ,
-.Li ENABLE ,
-.Li SELECT ,
-.Li EXAMINE ,
-.Li CREATE ,
-.Li DELETE ,
-.Li RENAME ,
-.Li SUBSCRIBE ,
-.Li UNSUBSCRIBE ,
-.Li LIST ,
-.Li LSUB ,
-.Li NAMESPACE ,
-.Li STATUS ,
-.Li FETCH ,
-.Li STORE ,
-.Li SEARCH ,
-.Li APPEND ,
-.Li COPY ,
-.Li MOVE ,
-.Li EXPUNGE ,
-.Li UNSELECT ,
-.Li CLOSE ,
-.Li UID
-and
-.Li IDLE .
+is the account's maildir, and each other mailbox is a maildir in a
+subdirectory of it with the mailbox's name.
 .Pp
 .Nm
-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
+It does not detach from its controlling terminal, logs to
 .Xr syslogd 8
 with the
 .Dv LOG_MAIL
-facility.
+facility, and rereads its configuration file on
+.Dv SIGHUP .
 .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
@@ -139,62 +84,6 @@ Print the version and exit.
 .It Fl v
 Produce more verbose logging.
 .El
-.Sh AUTHENTICATION
-Users of
-.Nm
-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
-.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
-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
-.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/ssl/private/imapd.keyXX" -compact
 .It Pa /etc/imapd.conf
@@ -214,27 +103,20 @@ and
 .Pa imapd.index.tmp
 beside it.
 .It Pa imapd.subscriptions
-Subscribed mailboxes, one per line, at the root of each maildir, with
+Subscribed mailboxes, 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.
+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 SEE ALSO
-.Xr crypt_checkpass 3 ,
 .Xr imapd.conf 5 ,
 .Xr imapduser 8 ,
-.Xr rc.d 8 ,
 .Xr smtpd 8 ,
 .Xr syslogd 8
 .Sh STANDARDS
@@ -254,36 +136,6 @@ Example configuration file.
 .%R RFC 7162
 .%T IMAP Extensions: Quick Flag Changes Resynchronization (CONDSTORE) and Quick Mailbox Resynchronization (QRESYNC)
 .Re
-.Pp
-.Rs
-.%A F. Yergeau
-.%D November 2003
-.%R RFC 3629
-.%T UTF-8, a transformation format of ISO 10646
-.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
@@ -293,103 +145,34 @@ privilege-separation tradition of
 .Xr smtpd 8 .
 .Sh CAVEATS
 This implementation is under active development.
+Commands, options and
+.Li FETCH
+items it does not support are refused with
+.Li BAD
+or
+.Li NO .
 .Pp
 .Li IDLE
-is served by polling the selected mailbox, so new mail is reported
-within one
+polls the selected mailbox, so new mail is reported within one
 .Ic idle poll
-interval rather than at once.
+interval.
 .Pp
-All sessions of one account are served by one process, one command
-at a time.
-.Li FETCH
-and
-.Li SEARCH
-give way to the account's other sessions as they go;
+One process serves all sessions of an account, so a
 .Li STORE ,
 .Li EXPUNGE ,
 .Li COPY
-and
+or
 .Li MOVE
-do not, so one of these over a very large mailbox delays every other
-session of the same account until it completes.
+over a very large mailbox delays the account's other sessions.
 .Pp
-If the process holding the TLS private key exits, it is not restarted,
-and new TLS handshakes fail until
+If the process holding the TLS private key exits, 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
+.Pa new ,
+.Pa cur
+and the maildir files under
 .Sx FILES
-that are kept in a maildir, cannot be used as mailbox names.
+cannot be used as mailbox names.
blob - f46a691dfe5110714c6b3d4809ba96583710a330
blob + 7cbe25a59044bfe29bab71adac0297c25f905621
--- src/imapd.conf.5
+++ src/imapd.conf.5
@@ -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 30 2026 $
+.Dd $Mdocdate: October 3 2026 $
 .Dt IMAPD.CONF 5
 .Os
 .Sh NAME
@@ -110,6 +110,10 @@ will read to produce its
 or one of its MIME parts.
 A larger message is treated as one whose structure cannot be produced;
 it is not truncated.
+A
+.Li FETCH
+of either leaves that item out of its response and ends with
+.Li NO .
 The same limit bounds what
 .Li SEARCH
 reads of a message, so a search on
@@ -118,9 +122,35 @@ or
 .Li TEXT
 that reaches a larger message fails with
 .Li NO .
+.Pp
+Mail delivered by
+.Xr smtpd 8
+is not bound by
+.Ic append max .
+Keep the
+.Ic smtp max-message-size
+of
+.Xr smtpd.conf 5
+below
+.Ic attachment max ,
+with room for the headers
+.Xr smtpd 8
+adds to each message and for the carriage return
+.Xr imapd 8
+adds before each line feed, neither of which its limit counts.
+A maildir brought from elsewhere may already hold larger messages.
+To list them, for the default
+.Ic spool
+and
+.Ic attachment max :
+.Pp
+.Dl # find /var/mail/imapd -type f -size +41943040c
+.Pp
 Must be between 12000 and 1073741824 and may not be less than
 .Ic append max .
-The default is 41943040 (40 MiB).
+The default is 41943040 (40 MiB), which leaves room above the
+.Ic smtp max-message-size
+default of 35M.
 .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
@@ -148,7 +178,7 @@ The default is 256, which fits the
 defaults.
 .It Ic credentials Ar path
 The credentials file; see
-.Xr imapd 8 .
+.Sx Credentials file .
 The default is
 .Pa /etc/imapd/credentials .
 .It Ic idle poll Ar seconds
@@ -282,11 +312,143 @@ A key that fails this check is not loaded, and a warni
 The default is
 .Pa /etc/ssl/private/imapd.key .
 .El
+.Ss Credentials file
+Users of
+.Xr imapd 8
+are not system accounts.
+They are listed in the file named by
+.Ic 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
+.Ar gid
+are the user and group the account's mailbox process runs as, and
+.Ar maildir
+is the account's maildir, relative to
+.Ic spool .
+Entries that share all three name the same account.
+.Pp
+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
+.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.
+.Ss Mail delivery
+.Xr imapd 8
+does not deliver mail.
+.Xr smtpd 8
+delivers it with its
+.Cm maildir
+method, configured in
+.Xr smtpd.conf 5 .
+Accounts are not system users, so the action needs a
+.Cm userbase
+table, in the format of
+.Xr table 5 ,
+mapping each username to the
+.Ar uid ,
+.Ar gid
+and maildir of its credentials line, the maildir as an absolute path:
+.Bd -literal -offset indent
+joe	2000:2000:/var/mail/imapd/joe
+.Ed
+.Pp
+.Xr imapduser 8
+prints this line for each account it adds.
+The
+.Ar uid
+and
+.Ar gid
+must be those of the credentials line:
+.Xr smtpd 8
+writes each message as that user, mode 0600, and
+.Xr imapd 8
+cannot read a message written as any other user.
+.Pp
+A configuration that delivers mail for one domain to the accounts in
+the table:
+.Bd -literal -offset indent
+table imapd_users file:/etc/mail/imapd_users
+
+action "imapd" maildir "%{user.directory}" userbase <imapd_users>
+
+match from any for domain "example.org" action "imapd"
+.Ed
+.Pp
+The
+.Cm maildir
+path must be given.
+Without it, mail goes to
+.Pa ~/Maildir ,
+a subdirectory of the account's maildir, which
+.Xr imapd 8
+shows as a mailbox named
+.Dq Maildir .
+The
+.Ic match
+rule must come before any other rule that matches the same recipients.
+Mail from other hosts must arrive on a listener without the
+.Cm auth
+option, or with
+.Cm auth-optional
+instead.
+After editing the table, run:
+.Pp
+.Dl # smtpctl update table imapd_users
+.Pp
+With the
+.Cm junk
+option, spam is delivered to a mailbox named
+.Dq \&.Junk .
+.Pp
+Mail for a subaddress, such as
+.Ql joe+lists ,
+is delivered to the mailbox named
+.Dq \&.lists
+if it exists, and to INBOX otherwise.
+To have such mail filed, create the mailbox with the leading dot.
 .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 /etc/imapd/credentials
+Default credentials file.
 .It Pa /usr/local/share/examples/imapd/imapd.conf
 Example configuration file.
 .El
@@ -305,10 +467,15 @@ tls key "/etc/ssl/private/mail.example.com.key"
 account sessions 16
 .Ed
 .Sh SEE ALSO
+.Xr crypt_checkpass 3 ,
 .Xr login.conf 5 ,
 .Xr smtpd.conf 5 ,
 .Xr sshd_config 5 ,
+.Xr table 5 ,
 .Xr imapd 8 ,
+.Xr imapduser 8 ,
+.Xr smtpctl 8 ,
+.Xr smtpd 8 ,
 .Xr sysctl 8
 .Sh HISTORY
 .Nm
blob - 1399571babfb3228b192fa93d49b66a9bbe29e86
blob + 178a56bd0c968d14e88ac309af53431fb69dbae2
--- src/imapd.h
+++ src/imapd.h
@@ -28,7 +28,7 @@
 #include <imsg.h>
 #include <stdint.h>
 
-#define IMAPD_VERSION	"0.2.0"
+#define IMAPD_VERSION	"0.2.1"
 
 #define IMAPD_USER	"_imapd"
 #define IMAPD_AUTH_USER	"_imapauth"
blob - 2a1b2706eaad3081c7d655ba21ff1fcba647e5ee
blob + 2c32ea030166b5b383da1b7d1c3561933e8c9407
--- src/index.c
+++ src/index.c
@@ -41,6 +41,8 @@
 /* RFC 7162 SS7 */
 #define INDEX_MODSEQ_MAX	INT64_MAX
 
+#define CRLF_BLOCK		65536
+
 int
 index_field_valid(const char *field)
 {
@@ -788,12 +790,100 @@ uidvalidity_next(void)
 	return (val);
 }
 
+/* RFC 5322 SS2.3: CR and LF only as CRLF; mail.maildir(8) writes LF alone */
 static int
+new_to_crlf(int dfd, const char *name)
+{
+	struct stat	 st;
+	char		 src[PATH_MAX], tmp[PATH_MAX];
+	char		*in = NULL, *out;
+	size_t		 i, o, w;
+	ssize_t		 n;
+	int		 ifd = -1, ofd = -1, made = 0, cr = 0, changed = 0;
+	int		 rc = -1;
+
+	if (snprintf(src, sizeof(src), "new/%s", name) >= (int)sizeof(src) ||
+	    snprintf(tmp, sizeof(tmp), "tmp/%s.crlf", name) >=
+	    (int)sizeof(tmp)) {
+		log_warnx("session %u: new/%s: name too long for CRLF, "
+		    "indexed as it is", session_id, name);
+		return (-1);
+	}
+	ifd = openat(dfd, src, O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
+	if (ifd == -1 || fstat(ifd, &st) == -1)
+		goto fail;
+	if (!S_ISREG(st.st_mode)) {
+		log_warnx("session %u: %s: not a regular file, indexed as "
+		    "it is", session_id, src);
+		goto done;
+	}
+	if ((in = malloc(3 * CRLF_BLOCK)) == NULL)
+		goto fail;
+	out = in + CRLF_BLOCK;
+	ofd = openat(dfd, tmp, O_WRONLY | O_CREAT | O_EXCL, 0600);
+	if (ofd == -1 && errno == EEXIST && unlinkat(dfd, tmp, 0) == 0)
+		ofd = openat(dfd, tmp, O_WRONLY | O_CREAT | O_EXCL, 0600);
+	if (ofd == -1)
+		goto fail;
+	made = 1;
+
+	for (;;) {
+		if ((n = read(ifd, in, CRLF_BLOCK)) == -1) {
+			if (errno == EINTR)
+				continue;
+			goto fail;
+		}
+		if (n == 0)
+			break;
+		for (i = 0, o = 0; i < (size_t)n; i++) {
+			if (in[i] == '\n' && !cr) {
+				out[o++] = '\r';
+				changed = 1;
+			}
+			cr = in[i] == '\r';
+			out[o++] = in[i];
+		}
+		for (w = 0; w < o; w += (size_t)n) {
+			while ((n = write(ofd, out + w, o - w)) == -1 &&
+			    errno == EINTR)
+				;
+			if (n == -1)
+				goto fail;
+		}
+	}
+
+	if (changed) {
+		if (fsync(ofd) == -1)
+			goto fail;
+		n = close(ofd);
+		ofd = -1;
+		if (n == -1 || renameat(dfd, tmp, dfd, src) == -1)
+			goto fail;
+		made = 0;
+		log_debug("session %u: %s: bare LF made CRLF", session_id, src);
+	}
+	rc = changed;
+	goto done;
+
+fail:
+	log_warn("session %u: %s to CRLF, indexed as it is", session_id, src);
+done:
+	if (ifd != -1)
+		close(ifd);
+	if (ofd != -1)
+		close(ofd);
+	if (made)
+		unlinkat(dfd, tmp, 0);
+	free(in);
+	return (rc);
+}
+
+static int
 index_scan_new(int dfd, struct mbox_index *idx, int mutate)
 {
 	DIR		*dp;
 	struct dirent	*de;
-	int		 added = 0;
+	int		 added = 0, converted = 0;
 
 	{
 		int	newfd;
@@ -830,6 +920,9 @@ index_scan_new(int dfd, struct mbox_index *idx, int mu
 			closedir(dp);
 			return (1);
 		}
+		/* RFC 9051 SS2.3.1.1: convert before the UID is given */
+		if (new_to_crlf(dfd, de->d_name) == 1)
+			converted = 1;
 		if (index_append(idx, idx->uidnext, de->d_name) == -1) {
 			closedir(dp);
 			index_free(idx);
@@ -838,6 +931,8 @@ index_scan_new(int dfd, struct mbox_index *idx, int mu
 		idx->uidnext++;
 		added = 1;
 	}
+	if (converted && fsync(dirfd(dp)) == -1)
+		log_warn("session %u: fsync new", session_id);
 	closedir(dp);
 	return (added);
 }
blob - 0741e76249dd96e3781a2f87114e35397b1d0fcd
blob + 72cd56b418ec77a0a000346f1b2af0a4aa3ff452
--- src/listener.c
+++ src/listener.c
@@ -1691,8 +1691,13 @@ listener_dispatch_auth(int fd, short event, void *arg)
 			}
 
 			auth_granted = 1;
-			s->state = SESSION_STORE_PENDING;
 			setproctitle("session %u [authenticated]", s->id);
+			if (s->state != SESSION_AUTHENTICATING) {
+				log_debug("session %u: auth result after the "
+				    "store peer", s->id);
+				break;
+			}
+			s->state = SESSION_STORE_PENDING;
 			break;
 		}
 		default:
blob - a7f71c2b4615291f9471cd67ab552d19672ed753
blob + b31e1c0eec8e826c1f842dc5fa52f2781378025e
--- src/mime.c
+++ src/mime.c
@@ -352,7 +352,7 @@ read_body_from_fd(int fd, const char *basename, int te
     size_t maxlen, const char *label, char **buf_out,
     uint32_t *len_out)
 {
-	char		*readbuf;
+	char		*readbuf = NULL;
 	size_t		 readbuf_size;
 	struct stat	 st;
 	ssize_t		 n, total = 0;
@@ -363,9 +363,11 @@ read_body_from_fd(int fd, const char *basename, int te
 
 	/* sized to the message, not the configured ceiling */
 	readbuf_size = maxlen + 1;
-	if (fstat(fd, &st) == 0 && S_ISREG(st.st_mode) && st.st_size >= 0 &&
-	    (uint64_t)st.st_size < (uint64_t)maxlen)
+	if (fstat(fd, &st) == 0 && S_ISREG(st.st_mode) && st.st_size >= 0) {
+		if ((uint64_t)st.st_size > (uint64_t)maxlen)
+			goto toolarge;
 		readbuf_size = (size_t)st.st_size + 1;
+	}
 
 	if ((readbuf = malloc(readbuf_size)) == NULL) {
 		log_warn("session %u: malloc message body readbuf (%s)",
@@ -389,12 +391,8 @@ read_body_from_fd(int fd, const char *basename, int te
 		total += n;
 	}
 
-	if (total > (ssize_t)maxlen) {
-		log_warnx("session %u: message %s exceeds %zu bytes, "
-		    "%s skipped", session_id, basename, maxlen, label);
-		free(readbuf);
-		return (-1);
-	}
+	if (total > (ssize_t)maxlen)
+		goto toolarge;
 
 	for (i = 0; i < (size_t)total; i++) {
 		if (readbuf[i] == '\0') {
@@ -431,6 +429,12 @@ read_body_from_fd(int fd, const char *basename, int te
 	}
 	free(readbuf);
 	return (0);
+
+toolarge:
+	log_warnx("session %u: message %s exceeds %zu bytes, %s skipped",
+	    session_id, basename, maxlen, label);
+	free(readbuf);
+	return (-1);
 }
 
 /* RFC 9051 SS6.4.5.1: ASCII case-insensitive */
blob - e6e6dcf7bba24a7986c91c5d690b1721c12eeb23
blob + e6ffc62566f5ef03659e82ca925bbb36182680d5
--- src/parent.c
+++ src/parent.c
@@ -143,6 +143,7 @@ static int	 setup_peer_send(struct imsgev *, struct im
 		    const char *, uint32_t);
 static void	 setup_done_send(struct imsgev *);
 static void	 parent_dispatch_child(int, short, void *);
+static void	 child_drop(struct child *);
 static struct open_session *open_session_find(uint32_t);
 static unsigned int count_startups(void);
 static int	 startups_should_drop(unsigned int);
@@ -361,7 +362,7 @@ fork_child_nonfatal(enum openimap_proc_type type, stru
 	}
 	c->pid = pid;
 	c->type = type;
-	imsgev_init(&c->iev, sp[0], handler, NULL);
+	imsgev_init(&c->iev, sp[0], handler, c);
 	TAILQ_INSERT_TAIL(&children, c, entry);
 
 	*ievp = &c->iev;
@@ -432,21 +433,31 @@ setup_done_send(struct imsgev *iev)
 	imsg_free(&imsg);
 }
 
+/* a broken channel ends that child, not imapd */
 static void
 parent_dispatch_child(int fd, short event, void *arg)
 {
-	struct imsgev	*iev = arg;
+	struct child	*c = arg;
+	struct imsgev	*iev = &c->iev;
 	struct imsg	 imsg;
 	ssize_t		 n;
 
 	if (event & EV_WRITE) {
-		if (imsgbuf_write(&iev->ibuf) == -1)
-			fatal("imsgbuf_write");
+		if (imsgbuf_write(&iev->ibuf) == -1) {
+			log_debug("%s[%d]: channel closed: %s",
+			    log_procname(c->type), c->pid, strerror(errno));
+			child_drop(c);
+			return;
+		}
 	}
 
 	if (event & EV_READ) {
-		if ((n = imsgbuf_read(&iev->ibuf)) == -1)
-			fatal("imsgbuf_read");
+		if ((n = imsgbuf_read(&iev->ibuf)) == -1) {
+			log_warn("%s[%d]: imsgbuf_read, killing it",
+			    log_procname(c->type), c->pid);
+			child_drop(c);
+			return;
+		}
 		if (n == 0) {
 			event_del(&iev->ev);
 			return;
@@ -454,8 +465,12 @@ parent_dispatch_child(int fd, short event, void *arg)
 	}
 
 	for (;;) {
-		if ((n = imsgbuf_get(&iev->ibuf, &imsg)) == -1)
-			fatal("imsgbuf_get");
+		if ((n = imsgbuf_get(&iev->ibuf, &imsg)) == -1) {
+			log_warn("%s[%d]: imsgbuf_get, killing it",
+			    log_procname(c->type), c->pid);
+			child_drop(c);
+			return;
+		}
 		if (n == 0)
 			break;
 
@@ -518,6 +533,14 @@ parent_dispatch_child(int fd, short event, void *arg)
 	(void)fd;
 }
 
+/* reap_child() frees c once SIGCHLD reports the exit */
+static void
+child_drop(struct child *c)
+{
+	event_del(&c->iev.ev);
+	kill(c->pid, SIGKILL);
+}
+
 static struct open_session *
 open_session_find(uint32_t session_id)
 {
blob - 0fc76f71c80271d4856ee9038dcac60a2f275f4f
blob + 39a322115575c507c1a25286fcf680d5f615b106
--- src/search_match.c
+++ src/search_match.c
@@ -18,6 +18,7 @@
 
 #include <sys/types.h>
 #include <sys/queue.h>
+#include <sys/stat.h>
 
 #include <errno.h>
 #include <event.h>
@@ -346,10 +347,14 @@ static int
 read_header(int fd, const char *basename, int whole, char **buf_out,
     size_t *len_out)
 {
-	char	*buf = NULL, *nbuf;
-	size_t	 len = 0, size = 0, hdrend, limit;
-	ssize_t	 n;
+	struct stat	 st;
+	char		*buf = NULL, *nbuf;
+	size_t		 len = 0, size = 0, hdrend, limit;
+	ssize_t		 n;
 
+	if (whole && fstat(fd, &st) == 0 && S_ISREG(st.st_mode) &&
+	    st.st_size > (off_t)bodystructure_read_max)
+		goto toolarge;
 	/* one octet past the cap tells a header at it from one over it */
 	limit = (size_t)bodystructure_read_max + 1;
 	for (;;) {
@@ -383,17 +388,18 @@ read_header(int fd, const char *basename, int whole, c
 			break;
 		}
 	}
-	if (len > bodystructure_read_max) {
-		log_warnx("session %u: message %s %s %u bytes, SEARCH "
-		    "cannot check it", session_id, basename, whole ?
-		    "is over" : "has no header end within",
-		    bodystructure_read_max);
-		free(buf);
-		return (-1);
-	}
+	if (len > bodystructure_read_max)
+		goto toolarge;
 	*buf_out = buf;
 	*len_out = len;
 	return (0);
+
+toolarge:
+	log_warnx("session %u: message %s %s %u bytes, SEARCH cannot "
+	    "check it", session_id, basename, whole ? "is over" :
+	    "has no header end within", bodystructure_read_max);
+	free(buf);
+	return (-1);
 }
 
 /* RFC 9051 SS6.4.4: addresses as ENVELOPE shows them, not group names */