Skip to content
Closed
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
16 changes: 16 additions & 0 deletions app/api/v1/mail/ApiMailMailbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
MailboxBatchActionResponseSchema,
MailboxSearchSchema,
MailboxSearchResponseSchema,
MailboxTagsResponseSchema,
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -251,3 +252,18 @@ def post(self, search_params: dict, collection_param: "CollectionPaginateArgs",
logger_api.debug("Calling ApiMailBoxesAccountSearch.post for account_id: %s with params: %s", account_id, search_params)
interface: InterfaceApiMailMailbox = g.inter
return interface.search_mailbox(account_id, search_params, collection_param, deleted_query["deleted"])


@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 @@ -69,6 +69,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
30 changes: 30 additions & 0 deletions app/manager/mail/ClientImap.py
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,35 @@ def get_my_rights_raw_for_folders(self, folder_paths: list[str]) -> dict[str, st
rights_by_folder[folder_path] = self._get_my_rights_raw_or_empty(folder_path)
return rights_by_folder
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:
"""
Expand Down Expand Up @@ -1317,6 +1346,7 @@ def select_mailbox(self, mailbox: str, readonly: bool = False) -> int:
raise
logger_imap.info("Mailbox '%s' is read-only, selecting it with EXAMINE", mailbox)
success, datas = self._exec_imap4_method(self.connection.select, mailbox, True)
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 @@ -149,6 +149,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 @@ -176,6 +176,19 @@ def _convert_folders_rights(folders: list[dict[str, Any]]) -> None:
folder[cs.FOLDER_RIGHTS] = {right_to_camel(right): 1 for right, value in rights.items() if value}
ModuleMail._convert_folders_rights(folder.get(cs.FOLDER_CHILDREN, []))

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 @@ -89,6 +89,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 @@ -120,6 +121,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 @@ -1711,6 +1714,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