Keep newly created IMAP mailboxes unsubscribed
Debian Trixie package bundle / packages (push) Successful in 22m21s

This commit is contained in:
Mario Fetka
2026-07-29 07:42:40 +02:00
parent 15c86d49b8
commit b42333c048
6 changed files with 234 additions and 10 deletions
+3
View File
@@ -49,6 +49,9 @@ one-off files in `/tmp`:
legacy IMAP4 capability contract, STARTTLS with cleartext
`LOGINDISABLED`, plaintext-pipeline disposal, implicit TLS, LOGIN, and
shared GSASL PLAIN/LOGIN authentication.
- `imap-mailbox-check.py` uses disposable quoted hierarchy names to verify
LIST/LSUB, Extended LIST and LIST-STATUS plus CREATE, RENAME, SUBSCRIBE,
UNSUBSCRIBE, and safe non-empty/depth-first DELETE behavior.
- `smtp4dev-fixture.py` manages two authenticated, loopback-only external SMTP
provider fixtures for STARTTLS and implicit TLS plus a TLS-required,
no-auth relayhost. It validates credentials, captures messages through
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env python3
# This program is free software, licensed under the terms of the GNU GPL.
# See the Bongo COPYING file for full details.
# Copyright (c) 2026 Bongo Project contributors
"""Verify live IMAP mailbox listing and lifecycle commands."""
from __future__ import annotations
import os
import re
import socket
import ssl
import time
HOST = os.environ.get("BONGO_TEST_HOST", "127.0.0.1")
PORT = int(os.environ.get("BONGO_TEST_IMAPS_PORT", "993"))
TIMEOUT = float(os.environ.get("BONGO_TEST_TIMEOUT", "10"))
USERNAME = os.environ.get("BONGO_TEST_USER", "test1")
PASSWORD = os.environ.get("BONGO_TEST_PASSWORD", "")
TAGGED = re.compile(rb"^([A-Za-z0-9]+) (OK|NO|BAD)(?: .*)?\r\n$")
class IMAPCheckError(RuntimeError):
"""Raised when the live IMAP peer violates the tested contract."""
def quote(value: str) -> str:
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
class IMAPConnection:
def __init__(self) -> None:
raw = socket.create_connection((HOST, PORT), timeout=TIMEOUT)
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
context.minimum_version = ssl.TLSVersion.TLSv1_2
self.socket = context.wrap_socket(raw, server_hostname=HOST)
self.socket.settimeout(TIMEOUT)
self.reader = self.socket.makefile("rb")
self.counter = 0
greeting = self.line()
if not greeting.startswith(b"* OK "):
raise IMAPCheckError(f"unexpected IMAP greeting: {greeting!r}")
def close(self) -> None:
try:
self.reader.close()
finally:
self.socket.close()
def line(self) -> bytes:
line = self.reader.readline(65538)
if not line:
raise IMAPCheckError("connection closed while waiting for IMAP response")
if len(line) > 65536 or not line.endswith(b"\r\n"):
raise IMAPCheckError(f"invalid IMAP response line: {line!r}")
return line
def command(
self, command: str, expected: tuple[str, ...] = ("OK",)
) -> list[bytes]:
self.counter += 1
tag = f"M{self.counter:04d}"
self.socket.sendall(f"{tag} {command}\r\n".encode("utf-8"))
lines: list[bytes] = []
while True:
line = self.line()
match = TAGGED.match(line)
if match is not None and match.group(1).decode("ascii") == tag:
status = match.group(2).decode("ascii")
if status not in expected:
raise IMAPCheckError(
f"{command!r} expected {expected}, received {line!r}"
)
return lines + [line]
lines.append(line)
def contains_mailbox(lines: list[bytes], command: bytes, mailbox: str) -> bool:
encoded = quote(mailbox).encode("utf-8")
return any(
line.upper().startswith(b"* " + command + b" ") and encoded in line
for line in lines
)
def main() -> int:
if not PASSWORD:
raise IMAPCheckError("BONGO_TEST_PASSWORD must be set")
token = f"{os.getpid():x}-{time.time_ns():x}"
root = f"Bongo 07 {token}"
child = f"{root}/Quoted Child"
grandchild = f"{child}/Grandchild"
renamed = f"{root} Renamed"
renamed_child = f"{renamed}/Quoted Child"
renamed_grandchild = f"{renamed_child}/Grandchild"
client = IMAPConnection()
existing: list[str] = []
subscribed: set[str] = set()
try:
client.command(f"LOGIN {quote(USERNAME)} {quote(PASSWORD)}")
delimiter = client.command('LIST "" ""')
if not any(b'* LIST (' in line and b'"/"' in line for line in delimiter):
raise IMAPCheckError(f"LIST delimiter response missing: {delimiter!r}")
for mailbox in (root, child, grandchild):
client.command(f"CREATE {quote(mailbox)}")
existing.append(mailbox)
for mailbox in (root, child):
client.command(f"SUBSCRIBE {quote(mailbox)}")
subscribed.add(mailbox)
one_level = client.command(f'LIST "" {quote(root + "/%")}')
if not contains_mailbox(one_level, b"LIST", child):
raise IMAPCheckError("LIST % omitted the direct child")
if contains_mailbox(one_level, b"LIST", grandchild):
raise IMAPCheckError("LIST % incorrectly returned a grandchild")
parent_lsub = client.command(f'LSUB "" {quote(root)}')
if not contains_mailbox(parent_lsub, b"LSUB", root):
raise IMAPCheckError("LSUB omitted the subscribed parent")
lsub = client.command(f'LSUB "" {quote(root + "/*")}')
if not contains_mailbox(lsub, b"LSUB", child):
raise IMAPCheckError("LSUB omitted the subscribed child")
if contains_mailbox(lsub, b"LSUB", grandchild):
raise IMAPCheckError(
f"LSUB returned an unsubscribed grandchild: {lsub!r}"
)
extended = client.command(
f'LIST (SUBSCRIBED) "" {quote(root + "/*")} '
"RETURN (CHILDREN STATUS (MESSAGES UNSEEN SIZE))"
)
if not contains_mailbox(extended, b"LIST", child):
raise IMAPCheckError("extended LIST omitted the subscribed child")
child_lines = [
line for line in extended if quote(child).encode("utf-8") in line
]
if not any(b"\\Subscribed" in line and b"\\HasChildren" in line
for line in child_lines):
raise IMAPCheckError(
f"extended LIST attributes missing: {child_lines!r}"
)
if not any(
line.upper().startswith(b"* STATUS ")
and quote(child).encode("utf-8") in line
and all(item in line.upper() for item in (b"MESSAGES", b"UNSEEN", b"SIZE"))
for line in extended
):
raise IMAPCheckError(f"LIST-STATUS response missing: {extended!r}")
client.command(f"RENAME {quote(root)} {quote(renamed)}")
existing[:] = [renamed, renamed_child, renamed_grandchild]
subscribed = {renamed, renamed_child}
renamed_list = client.command(f'LIST "" {quote(renamed + "/*")}')
if not contains_mailbox(renamed_list, b"LIST", renamed_child):
raise IMAPCheckError("RENAME did not preserve the child hierarchy")
if not contains_mailbox(renamed_list, b"LIST", renamed_grandchild):
raise IMAPCheckError("RENAME did not preserve the grandchild hierarchy")
client.command(f"UNSUBSCRIBE {quote(renamed_child)}")
subscribed.discard(renamed_child)
after_unsubscribe = client.command(f'LSUB "" {quote(renamed + "/*")}')
if contains_mailbox(after_unsubscribe, b"LSUB", renamed_child):
raise IMAPCheckError("UNSUBSCRIBE left the child in LSUB")
client.command(f"DELETE {quote(renamed)}", expected=("NO",))
for mailbox in (renamed_grandchild, renamed_child, renamed):
client.command(f"DELETE {quote(mailbox)}")
existing.remove(mailbox)
subscribed.clear()
client.command("LOGOUT")
finally:
for mailbox in sorted(subscribed, key=len, reverse=True):
try:
client.command(f"UNSUBSCRIBE {quote(mailbox)}", ("OK", "NO"))
except (IMAPCheckError, OSError):
pass
for mailbox in sorted(existing, key=len, reverse=True):
try:
client.command(f"DELETE {quote(mailbox)}", ("OK", "NO"))
except (IMAPCheckError, OSError):
pass
client.close()
print(
"IMAP-05/06 PASS "
f"host={HOST}:{PORT} delimiter=/ hierarchy=quoted "
"list=percent/star lsub=subscribed-only "
"extended=subscribed/children/list-status "
"lifecycle=create/rename/sub/unsub/delete nonempty-delete=NO "
"cleanup=yes"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+9 -10
View File
@@ -1283,7 +1283,10 @@ FolderHierarchyCreate(ImapSession *session, char *folderPathUtf8)
if (NMAPSendCommandF(session->store.conn, "CREATE /mail/%s\r\n", folderPathUtf8) != -1) {
ccode = NMAPReadResponse(session->store.conn, NULL, 0, 0);
if (ccode == 1000) {
if (NMAPSendCommandF(session->store.conn, "FLAG /mail/%s +%u\r\n", folderPathUtf8, STORE_COLLECTION_FLAG_HIERARCHY_ONLY) != -1) {
if (NMAPSendCommandF(
session->store.conn, "FLAG /mail/%s %u\r\n",
folderPathUtf8,
(unsigned int)IMAPCreateMailboxFlags(0, TRUE)) != -1) {
ccode = NMAPReadResponse(session->store.conn, NULL, 0, 0);
if (ccode == 1000) {
*ptr = '/';
@@ -1383,7 +1386,7 @@ ImapCommandCreate(void *param)
/* this folder is just hierarchy. Make it a real folder */
folder->flags &= ~(STORE_COLLECTION_FLAG_HIERARCHY_ONLY |
STORE_COLLECTION_FLAG_SPECIAL_USE_MASK);
folder->flags |= specialUseFlags;
folder->flags |= IMAPCreateMailboxFlags(specialUseFlags, FALSE);
if (NMAPSendCommandF(session->store.conn, "FLAG /mail/%s %lu\r\n",
pathUtf8, folder->flags) == -1) {
MemFree(pathUtf8);
@@ -1405,14 +1408,10 @@ ImapCommandCreate(void *param)
return(SendError(session->client.conn, session->command.tag, "CREATE", ccode));
}
if (specialUseFlags != 0) {
ccode = NMAPSendCommandF(session->store.conn,
"CREATE \"/mail/%s\" 0 %u\r\n",
pathUtf8, (unsigned int)specialUseFlags);
} else {
ccode = NMAPSendCommandF(session->store.conn,
"CREATE \"/mail/%s\"\r\n", pathUtf8);
}
ccode = NMAPSendCommandF(
session->store.conn, "CREATE \"/mail/%s\" 0 %u\r\n",
pathUtf8,
(unsigned int)IMAPCreateMailboxFlags(specialUseFlags, FALSE));
if (ccode == -1) {
MemFree(pathUtf8);
return(SendError(session->client.conn, session->command.tag, "CREATE", STATUS_NMAP_COMM_ERROR));
+10
View File
@@ -127,3 +127,13 @@ IMAPSpecialUseFormat(uint32_t flags, char *buffer, size_t buffer_size)
}
return length;
}
uint32_t
IMAPCreateMailboxFlags(uint32_t special_use_flags, int hierarchy_only)
{
uint32_t flags = special_use_flags |
STORE_COLLECTION_FLAG_NON_SUBSCRIBED;
if (hierarchy_only) flags |= STORE_COLLECTION_FLAG_HIERARCHY_ONLY;
return flags;
}
+2
View File
@@ -27,5 +27,7 @@
int IMAPSpecialUseParseCreateOptions(const char *text, uint32_t *flags);
size_t IMAPSpecialUseFormat(uint32_t flags, char *buffer, size_t buffer_size);
uint32_t IMAPCreateMailboxFlags(uint32_t special_use_flags,
int hierarchy_only);
#endif
+9
View File
@@ -50,5 +50,14 @@ main(void)
assert(buffer[0] == '\0');
assert(IMAPSpecialUseFormat(flags, buffer, 4) == 0);
assert(buffer[0] == '\0');
assert(IMAPCreateMailboxFlags(0, 0) ==
STORE_COLLECTION_FLAG_NON_SUBSCRIBED);
assert(IMAPCreateMailboxFlags(STORE_COLLECTION_FLAG_SPECIAL_SENT, 0) ==
(STORE_COLLECTION_FLAG_NON_SUBSCRIBED |
STORE_COLLECTION_FLAG_SPECIAL_SENT));
assert(IMAPCreateMailboxFlags(0, 1) ==
(STORE_COLLECTION_FLAG_NON_SUBSCRIBED |
STORE_COLLECTION_FLAG_HIERARCHY_ONLY));
return 0;
}