Commit Diff


commit - da1136e4275efd1152585f31dab183ab5089370d
commit + 58a006b393da74d6143bdf2a5378f87f87bd70be
blob - 50638422e9585c4f4108abfb0fa57a2695ca3baa
blob + 152b3f5b275cc7d4db1e7535377d06795b9e40fa
--- 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.1 Pre-release, actively developed. Not a port. See [Getting the source](#getting-the-source) below for the repository.
+**Status:** 0.2.2 Pre-release, actively developed. Not a port. See [Getting the source](#getting-the-source) below for the repository.
 
 ## What it is
 
blob - 7e0c578730502999d1136d4258eab53efa0c9d4b
blob + 596c4dfcef0195bd1299f70fbea1d32586cee463
--- src/append_cmd.c
+++ src/append_cmd.c
@@ -349,6 +349,9 @@ session_handle_mbox_appended(struct session *s,
 		else if (res->error == MBOX_OP_ERR_BUSY)
 			session_reply(s, s->pending_tag, "NO",
 			    IMAP_BUSY_TEXT);
+		else if (res->error == MBOX_OP_ERR_OVERQUOTA)
+			session_reply(s, s->pending_tag, "NO",
+			    IMAP_OVERQUOTA_TEXT);
 		else
 			session_reply(s, s->pending_tag, "NO",
 			    "APPEND failed");
blob - 7cbe25a59044bfe29bab71adac0297c25f905621
blob + a4b13bb83eaccee953142bf9ebf1432fe2958254
--- 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: October 3 2026 $
+.Dd $Mdocdate: October 4 2026 $
 .Dt IMAPD.CONF 5
 .Os
 .Sh NAME
@@ -252,6 +252,17 @@ and every entry directly under it must be owned by roo
 writable by mail users;
 .Xr imapd 8
 does not check this.
+When less than 5% of the space or inodes of the filesystem holding a
+mailbox is free to non-root users,
+.Xr imapd 8
+refuses to add to it: APPEND, COPY, MOVE to another mailbox and CREATE
+fail with the
+.Dq OVERQUOTA
+response code.
+Flags can still be changed and messages expunged.
+Mail delivered by
+.Xr smtpd 8
+is not refused.
 The default is
 .Pa /var/mail/imapd .
 .It Ic startups begin Ar count Ic rate Ar percent Ic full Ar count
blob - 178a56bd0c968d14e88ac309af53431fb69dbae2
blob + ad6ba2b57e341a039b4ee83e8985f20671d4446f
--- src/imapd.h
+++ src/imapd.h
@@ -28,7 +28,7 @@
 #include <imsg.h>
 #include <stdint.h>
 
-#define IMAPD_VERSION	"0.2.1"
+#define IMAPD_VERSION	"0.2.2"
 
 #define IMAPD_USER	"_imapd"
 #define IMAPD_AUTH_USER	"_imapauth"
@@ -258,7 +258,7 @@ struct imsg_store_init {
 
 #define MBOX_NAME_MAX	256
 
-/* RFC 5530 NONEXISTENT/ALREADYEXISTS/LIMIT; RFC 9051 INUSE, NOTSAVED */
+/* RFC 5530 SS3 response codes, and RFC 9051 NOTSAVED */
 enum mbox_op_error {
 	MBOX_ERR_UNSET = 0,
 	MBOX_OP_OK,
@@ -269,6 +269,7 @@ enum mbox_op_error {
 	MBOX_OP_ERR_EXPUNGEISSUED,
 	MBOX_OP_ERR_LIMIT,
 	MBOX_OP_ERR_NOTSAVED,
+	MBOX_OP_ERR_OVERQUOTA,
 };
 
 /* QRESYNC select-param (RFC 7162 SS3.2.5). */
blob - 2c32ea030166b5b383da1b7d1c3561933e8c9407
blob + 68126e28fffe04e0f26c21b5e2882ed2b8e61d41
--- src/index.c
+++ src/index.c
@@ -503,7 +503,7 @@ int
 index_save(int dfd, const struct mbox_index *idx)
 {
 	FILE	*fp;
-	int	 fd;
+	int	 fd, serrno;
 	size_t	 i;
 
 	/* O_EXCL: never follow a planted symlink */
@@ -530,28 +530,24 @@ index_save(int dfd, const struct mbox_index *idx)
 	    (unsigned long long)idx->highestmodseq) < 0) {
 		log_warnx("session %u: write index header failed",
 		    session_id);
-		fclose(fp);
-		return (-1);
+		goto fail;
 	}
 	for (i = 0; i < idx->nlines; i++) {
 		if (fprintf(fp, "%s\n", idx->lines[i]) < 0) {
 			log_warnx("session %u: write index line failed",
 			    session_id);
-			fclose(fp);
-			return (-1);
+			goto fail;
 		}
 	}
 	if (fflush(fp) != 0) {
 		log_warn("session %u: fflush %s", session_id,
 		    STORE_INDEX_TMP_NAME);
-		fclose(fp);
-		return (-1);
+		goto fail;
 	}
 	if (fsync(fileno(fp)) == -1) {
 		log_warn("session %u: fsync %s", session_id,
 		    STORE_INDEX_TMP_NAME);
-		fclose(fp);
-		return (-1);
+		goto fail;
 	}
 	if (fclose(fp) != 0) {
 		log_warn("session %u: fclose %s", session_id,
@@ -571,6 +567,13 @@ index_save(int dfd, const struct mbox_index *idx)
 		log_warn("session %u: fsync mailbox directory "
 		    "(continuing)", session_id);
 	return (0);
+
+fail:
+	/* the caller reads errno: ENOSPC answers OVERQUOTA */
+	serrno = errno;
+	fclose(fp);
+	errno = serrno;
+	return (-1);
 }
 
 
@@ -940,7 +943,7 @@ index_scan_new(int dfd, struct mbox_index *idx, int mu
 int
 refresh_index(int dfd, struct mbox_index *idx, int fd)
 {
-	int	added;
+	int	added, serrno;
 
 	if (index_load(fd, idx) == -1)
 		return (-1);
@@ -953,7 +956,9 @@ refresh_index(int dfd, struct mbox_index *idx, int fd)
 		return (0);
 
 	if (index_save(dfd, idx) == -1) {
+		serrno = errno;
 		index_free(idx);
+		errno = serrno;
 		return (-1);
 	}
 	return (0);
blob - 900627b3d2989560a5d97bc7a6a83e9a5e7e8ed8
blob + 14c2f93c66bdbc2b25143b61ec596f8d7db838fd
--- src/listener.h
+++ src/listener.h
@@ -228,8 +228,9 @@ extern struct imsgev		 iev_auth;
 extern struct imsgev		 iev_parent;
 extern struct tls		*listener_tls_ctx;
 
-/* RFC 9051 SS7.1 INUSE */
+/* RFC 9051 SS7.1 INUSE, OVERQUOTA */
 #define IMAP_BUSY_TEXT	"[INUSE] mailbox busy, try again"
+#define IMAP_OVERQUOTA_TEXT	"[OVERQUOTA] not enough free space"
 
 extern uint32_t			 listener_idle_poll_secs;
 extern uint32_t			 listener_login_grace_secs;
blob - c91609ea6e57b9ca568497435bfef3cbb3f02b97
blob + 00ffff1b63663f3599aa5c68bef8d0360370412d
--- src/mbox_copy.c
+++ src/mbox_copy.c
@@ -227,10 +227,15 @@ copy_message_file(struct store_session *ss, const stru
 		}
 		left -= n;
 	}
-	if (fsync(tmpfd) == -1)
-		log_warn("session %u: COPY: fsync %s (continuing)", session_id,
-		    tmppath);
-	close(tmpfd);
+	if (fsync(tmpfd) == -1) {
+		log_warn("session %u: COPY: fsync %s", session_id, tmppath);
+		goto fail;
+	}
+	if (close(tmpfd) == -1) {
+		log_warn("session %u: COPY: close %s", session_id, tmppath);
+		tmpfd = -1;
+		goto fail;
+	}
 	close(srcfd);
 	if (renameat(dfd, tmppath, dfd, curpath) == -1) {
 		log_warn("session %u: COPY: rename %s -> %s", session_id,
@@ -241,7 +246,8 @@ copy_message_file(struct store_session *ss, const stru
 	return (0);
 
 fail:
-	close(tmpfd);
+	if (tmpfd != -1)
+		close(tmpfd);
 	close(srcfd);
 	unlinkat(dfd, tmppath, 0);
 	return (-1);
@@ -444,7 +450,8 @@ finish_copy_move(struct mbox_index *idx_a, struct mbox
 	index_lock_release(il_b);
 
 	if (result->error != MBOX_OP_ERR_NO_SUCH_MAILBOX &&
-	    result->error != MBOX_OP_ERR_LIMIT)
+	    result->error != MBOX_OP_ERR_LIMIT &&
+	    result->error != MBOX_OP_ERR_OVERQUOTA)
 		result->error = ok ? MBOX_OP_OK : MBOX_OP_ERR_GENERIC;
 	index_free(idx_a);
 	index_free(idx_b);
@@ -490,6 +497,11 @@ handle_mbox_copy(struct imsg_mbox_copy *req, const str
 		goto done;
 	}
 	dfd = cross_mailbox ? destfd : ss->mailbox_dir_fd;
+	if (!store_space_ok(dfd)) {
+		result.error = MBOX_OP_ERR_OVERQUOTA;
+		ok = 0;
+		goto close_dest;
+	}
 	/* RFC 2180 SS4.4.2: the numbers are those before the EXPUNGEs */
 	if ((got = saved_resolve(ss, ranges, nranges, vr, &nvr,
 	    &req->by_uid, &used)) != 0) {
@@ -827,6 +839,12 @@ handle_mbox_move(struct imsg_mbox_copy *req, const str
 		ok = 0;
 		goto done;
 	}
+	if (cross_mailbox && !store_space_ok(destfd)) {
+		result.error = MBOX_OP_ERR_OVERQUOTA;
+		ok = 0;
+		close(destfd);
+		goto done;
+	}
 
 	if ((got = saved_resolve(ss, ranges, nranges, vr, &nvr,
 	    &req->by_uid, &used)) != 0) {
blob - 8b2586daa09bc057f91e13740e82ca128037d069
blob + 4a85168d6e91df742c5d7a3a265c65d69f6f2598
--- src/mbox_fetch.c
+++ src/mbox_fetch.c
@@ -627,13 +627,15 @@ handle_mbox_status(struct imsg_mbox_status *req, struc
 	else {
 		log_debug("session %u: STATUS %s: invalid mailbox name",
 		    session_id, req->mailbox);
-		reply.error = MBOX_OP_ERR_GENERIC;
+		reply.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
 		goto send;
 	}
 	if ((tfd = mailbox_open_dir(target)) == -1) {
-		log_debug("session %u: STATUS %s: no such mailbox",
-		    session_id, req->mailbox);
-		reply.error = MBOX_OP_ERR_GENERIC;
+		reply.error = errno == ENOENT || errno == ENOTDIR ?
+		    MBOX_OP_ERR_NO_SUCH_MAILBOX : MBOX_OP_ERR_GENERIC;
+		if (reply.error == MBOX_OP_ERR_GENERIC)
+			log_warn("session %u: STATUS %s", session_id,
+			    req->mailbox);
 		goto send;
 	}
 
@@ -648,8 +650,9 @@ handle_mbox_status(struct imsg_mbox_status *req, struc
 	}
 
 	if (refresh_index(tfd, &idx, il.fd) == -1) {
+		reply.error = errno == ENOSPC || errno == EDQUOT ?
+		    MBOX_OP_ERR_OVERQUOTA : MBOX_OP_ERR_GENERIC;
 		index_lock_release(&il);
-		reply.error = MBOX_OP_ERR_GENERIC;
 		goto send;
 	}
 
blob - 8e433d36a9774891a96bfabd88ce6621fbbabced
blob + 87c2870025ee974842c127c02c638b19577c9cda
--- src/mbox_manage.c
+++ src/mbox_manage.c
@@ -2,6 +2,7 @@
 
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
+ * Copyright (c) 2011 Gilles Chehade <gilles@poolp.org>
  *
  * Permission to use, copy, modify, and distribute this software for any
  * purpose with or without fee is hereby granted, provided that the above
@@ -19,6 +20,7 @@
 
 #include <sys/types.h>
 #include <sys/file.h>
+#include <sys/mount.h>
 #include <sys/stat.h>
 
 #include <dirent.h>
@@ -61,14 +63,16 @@ handle_mbox_select(struct imsg_mbox_select *req,
 	else {
 		log_debug("session %u: SELECT %s: invalid mailbox name",
 		    session_id, req->mailbox);
-		reply.error = MBOX_OP_ERR_GENERIC;
+		reply.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
 		goto send;
 	}
 
 	if ((dfd = mailbox_open_dir(target)) == -1) {
-		log_debug("session %u: SELECT %s: no such mailbox",
-		    session_id, req->mailbox);
-		reply.error = MBOX_OP_ERR_GENERIC;
+		reply.error = errno == ENOENT || errno == ENOTDIR ?
+		    MBOX_OP_ERR_NO_SUCH_MAILBOX : MBOX_OP_ERR_GENERIC;
+		if (reply.error == MBOX_OP_ERR_GENERIC)
+			log_warn("session %u: SELECT %s", session_id,
+			    req->mailbox);
 		goto send;
 	}
 
@@ -85,9 +89,10 @@ handle_mbox_select(struct imsg_mbox_select *req,
 	}
 
 	if (refresh_index(dfd, &idx, il.fd) == -1) {
+		reply.error = errno == ENOSPC || errno == EDQUOT ?
+		    MBOX_OP_ERR_OVERQUOTA : MBOX_OP_ERR_GENERIC;
 		index_lock_release(&il);
 		close(dfd);
-		reply.error = MBOX_OP_ERR_GENERIC;
 		goto send;
 	}
 
@@ -153,6 +158,41 @@ ensure_maildir_dirs(int dfd, const char *prefix)
 }
 
 
+#define STORE_MINSPACE		5
+#define STORE_MININODES		5
+
+/* growth needs 5% of space and inodes free, as smtpd's queue_fs.c */
+int
+store_space_ok(int fd)
+{
+	struct statfs	sfs;
+	uint64_t	used, avail;
+
+	if (fstatfs(fd, &sfs) == -1) {
+		log_warn("session %u: fstatfs", session_id);
+		return (0);
+	}
+	used = sfs.f_blocks - sfs.f_bfree;
+	avail = sfs.f_bavail > 0 ? (uint64_t)sfs.f_bavail : 0;
+	if (sfs.f_blocks > 0 &&
+	    avail * 100 < (avail + used) * STORE_MINSPACE) {
+		log_warnx("session %u: less than %d%% of the mail store's "
+		    "space is free, refusing to add to it", session_id,
+		    STORE_MINSPACE);
+		return (0);
+	}
+	used = sfs.f_files - sfs.f_ffree;
+	avail = sfs.f_favail > 0 ? (uint64_t)sfs.f_favail : 0;
+	if (sfs.f_files > 0 &&
+	    avail * 100 < (avail + used) * STORE_MININODES) {
+		log_warnx("session %u: less than %d%% of the mail store's "
+		    "inodes are free, refusing to add to it", session_id,
+		    STORE_MININODES);
+		return (0);
+	}
+	return (1);
+}
+
 /* RFC 9051 SS6.3.4 CREATE */
 
 void
@@ -171,12 +211,17 @@ handle_mbox_create(struct imsg_mbox_create *req, struc
 		goto send;
 	}
 
+	if (!store_space_ok(maildir_root_fd)) {
+		result.error = MBOX_OP_ERR_OVERQUOTA;
+		goto send;
+	}
 
 	if (mkdirat(maildir_root_fd, req->mailbox, 0700) == -1) {
 		if (errno != EEXIST) {
+			result.error = errno == ENOSPC || errno == EDQUOT ?
+			    MBOX_OP_ERR_OVERQUOTA : MBOX_OP_ERR_GENERIC;
 			log_warn("session %u: CREATE: mkdir %s", session_id,
 			    req->mailbox);
-			result.error = MBOX_OP_ERR_GENERIC;
 		} else {
 			log_debug("session %u: CREATE %s: already exists",
 			    session_id, req->mailbox);
@@ -1054,6 +1099,10 @@ handle_mbox_append_begin(struct store_session *ss,
 		ap->error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
 		return;
 	}
+	if (!store_space_ok(ap->tfd)) {
+		ap->error = MBOX_OP_ERR_OVERQUOTA;
+		return;
+	}
 	if (ensure_maildir_dirs(ap->tfd, "") == -1)
 		return;
 
@@ -1073,6 +1122,8 @@ handle_mbox_append_begin(struct store_session *ss,
 	}
 	if ((ap->tmpfd = openat(ap->tfd, ap->tmppath,
 	    O_WRONLY | O_CREAT | O_EXCL, 0600)) == -1) {
+		if (errno == ENOSPC || errno == EDQUOT)
+			ap->error = MBOX_OP_ERR_OVERQUOTA;
 		log_warn("session %u: open %s", session_id, ap->tmppath);
 		/* not ours to remove, O_EXCL may have found another file */
 		ap->tmppath[0] = '\0';
@@ -1111,10 +1162,11 @@ handle_mbox_append_data(struct store_session *ss, cons
 		if (n == -1) {
 			if (errno == EINTR)
 				continue;
+			ap->error = errno == ENOSPC || errno == EDQUOT ?
+			    MBOX_OP_ERR_OVERQUOTA : MBOX_OP_ERR_GENERIC;
 			log_warn("session %u: write %s", session_id,
 			    ap->tmppath);
 			ap->failed = 1;
-			ap->error = MBOX_OP_ERR_GENERIC;
 			return;
 		}
 		written += (size_t)n;
@@ -1158,11 +1210,19 @@ handle_mbox_append_end(struct store_session *ss)
 
 	/* A re-run after a busy lock finds this already done. */
 	if (ap->tmpfd != -1) {
-		if (fsync(ap->tmpfd) == -1)
-			log_warn("session %u: fsync %s (continuing)",
-			    session_id, ap->tmppath);
-		close(ap->tmpfd);
-		ap->tmpfd = -1;
+		int	r;
+
+		if ((r = fsync(ap->tmpfd)) == 0) {
+			r = close(ap->tmpfd);
+			ap->tmpfd = -1;
+		}
+		if (r == -1) {
+			reply.error = errno == ENOSPC || errno == EDQUOT ?
+			    MBOX_OP_ERR_OVERQUOTA : MBOX_OP_ERR_GENERIC;
+			log_warn("session %u: fsync or close %s", session_id,
+			    ap->tmppath);
+			goto done;
+		}
 	}
 
 	locked = index_lock_acquire(ap->tfd, &il, LOCK_EX | LOCK_NB);
blob - 17234463d2693ea76eb2ecc26bc1cd5a72c89042
blob + 35ffc566b73f498e61f59bf40776748d26b87b89
--- src/store_cmd.c
+++ src/store_cmd.c
@@ -805,6 +805,10 @@ session_finish_copy_or_move(struct session *s,
 		    res->error == MBOX_OP_ERR_LIMIT)
 			snprintf(text, sizeof(text), "[LIMIT] %s failed",
 			    cmdname);
+		else if (!s->copy_alloc_failed &&
+		    res->error == MBOX_OP_ERR_OVERQUOTA)
+			snprintf(text, sizeof(text), "%s",
+			    IMAP_OVERQUOTA_TEXT);
 		else
 			snprintf(text, sizeof(text), "%s failed", cmdname);
 		session_reply(s, s->pending_tag, "NO", text);
blob - 256654b5b1046de87793173ceb0b869e49e58304
blob + af036bd14d59347e1db3ab7d2c57f9914359fe22
--- src/store_internal.h
+++ src/store_internal.h
@@ -403,6 +403,7 @@ void	 qresync_send_resync(const struct imsg_mbox_selec
 
 const char	*append_hostname(void);
 int		 ensure_maildir_dirs(int, const char *);
+int		 store_space_ok(int);
 uint32_t	 letters_to_sysflags(const char *);
 int		 merge_keywords(int, const char *, const char *, char *,
 		    size_t);
blob - a7dcc836654c3556913a0bde6116b715907c581d
blob + fb6781900013f0b414aa6a72d370df8ce6e14ddc
--- src/store_ipc.c
+++ src/store_ipc.c
@@ -409,12 +409,15 @@ session_handle_mbox_selected(struct session *s,
 	char	buf[128];
 
 	if (res->error != MBOX_OP_OK || s->qresync_alloc_failed) {
-		/* RFC 5530 NONEXISTENT */
+		/* RFC 5530 NONEXISTENT only when there is no such mailbox */
 		s->state = SESSION_AUTHENTICATED;
 		session_reply(s, s->pending_tag, "NO",
 		    s->qresync_alloc_failed ? "[UNAVAILABLE] SELECT failed" :
 		    res->error == MBOX_OP_ERR_BUSY ? IMAP_BUSY_TEXT :
-		    "[NONEXISTENT] no such mailbox");
+		    res->error == MBOX_OP_ERR_NO_SUCH_MAILBOX ?
+		    "[NONEXISTENT] no such mailbox" :
+		    res->error == MBOX_OP_ERR_OVERQUOTA ? IMAP_OVERQUOTA_TEXT :
+		    s->mbox_readonly ? "EXAMINE failed" : "SELECT failed");
 
 		/* a compromised store child may have streamed resync data */
 		free(s->vanished_ranges);
@@ -538,10 +541,13 @@ session_handle_mbox_status_result(struct session *s,
 	s->state = s->status_prev_state;
 
 	if (res->error != MBOX_OP_OK) {
-		/* a missing mailbox and an I/O failure look alike */
+		/* RFC 5530 NONEXISTENT only when there is no such mailbox */
 		session_reply(s, s->pending_tag, "NO",
 		    res->error == MBOX_OP_ERR_BUSY ? IMAP_BUSY_TEXT :
-		    "[NONEXISTENT] no such mailbox");
+		    res->error == MBOX_OP_ERR_NO_SUCH_MAILBOX ?
+		    "[NONEXISTENT] no such mailbox" :
+		    res->error == MBOX_OP_ERR_OVERQUOTA ? IMAP_OVERQUOTA_TEXT :
+		    "STATUS failed");
 		return;
 	}
 
@@ -627,6 +633,9 @@ session_finish_mbox_op(struct session *s, const struct
 		session_reply(s, s->pending_tag, "NO",
 		    "[ALREADYEXISTS] mailbox already exists");
 		return;
+	case MBOX_OP_ERR_OVERQUOTA:
+		session_reply(s, s->pending_tag, "NO", IMAP_OVERQUOTA_TEXT);
+		return;
 	default:
 		snprintf(text, sizeof(text), "%s failed", cmdname);
 		session_reply(s, s->pending_tag, "NO", text);