Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions app/api/v1/mail/ApiMailMailbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
DelegationResponseSchema,
MailboxPurgeSchema,
MailboxPurgeResponseSchema,
MailboxTagsResponseSchema,
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -155,3 +156,17 @@ def post(self, purge_data: dict, account_id: str) -> ResponseReturnValue:
interface: InterfaceApiMailMailbox = g.inter
return interface.purge_mailbox(account_id, purge_data)


@blp.route("/<string:account_id>/tags")
class ApiMailBoxesAccountTags(MethodView):
"""
Resource: All mail tags of a mailbox
"""
@blp.response(200, MailboxTagsResponseSchema)
def get(self, account_id: str) -> ResponseReturnValue:
"""
List all distinct tags used across every mail in every folder of the specified mailbox
"""
logger_api.debug("Calling ApiMailBoxesAccountTags.get for account_id: %s", account_id)
interface: InterfaceApiMailMailbox = g.inter
return interface.get_mailbox_tags(account_id)
25 changes: 25 additions & 0 deletions app/api/v1/mail/schemas/mailbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,31 @@ def example(cls) -> dict:
}


class MailboxTagsResponseSchema(ApiBaseResponse):
"""
Schema for response when listing all mail tags of the mailbox
The 'data' field contains a sorted list of tag names
"""
data = fields.List(fields.String(), required=False, allow_none=True)

@classmethod
def example(cls) -> dict:
"""Example response for listing mail tags.

:return: Example mail tags list response
:rtype: dict
"""
return {
"error_code": 0,
"error_msg": "",
"data": [
"Facture",
"Projet-X",
"Urgent"
]
}


class DelegationSchema(Schema):
"""
Schema for a single delegation entry
Expand Down
18 changes: 18 additions & 0 deletions app/interface/mail/InterfaceApiMailMailbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,24 @@ def list_mailboxes(self) -> tuple[dict[str, Any], int]:

return create_api_base_response(list_accounts)

def get_mailbox_tags(self, account_id: str) -> tuple[dict, int]:
"""Get all distinct tags used across every mail in every folder of the specified mailbox.

:param account_id: The account identifier ("0" for main account, hash for external)
:type account_id: str
:return: A tuple of (API response dict, status code)
:rtype: tuple[dict, int]
"""
if account_id != cs.DEFAULT_IDENTITY_KEY_VALUE and not self.user_module_settings.SOGO_D_ALLOW_EXT_MAIL_ACCOUNT:
return create_api_base_response(error=err.ERROR_EXTERNAL_ACCOUNT_FORBIDDEN)

try:
tags = self.mail_module.get_all_mail_tags(account_id)
return create_api_base_response(tags)
except RequestException as ex:
logger_api.error("Request exception in get_mailbox_tags for user %s, account %s: %s", self.user.uid, account_id, str(ex))
return create_api_base_response(None, ex.error)

def create_mailbox(self, account_data: dict) -> tuple[dict, int]:
"""Create a new mailbox (add external account).

Expand Down
32 changes: 31 additions & 1 deletion app/manager/mail/ClientImap.py
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,36 @@ def list_folders(self) -> list[dict[str, Any]]:
else:
raise BugException("Not authenticated meaning self.connect() and self.login() was not called beforehands")

def get_all_tags(self) -> set[str]:
"""List all distinct tags (IMAP flags/keywords) used across every mail of every folder.

For each selectable folder, EXAMINE (read-only SELECT) is used to retrieve the
untagged FLAGS response, without fetching any message. The cost is therefore
proportional to the number of folders, not to the number of messages.

:return: Set of tag names.
:rtype: set[str]
:raises RequestException: If not connected to the server.
"""
if self.connection is not None and self.authenticated:
tags: set[str] = set()
for folder in self._imap_list_folders():
if not folder.can_be_select:
continue
try:
self.select_mailbox(folder.path, readonly=True)
except RequestException as e:
logger_imap.warning("get_all_tags: could not examine folder '%s': %s", folder.path, e)
continue
_, flags_data = self.connection.response('FLAGS')
for raw_flags in flags_data:
if not raw_flags:
continue
tags.update(raw_flags.decode().strip('()').split())
return tags
else:
raise BugException("Not authenticated meaning self.connect() and self.login() was not called beforehands")

