Skip to content

Commit 8e49b36

Browse files
committed
OP#2552 :add advanced search API
1 parent 4833efc commit 8e49b36

10 files changed

Lines changed: 960 additions & 13 deletions

File tree

app/api/v1/mail/ApiMailMailbox.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from app.interface.mail.InterfaceApiMailMailbox import InterfaceApiMailMailbox
99
from app.utils.logger.logger import logger_api
1010
from app.utils.api.ApiBaseResponse import ApiBaseResponse
11+
from app.utils.api.paginate_sort_filter import collection_paginate, CustomPaginateResponse
1112
from app.api.v1.mail.schemas.mailbox import (
1213
MailboxCreateSchema,
1314
MailboxUpdateSchema,
@@ -18,11 +19,14 @@
1819
DelegationResponseSchema,
1920
MailboxPurgeSchema,
2021
MailboxPurgeResponseSchema,
22+
MailboxSearchSchema,
23+
MailboxSearchResponseSchema,
2124
)
2225

2326
if TYPE_CHECKING:
2427
from app.config.settings.ProcessSetting import ProcessSetting
2528
from app.auth.User import User
29+
from app.utils.api.paginate_sort_filter import CollectionPaginateArgs
2630

2731
blp = Blueprint("Mail Account", __name__, url_prefix="/mailboxes")
2832

@@ -155,3 +159,34 @@ def post(self, purge_data: dict, account_id: str) -> ResponseReturnValue:
155159
interface: InterfaceApiMailMailbox = g.inter
156160
return interface.purge_mailbox(account_id, purge_data)
157161

162+
163+
@blp.route("/<string:account_id>/search")
164+
class ApiMailBoxesAccountSearch(MethodView):
165+
"""
166+
Resource: Advanced Mail Search
167+
"""
168+
@blp.arguments(MailboxSearchSchema, example=MailboxSearchSchema.example(), error_status_code=400)
169+
@blp.response(200, MailboxSearchResponseSchema)
170+
@collection_paginate(blp, can_sort=True, sort_value_set={"date", "relevance", "sender", "subject", "size"},
171+
can_filter=True, filter_value_set=None)
172+
def post(self, search_params: dict, collection_param: "CollectionPaginateArgs", account_id: str) -> CustomPaginateResponse:
173+
"""
174+
Advanced mail search across one or multiple folders.
175+
176+
* **text**: str, full text search in subject/sender/recipients/body
177+
* **folders**: list[str], list of folder paths to search in (e.g. ["INBOX", "Sent"] or ["all"] for all folders)
178+
* **date_range**: dict, date range for the search (e.g. {"from": "2023-01-01", "to": "2023-01-31"})
179+
* **has_attachments**: bool, whether to search for emails with attachments
180+
* **to**: list[str], list of recipient email addresses to search for
181+
* **from**: list[str], list of sender email addresses to search for
182+
* **subject** : str, keywords to search for in the email subject
183+
* **attachment_type**: list[str], list of attachment types to search for (e.g. ["pdf", "jpg"])
184+
* **is_read**: bool, whether to search for read or unread emails
185+
* **labels**: list[str], list of labels/tags to search for
186+
187+
All search criteria are optional and combined with AND logic.
188+
Pagination, sorting and field filtering are controlled via query parameters (page, page_size, sort_by, sort_order, fields, fields_action).
189+
"""
190+
logger_api.debug("Calling ApiMailBoxesAccountSearch.post for account_id: %s with params: %s", account_id, search_params)
191+
interface: InterfaceApiMailMailbox = g.inter
192+
return interface.search_mailbox(account_id, search_params, collection_param)

app/api/v1/mail/schemas/mailbox.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,3 +604,88 @@ def example(cls) -> dict:
604604
}
605605
}
606606

