From eb5b79386842bc967adb8eff6af424490b4c39f3 Mon Sep 17 00:00:00 2001 From: tkeriven Date: Fri, 11 Sep 2026 15:08:32 +0200 Subject: [PATCH] OP#2415 : add API GET all tags --- app/api/v1/mail/ApiMailMailbox.py | 15 +++++++ app/api/v1/mail/schemas/mailbox.py | 25 +++++++++++ app/interface/mail/InterfaceApiMailMailbox.py | 18 ++++++++ app/manager/mail/ClientImap.py | 32 ++++++++++++- app/manager/mail/ClientMailServer.py | 12 +++++ app/module/mail/ModuleMail.py | 13 ++++++ .../test_manager/test_mail/test_clientImap.py | 45 +++++++++++++++++++ 7 files changed, 159 insertions(+), 1 deletion(-) diff --git a/app/api/v1/mail/ApiMailMailbox.py b/app/api/v1/mail/ApiMailMailbox.py index 2bfbc290..6f570a1c 100644 --- a/app/api/v1/mail/ApiMailMailbox.py +++ b/app/api/v1/mail/ApiMailMailbox.py @@ -18,6 +18,7 @@ DelegationResponseSchema, MailboxPurgeSchema, MailboxPurgeResponseSchema, + MailboxTagsResponseSchema, ) if TYPE_CHECKING: @@ -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("//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) diff --git a/app/api/v1/mail/schemas/mailbox.py b/app/api/v1/mail/schemas/mailbox.py index a53274f9..5a458484 100644 --- a/app/api/v1/mail/schemas/mailbox.py +++ b/app/api/v1/mail/schemas/mailbox.py @@ -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 diff --git a/app/interface/mail/InterfaceApiMailMailbox.py b/app/interface/mail/InterfaceApiMailMailbox.py index 24934126..d9c34e3f 100644 --- a/app/interface/mail/InterfaceApiMailMailbox.py +++ b/app/interface/mail/InterfaceApiMailMailbox.py @@ -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). diff --git a/app/manager/mail/ClientImap.py b/app/manager/mail/ClientImap.py index 1ef080f1..2f7a3b95 100644 --- a/app/manager/mail/ClientImap.py +++ b/app/manager/mail/ClientImap.py @@ -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. @@ -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) diff --git a/app/manager/mail/ClientMailServer.py b/app/manager/mail/ClientMailServer.py index 79fd8ef2..c5a32b4c 100644 --- a/app/manager/mail/ClientMailServer.py +++ b/app/manager/mail/ClientMailServer.py @@ -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: """ diff --git a/app/module/mail/ModuleMail.py b/app/module/mail/ModuleMail.py index a5d9ee16..b62386da 100644 --- a/app/module/mail/ModuleMail.py +++ b/app/module/mail/ModuleMail.py @@ -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. diff --git a/tests/test_manager/test_mail/test_clientImap.py b/tests/test_manager/test_mail/test_clientImap.py index d0680066..b1d5ef69 100644 --- a/tests/test_manager/test_mail/test_clientImap.py +++ b/tests/test_manager/test_mail/test_clientImap.py @@ -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", [ @@ -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 --- @@ -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 # ===========================================================================