Skip to content
141 changes: 131 additions & 10 deletions imapclient/imapclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from datetime import date, datetime
from logging import getLogger, LoggerAdapter
from operator import itemgetter
from typing import List, Optional
from typing import List, Optional, Tuple

from . import exceptions, imap4, response_lexer, tls
from .datetime_util import datetime_to_INTERNALDATE, format_criteria_date
Expand All @@ -42,6 +42,12 @@
"FLAGGED",
"DRAFT",
"RECENT",
"ALL",
"ARCHIVE",
"DRAFTS",
"JUNK",
"SENT",
"TRASH",
]


Expand Down Expand Up @@ -76,6 +82,11 @@
if "MOVE" not in imaplib.Commands:
imaplib.Commands["MOVE"] = ("AUTH", "SELECTED")

# .. and LIST-EXTENDED for RFC6154.
if "LIST-EXTENDED" not in imaplib.Commands:
imaplib.Commands["LIST-EXTENDED"] = ("AUTH", "SELECTED")


# System flags
DELETED = rb"\Deleted"
SEEN = rb"\Seen"
Expand Down Expand Up @@ -727,6 +738,29 @@ def xlist_folders(self, directory="", pattern="*"):
"""
return self._do_list("XLIST", directory, pattern)

@require_capability("SPECIAL-USE")
def list_special_folders(
self, directory: str = "", pattern: str = "*"
) -> List[Tuple[Tuple[bytes, ...], bytes, str]]:
"""List folders with SPECIAL-USE attributes.

This method uses the RFC 6154 LIST extension to efficiently query
folders with special-use attributes without listing all folders.

Args:
directory: Base directory to search (default: "")
pattern: Pattern to match folder names (default: "*")

Returns:
List of (flags, delimiter, name) tuples. Flags may contain
special-use attributes like b'\\Sent', b'\\Archive', etc.

Raises:
CapabilityError: If server doesn't support SPECIAL-USE
IMAPClientError: If the LIST command fails
"""
return self._do_list_extended("LIST", directory, pattern, "SPECIAL-USE")

def list_sub_folders(self, directory="", pattern="*"):
"""Return a list of subscribed folders on the server as
``(flags, delimiter, name)`` tuples.
Expand All @@ -744,6 +778,16 @@ def _do_list(self, cmd, directory, pattern):
typ, dat = self._imap._untagged_response(typ, dat, cmd)
return self._proc_folder_list(dat)

def _do_list_extended(self, cmd, directory, pattern, selection_option):
directory = self._normalise_folder(directory)
pattern = self._normalise_folder(pattern)
typ, dat = self._imap._simple_command(
cmd, directory, pattern, "RETURN", "(%s)" % selection_option
)
self._checkok(cmd, typ, dat)
typ, dat = self._imap._untagged_response(typ, dat, cmd)
return self._proc_folder_list(dat)

def _proc_folder_list(self, folder_data):
# Filter out empty strings and None's.
# This also deals with the special case of - no 'untagged'
Expand Down Expand Up @@ -777,10 +821,15 @@ def find_special_folder(self, folder_flag):
Returns the name of the folder if found, or None otherwise.
"""
# Detect folder by looking for known attributes
# TODO: avoid listing all folders by using extended LIST (RFC6154)
for folder in self.list_folders():
if folder and len(folder[0]) > 0 and folder_flag in folder[0]:
return folder[2]
# Use RFC 6154 SPECIAL-USE extension when available for efficiency
if self.has_capability("SPECIAL-USE"):
for folder in self.list_special_folders():
if folder and len(folder[0]) > 0 and folder_flag in folder[0]:
return folder[2]
else:
for folder in self.list_folders():
if folder and len(folder[0]) > 0 and folder_flag in folder[0]:
return folder[2]

# Detect folder by looking for common names
# We only look for folders in the "personal" namespace of the user
Expand Down Expand Up @@ -1026,11 +1075,83 @@ def close_folder(self):
"""
return self._command_and_check("close", unpack=True)

def create_folder(self, folder):
"""Create *folder* on the server returning the server response string."""
return self._command_and_check(
"create", self._normalise_folder(folder), unpack=True
)
def create_folder(self, folder: str, special_use: Optional[bytes] = None) -> str:
"""Create folder with optional SPECIAL-USE attribute.

Creates a new folder on the IMAP server. When special_use is provided,
the folder will be marked with the specified special-use attribute
according to RFC 6154.

Args:
folder: Folder name to create
special_use: Optional special-use attribute (e.g., SENT, DRAFTS, JUNK, etc.)
Must be one of the RFC 6154 constants: ALL, ARCHIVE, DRAFTS,
JUNK, SENT, TRASH

Returns:
Server response string

Raises:
CapabilityError: If server doesn't support CREATE-SPECIAL-USE when
special_use is provided
IMAPClientError: If the CREATE command fails

Examples:
Standard folder creation (existing behavior):
>>> client.create_folder("INBOX.NewFolder")

Special-use folder creation (new feature):
>>> client.create_folder("INBOX.MySent", special_use=imapclient.SENT)
>>> client.create_folder("INBOX.MyDrafts", special_use=imapclient.DRAFTS)
"""
if special_use is not None:
return self._create_folder_with_special_use(folder, special_use)
else:
# Use standard CREATE command (existing behavior)
return self._command_and_check(
"create", self._normalise_folder(folder), unpack=True
)

