diff --git a/contrib/testing/README.md b/contrib/testing/README.md index 265eb1b..fd89de0 100644 --- a/contrib/testing/README.md +++ b/contrib/testing/README.md @@ -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 diff --git a/contrib/testing/imap-mailbox-check.py b/contrib/testing/imap-mailbox-check.py new file mode 100755 index 0000000..e4df26e --- /dev/null +++ b/contrib/testing/imap-mailbox-check.py @@ -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()) diff --git a/src/agents/imap/imapd.c b/src/agents/imap/imapd.c index ab4c660..70bbe9f 100644 --- a/src/agents/imap/imapd.c +++ b/src/agents/imap/imapd.c @@ -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)); diff --git a/src/agents/imap/special-use.c b/src/agents/imap/special-use.c index efbef7c..99a904f 100644 --- a/src/agents/imap/special-use.c +++ b/src/agents/imap/special-use.c @@ -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; +} diff --git a/src/agents/imap/special-use.h b/src/agents/imap/special-use.h index 68d3255..40df221 100644 --- a/src/agents/imap/special-use.h +++ b/src/agents/imap/special-use.h @@ -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 diff --git a/src/agents/imap/tests/special-use-test.c b/src/agents/imap/tests/special-use-test.c index 019381e..a62bfaa 100644 --- a/src/agents/imap/tests/special-use-test.c +++ b/src/agents/imap/tests/special-use-test.c @@ -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; }