607+
class DateRangeSchema(Schema):
608+
"""
609+
Schema for date range filter in advanced search
610+
"""
611+
start = fields.String(required=False, allow_none=True, metadata={"description": "Start date in ISO 8601 format (e.g. 2026-05-01T00:00:00Z)"})
612+
end = fields.String(required=False, allow_none=True, metadata={"description": "End date in ISO 8601 format (e.g. 2026-05-19T23:59:59Z)"})
613+
614+
@classmethod
615+
def example(cls) -> dict:
616+
return {
617+
"start": "2026-05-01T00:00:00Z",
618+
"end": "2026-05-19T23:59:59Z"
619+
}
620+
621+
622+
class MailboxSearchSchema(Schema):
623+
"""
624+
Schema for POST /mailboxes/<account_id>/search - Advanced mail search.
625+
626+
All fields are optional. When multiple criteria are provided, they are combined with AND logic.
627+
"""
628+
text = fields.String(required=False, allow_none=True, load_default=None, metadata={"description": "Full-text search in body and headers"})
629+
from_ = fields.String(required=False, allow_none=True, load_default=None, data_key="from", metadata={"description": "Filter by sender email address"})
630+
to = fields.List(fields.String(), required=False, allow_none=True, load_default=None, metadata={"description": "Filter by recipient email addresses"})
631+
subject = fields.String(required=False, allow_none=True, load_default=None, metadata={"description": "Filter by subject (substring match)"})
632+
has_attachment = fields.Boolean(required=False, allow_none=True, load_default=None, metadata={"description": "Filter mails that have (or don't have) attachments"})
633+
attachment_type = fields.List(fields.String(), required=False, allow_none=True, load_default=None, metadata={"description": "Filter by attachment file extensions (e.g. ['pdf', 'jpg'])"})
634+
date_range = fields.Nested(DateRangeSchema, required=False, allow_none=True, load_default=None, metadata={"description": "Filter by date range"})
635+
is_read = fields.Boolean(required=False, allow_none=True, load_default=None, metadata={"description": "Filter by read/unread status"})
636+
is_flagged = fields.Boolean(required=False, allow_none=True, load_default=None, metadata={"description": "Filter by starred (flagged) status"})
637+
folders = fields.List(fields.String(), required=False, allow_none=True, load_default=None, metadata={"description": "Folders to search in (use ['all'] for entire mailbox)"})
638+
labels = fields.List(fields.String(), required=False, allow_none=True, load_default=None, metadata={"description": "Filter by IMAP keyword labels"})
639+
640+
@classmethod
641+
def example(cls) -> dict:
642+
"""Example data for advanced mail search.
643+
644+
:return: Example search payload
645+
:rtype: dict
646+
"""
647+
return {
648+
"text": "contrat urgent",
649+
"from": "customer@entreprise.com",
650+
"to": ["jdoe@domaine.com", "team@domaine.com"],
651+
"subject": "Projet X",
652+
"has_attachment": True,
653+
"attachment_type": ["pdf", "jpg"],
654+
"date_range": {
655+
"start": "2025-05-01T00:00:00Z",
656+
"end": "2026-05-19T23:59:59Z"
657+
},
658+
"is_read": False,
659+
"is_flagged": True,
660+
"folders": ["INBOX", "Archive"],
661+
"labels": ["important", "work"],
662+
}
663+
664+
665+
class MailboxSearchResponseSchema(ApiBaseResponse):
666+
"""
667+
Schema for the response of the advanced mail search endpoint.
668+
"""
669+
data = fields.Dict(required=False, allow_none=True, metadata={"description": "Search results with mails list and total count"})
670+
671+
@classmethod
672+
def example(cls) -> dict:
673+
return {
674+
"error_code": 0,
675+
"error_msg": "",
676+
"data": {
677+
"total": 2,
678+
"mails": [
679+
{
680+
"uid": "42",
681+
"subject": "Projet X - Contrat urgent",
682+
"from": {"name": "Client", "email": "client@entreprise.com"},
683+
"date": "Tue, 19 May 2026 10:00:00 +0000",
684+
"seen": False,
685+
"flagged": True,
686+
"has_attachment": True,
687+
"folder": "INBOX"
688+
}
689+
]
690+
}
691+
}

app/interface/mail/InterfaceApiMailMailbox.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
if TYPE_CHECKING:
1616
from app.config.settings.ProcessSetting import ProcessSetting
1717
from app.auth.User import User
18+
from app.utils.api.paginate_sort_filter import CollectionPaginateArgs
1819

1920