def _create_folder_with_special_use(self, folder: str, special_use: bytes) -> str:
"""Create folder with SPECIAL-USE attribute using RFC 6154 CREATE extension.

Args:
folder: Folder name to create
special_use: Special-use attribute (bytes)

Returns:
Server response string

Raises:
CapabilityError: If server doesn't support CREATE-SPECIAL-USE
IMAPClientError: If special_use is not a valid RFC 6154 constant
"""
if not self.has_capability("CREATE-SPECIAL-USE"):
raise exceptions.CapabilityError(
"Server does not support CREATE-SPECIAL-USE"
)

# Validate special_use against known RFC 6154 constants
valid_special_uses = {ALL, ARCHIVE, DRAFTS, JUNK, SENT, TRASH}
if special_use not in valid_special_uses:
raise exceptions.IMAPClientError(
f"Invalid special_use attribute: {special_use!r}. "
f"Must be one of: {', '.join(attr.decode('ascii') for attr in valid_special_uses)}"
)

normalized_folder = self._normalise_folder(folder)

# Construct CREATE command with USE attribute: CREATE "folder" (USE (special_use))
use_clause = b"(USE (" + special_use + b"))"

# Note: Using direct _imap.create() instead of _command_and_check() to pass
# the additional use_clause parameter for RFC 6154 CREATE-SPECIAL-USE extension.
# Error handling remains functionally equivalent to standard pattern.
typ, data = self._imap.create(normalized_folder, use_clause)
if typ != "OK":
raise exceptions.IMAPClientError(f"CREATE command failed: {data}")

return data[0].decode("ascii", "replace")

def rename_folder(self, old_name, new_name):
"""Change the name of a folder on the server."""
Expand Down
92 changes: 92 additions & 0 deletions livetest.py
Original file line number Diff line number Diff line change
Expand Up @@ -1119,6 +1119,98 @@ def test_getacl(self):
rights = self.client.getacl(folder)
self.assertIn(who, [u for u, r in rights])

def test_list_special_folders(self):
self.skip_unless_capable("SPECIAL-USE")

# Test basic list_special_folders functionality
special_folders = self.client.list_special_folders()
self.assertIsInstance(special_folders, list)

# Verify the response format
for flags, delimiter, name in special_folders:
self.assertIsInstance(flags, tuple)
self.assertIsInstance(delimiter, bytes)
self.assertIsInstance(name, (str, bytes))

# Check if any flag is a special-use attribute
special_use_flags = [
b"\\Archive",
b"\\Drafts",
b"\\Flagged",
b"\\Junk",
b"\\Sent",
b"\\Trash",
b"\\All",
]
has_special_use = any(flag in special_use_flags for flag in flags)
if has_special_use:
# If we found a special-use folder, that's good evidence
# that the RFC 6154 implementation is working
break

# Test with pattern parameter
inbox_folders = self.client.list_special_folders("", "INBOX*")
self.assertIsInstance(inbox_folders, list)

def test_create_special_use_folder(self):
"""Test special-use folder creation against live server."""
self.skip_unless_capable("CREATE-SPECIAL-USE")

# Import the special-use constants
from imapclient import ARCHIVE, DRAFTS, SENT, TRASH

# Test folder names with timestamp to avoid conflicts
timestamp = str(int(time.time()))
test_folders = [
(f"TestSent_{timestamp}", SENT),
(f"TestDrafts_{timestamp}", DRAFTS),
(f"TestArchive_{timestamp}", ARCHIVE),
(f"TestTrash_{timestamp}", TRASH),
]

created_folders = []

try:
for folder_name, special_use in test_folders:
# Create the special-use folder
result = self.client.create_folder(
folder_name, special_use=special_use
)
self.assertIsInstance(result, str)
created_folders.append(folder_name)

# Verify the folder was created by listing it
folders = self.client.list_folders()
folder_names = [name for flags, delimiter, name in folders]
self.assertIn(folder_name, folder_names)

# If server supports SPECIAL-USE, verify the special-use attribute
if self.client.has_capability("SPECIAL-USE"):
special_folders = self.client.list_special_folders()

# Look for our created folder in special folders list
found_special = False
for flags, delimiter, name in special_folders:
if name == folder_name:
# Verify it has the expected special-use attribute
self.assertIn(special_use, flags)
found_special = True
break

# Note: Some servers may not immediately reflect the special-use
# attribute in LIST responses, so we don't fail if not found
if found_special:
print(f"Verified special-use attribute for {folder_name}")

finally:
# Clean up: delete test folders
for folder_name in created_folders:
try:
self.client.delete_folder(folder_name)
except IMAPClientError:
# Folder might already be deleted or not exist
pass

LiveTest.conf = conf
LiveTest.use_uid = use_uid

Expand Down
Loading