Skip to content

Commit 906bbab

Browse files
committed
refactor API search code
1 parent c2a1646 commit 906bbab

3 files changed

Lines changed: 111 additions & 66 deletions

File tree

app/manager/mail/ClientImap.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from app.manager.mail.ClientMailServer import ClientMailServer
1717
from app.utils import errors as err
1818
from app.utils import constants as cs
19-
from app.utils.strings import quote, imap_join_folders
19+
from app.utils.strings import quote, imap_join_folders, escape_imap_string
2020

2121
# Maximum debug output from imaplib
2222
#TODO all imap are logged, including login/auth password used SecretString (on ldap branch not in develoope now)
@@ -1978,6 +1978,75 @@ def delete_mail_permanently_from_folder_type(self, folder_type: str, mail_uid: s
19781978
folder_path = self.folders_map_type_to_name[folder_type]
19791979
self.delete_mails_by_uid(folder_path, mail_uid, move_to_trash=False, permanently=True)
19801980

1981+
def build_search_criteria(self, search_params: dict, include_deleted: bool) -> str:
1982+
"""Build an IMAP SEARCH criteria string from the generic search_params dict.
1983+
1984+
:param search_params: Validated search parameters (from MailboxSearchSchema).
1985+
:type search_params: dict
1986+
:param include_deleted: Whether mails flagged \\Deleted should be included.
1987+
:type include_deleted: bool
1988+
:raises RequestException: If a date value cannot be parsed.
1989+
:return: IMAP SEARCH criteria string (e.g. "(NOT DELETED SUBJECT \"foo\")" or "ALL").
1990+
:rtype: str
1991+
"""
1992+
criteria_parts: list[str] = []
1993+
if not include_deleted:
1994+
criteria_parts.append("NOT DELETED")
1995+
1996+
if search_params.get("text"):
1997+
criteria_parts.append(f'TEXT "{search_params["text"]}"')
1998+
1999+
if search_params.get("from_"):
2000+
escaped = escape_imap_string(search_params["from_"])
2001+
criteria_parts.append(f'FROM "{escaped}"')
2002+
2003+
if search_params.get("to"):
2004+
for addr in search_params["to"]:
2005+
criteria_parts.append(f'TO "{addr}"')
2006+
2007+
if search_params.get("subject"):
2008+
criteria_parts.append(f'SUBJECT "{search_params["subject"]}"')
2009+
2010+
if search_params.get("is_read") is True:
2011+
criteria_parts.append("SEEN")
2012+
elif search_params.get("is_read") is False:
2013+
criteria_parts.append("UNSEEN")
2014+
2015+
if search_params.get("is_flagged") is True:
2016+
criteria_parts.append("FLAGGED")
2017+
elif search_params.get("is_flagged") is False:
2018+
criteria_parts.append("UNFLAGGED")
2019+
2020+
if search_params.get("has_attachment") is True:
2021+
criteria_parts.append('HEADER Content-Type "multipart/mixed"')
2022+
2023+
if search_params.get("labels"):
2024+
for label in search_params["labels"]:
2025+
criteria_parts.append(f'KEYWORD "{label}"')
2026+
2027+
if search_params.get("date_range"):
2028+
date_range = search_params["date_range"]
2029+
if date_range.get("start"):
2030+
try:
2031+
dt = datetime.fromisoformat(date_range["start"].replace("Z", "+00:00"))
2032+
criteria_parts.append(f'SINCE {dt.strftime("%d-%b-%Y")}')
2033+
except (ValueError, AttributeError) as exc:
2034+
raise RequestException(
2035+
f"Invalid start date: {date_range['start']}",
2036+
err.ERROR_MAIL_SEARCH_INVALID_DATE
2037+
) from exc
2038+
if date_range.get("end"):
2039+
try:
2040+
dt = datetime.fromisoformat(date_range["end"].replace("Z", "+00:00"))
2041+
criteria_parts.append(f'BEFORE {dt.strftime("%d-%b-%Y")}')
2042+
except (ValueError, AttributeError) as exc:
2043+
raise RequestException(
2044+
f"Invalid end date: {date_range['end']}",
2045+
err.ERROR_MAIL_SEARCH_INVALID_DATE
2046+
) from exc
2047+
2048+
return "(" + " ".join(criteria_parts) + ")" if criteria_parts else "ALL"
2049+
19812050
def _search_uids_in_folder(self, folder_path: str, criteria: str) -> str | None:
19822051
"""Execute an IMAP SEARCH in a single folder and return the UID set string, or None if no results.
19832052

app/manager/mail/ClientMailServer.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,28 @@ def parse_fields_param(fields: str | None, fields_action: str | None) -> dict[st
6262

6363
return {"with_content": with_content, "include_deleted": include_deleted}
6464

65+
@abstractmethod
66+
def build_search_criteria(self, search_params: dict, include_deleted: bool) -> Any:
67+
"""Build a protocol-specific search criteria object/string from the generic,
68+
protocol-agnostic ``search_params`` dict (as validated by MailboxSearchSchema).
69+
70+
This keeps every bit of protocol-specific search syntax (IMAP SEARCH syntax,
71+
JMAP filter objects, ...) confined to the concrete client implementation, so
72+
that callers (e.g. ModuleMail) stay protocol agnostic.
73+
74+
:param search_params: Validated search parameters (from MailboxSearchSchema),
75+
with keys like "text", "from_", "to", "subject", "is_read", "is_flagged",
76+
"has_attachment", "labels", "date_range".
77+
:type search_params: dict
78+
:param include_deleted: Whether mails flagged as deleted should be included.
79+
:type include_deleted: bool
80+
:raises RequestException: If a value in search_params cannot be translated
81+
(e.g. an invalid date).
82+
:return: A protocol-specific criteria value to pass to search_mails_with_content
83+
/ search_mails_without_content.
84+
:rtype: Any
85+
"""
86+
6587
@abstractmethod
6688
def connect(self) -> None:
6789
"""Connect to the mail server."""
@@ -390,7 +412,18 @@ def save_draft(self, message: EmailMessage, uid: str | None = None) -> dict[str,
390412

391413
@abstractmethod
392414
def get_quota(self) -> dict[str, Any] | None:
393-
"""Get quota information for the mailbox."""
415+
"""Get quota information for the mailbox.
416+
417+
Uses the IMAP GETQUOTAROOT command on the inbox folder.
418+
Returns None if the server does not support QUOTA or the command is unavailable.
419+
420+
:return: Dictionary containing quota info, or None if unavailable:
421+
{
422+
"storage_used": int, # storage used in KB
423+
"storage_limit": int, # storage limit in KB (0 if unlimited)
424+
}
425+
:rtype: dict[str, Any] | None
426+
"""
394427

395428
@abstractmethod
396429
def search_mails_without_content(self, folders: list[str], criteria: str) -> Iterator[tuple[str, dict]]:

app/module/mail/ModuleMail.py

Lines changed: 7 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import email as email_existing
55
import email.mime.text
66
import email.policy
7-
from datetime import datetime
87
from email.header import decode_header, make_header
98
from email.message import EmailMessage
109
from email.message import Message
@@ -23,7 +22,7 @@
2322
from app.utils.maths.crypto_utils import decrypt_password
2423
from app.utils.module.importManager import import_and_instantiate_manager
2524
from app.utils.logger.logger import logger_mail_server
26-
from app.utils.strings import get_imap_config_from_url, get_domain_from_mail, get_domain_from_contact, escape_imap_string
25+
from app.utils.strings import get_imap_config_from_url, get_domain_from_mail, get_domain_from_contact
2726
from app.utils.constants import DELETE_MAIL_BEHAVIOR_MAP
2827

2928
if TYPE_CHECKING:
@@ -717,9 +716,9 @@ def get_folder_mails(self, account_id: str, folder_name: str, collection_param:
717716
def search_mails(self, account_id: str, search_params: dict, collection_param: CollectionPaginateArgs) -> tuple[list[dict[str, Any]], int]:
718717
"""Execute an advanced search across one or multiple folders.
719718
720-
Builds an IMAP SEARCH criteria string from ``search_params``, queries
721-
each requested folder and returns a paginated list of matching mails together
722-
with the total count.
719+
Delegates the building of the protocol-specific search criteria (IMAP SEARCH
720+
syntax, JMAP filter, ...) to the mail client, queries each requested folder
721+
and returns a paginated list of matching mails together with the total count.
723722
724723
:param account_id: The account identifier.
725724
:type account_id: str
@@ -729,7 +728,7 @@ def search_mails(self, account_id: str, search_params: dict, collection_param: C
729728
:type collection_param: CollectionPaginateArgs
730729
:return: A tuple of (list of mail dicts, total count).
731730
:rtype: tuple[list[dict[str, Any]], int]
732-
:raises RequestException: If IMAP operations fail or dates are invalid.
731+
:raises RequestException: If mail server operations fail or search_params are invalid.
733732
"""
734733
client = self._open_client_for(account_id)
735734

@@ -738,64 +737,8 @@ def search_mails(self, account_id: str, search_params: dict, collection_param: C
738737
without_content = not fields_params["with_content"]
739738
include_deleted = fields_params["include_deleted"]
740739

741-
# --- Build IMAP SEARCH criteria ---
742-
criteria_parts: list[str] = []
743-
if not include_deleted:
744-
criteria_parts.append("NOT DELETED")
745-
746-
if search_params.get("text"):
747-
criteria_parts.append(f'TEXT "{search_params["text"]}"')
748-
749-
if search_params.get("from_"):
750-
escaped = escape_imap_string(search_params["from_"])
751-
criteria_parts.append(f'FROM "{escaped}"')
752-
753-
if search_params.get("to"):
754-
for addr in search_params["to"]:
755-
criteria_parts.append(f'TO "{addr}"')
756-
757-
if search_params.get("subject"):
758-
criteria_parts.append(f'SUBJECT "{search_params["subject"]}"')
759-
760-
if search_params.get("is_read") is True:
761-
criteria_parts.append("SEEN")
762-
elif search_params.get("is_read") is False:
763-
criteria_parts.append("UNSEEN")
764-
765-
if search_params.get("is_flagged") is True:
766-
criteria_parts.append("FLAGGED")
767-
elif search_params.get("is_flagged") is False:
768-
criteria_parts.append("UNFLAGGED")
769-
770-
if search_params.get("has_attachment") is True:
771-
criteria_parts.append('HEADER Content-Type "multipart/mixed"')
772-
773-
if search_params.get("labels"):
774-
for label in search_params["labels"]:
775-
criteria_parts.append(f'KEYWORD "{label}"')
776-
777-
if search_params.get("date_range"):
778-
date_range = search_params["date_range"]
779-
if date_range.get("start"):
780-
try:
781-
dt = datetime.fromisoformat(date_range["start"].replace("Z", "+00:00"))
782-
criteria_parts.append(f'SINCE {dt.strftime("%d-%b-%Y")}')
783-
except (ValueError, AttributeError) as exc:
784-
raise RequestException(
785-
f"Invalid start date: {date_range['start']}",
786-
err.ERROR_MAIL_SEARCH_INVALID_DATE
787-
) from exc
788-
if date_range.get("end"):
789-
try:
790-
dt = datetime.fromisoformat(date_range["end"].replace("Z", "+00:00"))
791-
criteria_parts.append(f'BEFORE {dt.strftime("%d-%b-%Y")}')
792-
except (ValueError, AttributeError) as exc:
793-
raise RequestException(
794-
f"Invalid end date: {date_range['end']}",
795-
err.ERROR_MAIL_SEARCH_INVALID_DATE
796-
) from exc
797-
798-
criteria = "(" + " ".join(criteria_parts) + ")" if criteria_parts else "ALL"
740+
# --- Build the protocol-specific search criteria (delegated to the client) ---
741+
criteria = client.build_search_criteria(search_params, include_deleted)
799742

800743
# --- Determine folders to search ---
801744
folder_list = search_params.get("folders") or []

0 commit comments

Comments
 (0)