2021
class InterfaceApiMailMailbox:
@@ -297,3 +298,25 @@ def send_mail(self, account_id: str, mail_data: dict, draft_uid: str | None = No
297298
logger_api.warning("Failed to delete draft mail uid %s for user %s, account %s: %s", draft_uid, self.user.uid, account_id, str(ex))
298299

299300
return create_api_base_response(None)
301+
302+
def search_mailbox(self, account_id: str, search_params: dict, collection_param: "CollectionPaginateArgs") -> tuple[int, dict, int]:
303+
"""Advanced mail search across one or multiple folders for the given account.
304+
305+
:param account_id: The account identifier ("0" for main account)
306+
:type account_id: str
307+
:param search_params: Validated search parameters (from MailboxSearchSchema)
308+
:type search_params: dict
309+
:param collection_param: Pagination, sorting and filtering parameters.
310+
:type collection_param: CollectionPaginateArgs
311+
:return: A tuple of (total_count, API response dict, status code)
312+
:rtype: tuple[int, dict, int]
313+
"""
314+
if account_id != cs.DEFAULT_IDENTITY_KEY_VALUE and not self.user_module_settings.SOGO_D_ALLOW_EXT_MAIL_ACCOUNT:
315+
return 0, *create_api_base_response(error=err.ERROR_EXTERNAL_ACCOUNT_FORBIDDEN)
316+
317+
try:
318+
result, total = self.mail_module.search_mails(account_id, search_params, collection_param)
319+
except RequestException as ex:
320+
logger_api.error("Request exception in search_mailbox for user %s, account %s: %s", self.user.uid, account_id, str(ex))
321+
return 0, *create_api_base_response(None, ex.error)
322+
return total, *create_api_base_response(result)

app/manager/mail/ClientImap.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1934,6 +1934,136 @@ def delete_mail_permanently_from_folder_type(self, folder_type: str, mail_uid: s
19341934
folder_path = self.folders_map_type_to_name[folder_type]
19351935
self.delete_mails_by_uid(folder_path, mail_uid, move_to_trash=False, permanently=True)
19361936

1937+
def _search_uids_in_folder(self, folder_path: str, criteria: str) -> str | None:
1938+
"""Execute an IMAP SEARCH in a single folder and return the UID set string, or None if no results.
1939+
1940+
:param folder_path: IMAP folder path to search in.
1941+
:type folder_path: str
1942+
:param criteria: IMAP SEARCH criteria string.
1943+
:type criteria: str
1944+
:raises RequestException: If the SEARCH command fails.
1945+
:raises BugException: If not authenticated.
1946+
:return: Space-separated UID string, or None if no matches.
1947+
:rtype: str | None
1948+
"""
1949+
if not folder_path.isascii():
1950+
raise RequestException(f"Mailbox name is not ascii: {folder_path}", err.ERROR_IMAP_NOT_ASCII)
1951+
1952+
try:
1953+
self.select_mailbox(folder_path, readonly=True)
1954+
except RequestException:
1955+
logger_imap.warning("Folder '%s' not found or not selectable, skipping", folder_path)
1956+
return None
1957+
1958+
success, datas = self._exec_imap4_method(self.connection.uid, 'SEARCH', criteria)
1959+
if not success:
1960+
raise RequestException(
1961+
f"IMAP SEARCH failed in folder '{folder_path}' with criteria: {criteria}",
1962+
err.ERROR_MAIL_SEARCH_FAILED
1963+
)
1964+
1965+
if not datas or not datas[0]:
1966+
return None
1967+
1968+
uid_set = datas[0].decode().strip()
1969+
return uid_set if uid_set else None
1970+
1971+
def search_mails_without_content(self, folders: list[str], criteria: str) -> Iterator[tuple[str, dict]]:
1972+
"""Execute an IMAP SEARCH with the given criteria string on each folder and
1973+
fetch the matching mails (headers only, no body content).
1974+
1975+
Yields tuples of (folder_path, mail_dict) for every matching mail across
1976+
all requested folders. ``mail_dict`` has the same shape as
1977+
``_parse_mail_without_content_fetching`` output, enriched with
1978+
``"folder"`` (the IMAP folder path).
1979+
1980+
:param folders: List of IMAP folder paths to search in.
1981+
:type folders: list[str]
1982+
:param criteria: IMAP SEARCH criteria string
1983+
:type criteria: str
1984+
:raises RequestException: If a SEARCH or FETCH command fails.
1985+
:raises BugException: If not authenticated.
1986+
:return: Yields (folder_path, mail_dict) tuples.
1987+
:rtype: Iterator[tuple[str, dict]]
1988+
"""
1989+
logger_imap.debug("Searching mails (without content) in folders %s with criteria: %s", folders, criteria)
1990+
if self.connection is None or not self.authenticated:
1991+
raise BugException("Not authenticated meaning self.connect() and self.login() was not called beforehands")
1992+
1993+
for folder_path in folders:
1994+
uid_set = self._search_uids_in_folder(folder_path, criteria)
1995+
if uid_set is None:
1996+
continue
1997+
1998+
# Fetch headers + bodystructure for all matching UIDs in one round-trip
1999+
success, fetch_datas = self._exec_imap4_method(
2000+
self.connection.uid, 'FETCH', uid_set.replace(' ', ','),
2001+
'(BODY.PEEK[HEADER] BODYSTRUCTURE FLAGS UID RFC822.SIZE)'
2002+
)
2003+
if not success:
2004+
raise RequestException(
2005+
f"IMAP FETCH failed in folder '{folder_path}' for UIDs {uid_set}",
2006+
err.ERROR_MAIL_SEARCH_FAILED
2007+
)
2008+
2009+
for i in range(len(fetch_datas) - 1, -1, -2):
2010+
pair = fetch_datas[i - 1:i + 1]
2011+
if len(pair) < 2:
2012+
continue
2013+
bodystruct = pair[1]
2014+
message_parts = cast(tuple[bytes, bytes], pair[0])
2015+
if not isinstance(message_parts, tuple):
2016+
continue
2017+
has_attachment = self._parse_body_structure_for_attachment(bodystruct)
2018+
mail_dict = self._parse_mail_without_content_fetching(message_parts, has_attachment)
2019+
mail_dict["folder"] = folder_path
2020+
yield folder_path, mail_dict
2021+
2022+
def search_mails_with_content(self, folders: list[str], criteria: str) -> Iterator[tuple[str, dict]]:
2023+
"""Execute an IMAP SEARCH with the given criteria string on each folder and
2024+
fetch the matching mails with full body content.
2025+
2026+
Yields tuples of (folder_path, mail_dict) for every matching mail across
2027+
all requested folders. ``mail_dict`` has the same shape as
2028+
``_parse_mail_with_content_fetching`` output, enriched with
2029+
``"folder"`` (the IMAP folder path).
2030+
2031+
:param folders: List of IMAP folder paths to search in.
2032+
:type folders: list[str]
2033+
:param criteria: IMAP SEARCH criteria string
2034+
:type criteria: str
2035+
:raises RequestException: If a SEARCH or FETCH command fails.
2036+
:raises BugException: If not authenticated.
2037+
:return: Yields (folder_path, mail_dict) tuples.
2038+
:rtype: Iterator[tuple[str, dict]]
2039+
"""
2040+
logger_imap.debug("Searching mails (with content) in folders %s with criteria: %s", folders, criteria)
2041+
if self.connection is None or not self.authenticated:
2042+
raise BugException("Not authenticated meaning self.connect() and self.login() was not called beforehands")
2043+
2044+
for folder_path in folders:
2045+
uid_set = self._search_uids_in_folder(folder_path, criteria)
2046+
if uid_set is None:
2047+
continue
2048+
2049+
# Fetch full body for all matching UIDs in one round-trip
2050+
success, fetch_datas = self._exec_imap4_method(
2051+
self.connection.uid, 'FETCH', uid_set.replace(' ', ','),
2052+
'(BODY.PEEK[] FLAGS UID)'
2053+
)
2054+
if not success:
2055+
raise RequestException(
2056+
f"IMAP FETCH failed in folder '{folder_path}' for UIDs {uid_set}",
2057+
err.ERROR_MAIL_SEARCH_FAILED
2058+
)
2059+
2060+
for part in fetch_datas:
2061+
if not isinstance(part, tuple):
2062+
continue
2063+
mail_dict = self._parse_mail_with_content_fetching(part)
2064+
mail_dict["folder"] = folder_path
2065+
yield folder_path, mail_dict
2066+
19372067
def logout(self) -> None:
19382068
"""
19392069
Log out from the IMAP server.

app/manager/mail/ClientMailServer.py

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -338,15 +338,32 @@ def save_draft(self, message: EmailMessage, uid: str | None = None) -> dict[str,
338338

339339
@abstractmethod
340340
def get_quota(self) -> dict[str, Any] | None:
341-
"""Get quota information for the mailbox.
342-
343-
Uses the IMAP GETQUOTAROOT command on the inbox folder.
344-
Returns None if the server does not support QUOTA or the command is unavailable.
345-
346-
:return: Dictionary containing quota info, or None if unavailable:
347-
{
348-
"storage_used": int, # storage used in KB
349-
"storage_limit": int, # storage limit in KB (0 if unlimited)
350-
}
351-
:rtype: dict[str, Any] | None
352-
"""
341+
"""Get quota information for the mailbox."""
342+
343+
@abstractmethod
344+
def search_mails_without_content(self, folders: list[str], criteria: str) -> Iterator[tuple[str, dict]]:
345+
"""Search mails (headers only, no body) across the given folders.
346+
347+
Yields tuples of (folder_path, mail_dict) for every matching mail.
348+
349+
:param folders: List of folder paths to search in.
350+
:type folders: list[str]
351+
:param criteria: IMAP SEARCH criteria string.
352+
:type criteria: str
353+
:return: Yields (folder_path, mail_dict) tuples.
354+
:rtype: Iterator[tuple[str, dict]]
355+
"""
356+
357+
@abstractmethod
358+
def search_mails_with_content(self, folders: list[str], criteria: str) -> Iterator[tuple[str, dict]]:
359+
"""Search mails with full body content across the given folders.
360+
361+
Yields tuples of (folder_path, mail_dict) for every matching mail.
362+
363+
:param folders: List of folder paths to search in.
364+
:type folders: list[str]
365+
:param criteria: IMAP SEARCH criteria string.
366+
:type criteria: str
367+
:return: Yields (folder_path, mail_dict) tuples.
368+
:rtype: Iterator[tuple[str, dict]]
369+
"""

0 commit comments

Comments
 (0)