Keep newly created IMAP mailboxes unsubscribed
Debian Trixie package bundle / packages (push) Successful in 22m21s
Debian Trixie package bundle / packages (push) Successful in 22m21s
This commit is contained in:
@@ -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
|
||||
|
||||
Executable
+201
@@ -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
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user