commit 58a006b393da74d6143bdf2a5378f87f87bd70be from: David Williams date: Sun Oct 4 23:36:13 2026 UTC Free-space floor; SELECT/STATUS error codes; fail APPEND on fsync Change 1 of 3: Refuse APPEND, COPY, MOVE and CREATE below 5% free space On a full filesystem a user cannot free space: STORE \Deleted and EXPUNGE both write a new index before the old one is removed, so they fail with ENOSPC, and SELECT fails too when new mail is waiting. On the test host, with the spool full, every change to the mailbox failed and SELECT answered [NONEXISTENT]. Keep a floor, as smtpd(8) does for its queue (usr.sbin/smtpd/ queue_fs.c): the commands that add to the mail store, APPEND, COPY, MOVE to another mailbox and CREATE, are refused when less than 5% of the filesystem's space or inodes is free to non-root users. Index saves are not refused, so flags can still be changed and mail expunged below the floor. Unlike smtpd's check, a filesystem reporting no free blocks or inodes at all is refused, not accepted. A refusal answers NO [OVERQUOTA] (RFC 5530 section 3), as do APPEND and CREATE when a write, open or mkdir fails with ENOSPC or EDQUOT. Each refusal is logged, and imapd.conf.5 describes the floor under "spool". The check is adapted from smtpd's fsqueue_check_space(), and mbox_manage.c carries its copyright. smtpd still delivers below the floor, so a disk filled by delivery can still reach zero; then the commands that free space fail until the operator frees some. Change 2 of 3: SELECT and STATUS say [NONEXISTENT] only when it is true SELECT, EXAMINE and STATUS answered NO [NONEXISTENT] no such mailbox for every failure but a busy lock: the store reported every failure the same way. On the test host a full disk, and a mailbox whose directory could not be read, were both reported as missing, so a client could tell its user the mailbox was gone. The store now tells the listener why. A name that cannot exist, or a mailbox directory that openat(2) finds missing (ENOENT, ENOTDIR), still answers NO [NONEXISTENT] (RFC 5530 section 3). A full disk or quota while saving the index answers NO [OVERQUOTA], as APPEND does. Any other failure answers NO with no response code, "SELECT failed", "EXAMINE failed" or "STATUS failed" (RFC 9051 section 7.1.2), and is logged. index_save() keeps the errno of the call that failed across its cleanup so the caller can tell a full disk from other errors. Change 3 of 3: APPEND and COPY fail when fsync(2) or close(2) fails APPEND logged a failed fsync(2) of the new message as "(continuing)", did not check close(2), and went on to index the message and answer OK. COPY, when it copies a message rather than linking it, did the same. A message whose data the kernel could not write to disk was then listed as stored. Both now fail the command instead, remove the temporary file, and leave the index untouched, as index_save() and smtpd's mail.maildir already do. APPEND answers NO [OVERQUOTA] when the error is ENOSPC or EDQUOT, and NO APPEND failed otherwise. 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 #include -#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 + * Copyright (c) 2011 Gilles Chehade * * 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 #include +#include #include #include @@ -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);