def _imap_create_folder(self, folder_path: str, auto_sub:bool = True, no_error_if_exist:bool = False) -> None:
"""
Create a new folder (mailbox) on the IMAP server.
Expand Down Expand Up @@ -1067,7 +1097,7 @@ def select_mailbox(self, mailbox: str, readonly: bool = False) -> int:
if not mailbox.isascii():
raise RequestException(f"Mailbox name is not ascii: {mailbox}", err.ERROR_IMAP_NOT_ASCII)
mailbox = quote(self._fix_folder_path(mailbox))
success, datas = self._exec_imap4_method(self.connection.select, mailbox)
success, datas = self._exec_imap4_method(self.connection.select, mailbox, readonly)
if not success:
if datas[0].decode().startswith("Mailbox doesn't exist"):
raise RequestException(f"Folder '{mailbox}' does not exist", err.ERROR_FOLDER_NAME_NOT_FOUND)
Expand Down
12 changes: 12 additions & 0 deletions app/manager/mail/ClientMailServer.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ def get_one_folder(self, folder_path: str) -> dict[str, Any]:
}
"""

@abstractmethod
def get_all_tags(self) -> set[str]:
"""List all distinct tags (flags/keywords) used across every mail of every folder.

Implementations should avoid iterating over every mail; the cost should be
proportional to the number of folders, not to the number of messages.

:return: Set of tag names.
:rtype: set[str]
:raises RequestException: If the operation fails.
"""

@abstractmethod
def create_folder(self, folder_name: str, parent_path: str = "", auto_sub:bool = True) -> str:
"""
Expand Down
13 changes: 13 additions & 0 deletions app/module/mail/ModuleMail.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,19 @@ def get_folder_list(self, account_id:str) -> list[dict[str, Any]]:

return client.list_folders()

def get_all_mail_tags(self, account_id:str) -> list[str]:
"""Retrieve the sorted list of all distinct tags used across every mail of every folder.

:param account_id: The account identifier ("0" for main, hash for external)
:type account_id: str
:return: Sorted list of tag names.
:rtype: list[str]
:raises RequestException: If connection or manager operations fail
"""
client = self._open_client_for(account_id)

return sorted(client.get_all_tags())

def get_one_folder(self, account_id:str, folder_path: str) -> dict[str, Any]:
"""Retrieve details of a specific mail folder.

Expand Down
45 changes: 45 additions & 0 deletions tests/test_manager/test_mail/test_clientImap.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ def __init__(self):
self.deleteacl_response = ("OK", [b""])
self.status_response = ("OK", [b"INBOX (MESSAGES 10 UNSEEN 2)"])
self.namespace_response = ("OK", [b'(("" ".")) NIL NIL'])
self.flags_response = ("OK", [b'(\\Answered \\Flagged \\Deleted \\Seen \\Draft)'])
self.fetch_response = (
"OK",
[
Expand Down Expand Up @@ -118,6 +119,8 @@ def namespace(self):
def response(self, name):
if name == "CAPABILITY":
return ("OK", [b"IMAP4rev1 LIST-EXTENDED LIST-STATUS ACL"])
if name == "FLAGS":
return self.flags_response
return ("OK", [None])

# --- folders ---
Expand Down Expand Up @@ -1443,6 +1446,48 @@ def test_list_folders_not_authenticated_raises(self):
client.list_folders()


# ===========================================================================
# Tests: get_all_tags
# ===========================================================================

class TestGetAllTags:
def test_get_all_tags_returns_all_flags(self):
fake_conn = FakeIMAPConnection()
fake_conn.list_response = ("OK", [b'(\\HasNoChildren) "." "INBOX"'])
fake_conn.flags_response = ("OK", [b'(\\Answered \\Flagged \\Deleted \\Seen \\Draft Urgent Projet-X)'])
client = authenticated_client(fake_conn)

tags = client.get_all_tags()
assert tags == {"\\Answered", "\\Flagged", "\\Deleted", "\\Seen", "\\Draft", "Urgent", "Projet-X"}

def test_get_all_tags_unions_across_folders(self):
fake_conn = FakeIMAPConnection()
fake_conn.list_response = ("OK", [
b'(\\HasNoChildren) "." "INBOX"',
b'(\\HasNoChildren) "." "Sent"',
])
fake_conn.flags_response = ("OK", [b'(\\Seen Urgent)'])
client = authenticated_client(fake_conn)

tags = client.get_all_tags()
assert tags == {"\\Seen", "Urgent"}

def test_get_all_tags_skips_unselectable_folders(self):
fake_conn = FakeIMAPConnection()
fake_conn.list_response = ("OK", [b'(\\Noselect) "." "AllMail"'])
fake_conn.flags_response = ("OK", [b'(\\Seen Urgent)'])
client = authenticated_client(fake_conn)

tags = client.get_all_tags()
assert tags == set()

def test_get_all_tags_not_authenticated_raises_bug_exception(self):
client = make_client()
client.connection = None
with pytest.raises(BugException):
client.get_all_tags()


# ===========================================================================
# Tests: _imap_create_folder
# ===========================================================================
Expand Down
Loading