Skip to content

Commit be25fa1

Browse files
committed
OP#2552 : add advanced search API and add deleted mail option in paginate decorator
1 parent 4d498b8 commit be25fa1

13 files changed

Lines changed: 1393 additions & 27 deletions

File tree

app/api/v1/mail/ApiMailMailbox.py

Lines changed: 39 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,38 @@ 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={"contents", "deleted"})
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+
* **operator**: str, 'AND' (default) or 'OR' - how the criteria below are combined.
177+
With 'AND' every provided criterion must match, with 'OR' at least one must match.
178+
* **text**: str, full text search in subject/sender/recipients/body
179+
* **folders**: list[str], list of folder paths to search in (e.g. ["INBOX", "Sent"] or ["all"] for all folders)
180+
* **include_subfolders**: bool, default True - when True, also search the subfolders of each folder listed in "folders"; when False, search only the exact folders listed. Ignored when "folders" is empty or ["all"].
181+
* **date_range**: dict, date range for the search (e.g. {"from": "2023-01-01", "to": "2023-01-31"})
182+
* **has_attachments**: bool, whether to search for emails with attachments
183+
* **to**: str, email address to search for in either the recipient (To) or copy (Cc) headers
184+
* **bcc**: str, blind copy (Bcc) email address to search for
185+
* **from**: list[str], list of sender email addresses to search for
186+
* **subject** : str, keywords to search for in the email subject
187+
* **attachment_type**: list[str], list of attachment types to search for (e.g. ["pdf", "jpg"])
188+
* **is_read**: bool, whether to search for read or unread emails
189+
* **labels**: list[str], list of labels/tags to search for
190+
191+
All search criteria are optional and combined using the "operator" field (AND by default, OR to match any criterion).
192+
Pagination, sorting and field filtering are controlled via query parameters (page, page_size, sort_by, sort_order, fields, fields_action).
193+
"""
194+
logger_api.debug("Calling ApiMailBoxesAccountSearch.post for account_id: %s with params: %s", account_id, search_params)
195+
interface: InterfaceApiMailMailbox = g.inter
196+
return interface.search_mailbox(account_id, search_params, collection_param)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ def filter_by_values() -> set:
217217
"""
218218
return values available for sorting by
219219
"""
220-
return {"contents"}
220+
return {"contents", "deleted"}
221221

222222
@classmethod
223223
def example(cls) -> dict:

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

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,3 +604,102 @@ 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
627+
using the "operator" field: "AND" (default, every criterion must match) or
628+
"OR" (at least one criterion must match).
629+
"""
630+
operator = fields.String(
631+
required=False,
632+
allow_none=True,
633+
load_default="AND",
634+
validate=validate.OneOf(["AND", "OR"]),
635+
metadata={"description": "Logical operator combining the search criteria below: 'AND' (default) requires every provided criterion to match, 'OR' requires at least one to match"}
636+
)
637+
text = fields.String(required=False, allow_none=True, load_default=None, metadata={"description": "Full-text search in body and headers"})
638+
from_ = fields.String(required=False, allow_none=True, load_default=None, data_key="from", metadata={"description": "Filter by sender email address"})
639+
to = fields.String(required=False, allow_none=True, load_default=None, metadata={"description": "Filter by recipient email address (matches either the To or the Cc header)"})
640+
bcc = fields.String(required=False, allow_none=True, load_default=None, metadata={"description": "Filter by Bcc recipient email address"})
641+
subject = fields.String(required=False, allow_none=True, load_default=None, metadata={"description": "Filter by subject (substring match)"})
642+
has_attachment = fields.Boolean(required=False, allow_none=True, load_default=None, metadata={"description": "Filter mails that have (or don't have) attachments"})
643+
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'])"})
644+
date_range = fields.Nested(DateRangeSchema, required=False, allow_none=True, load_default=None, metadata={"description": "Filter by date range"})
645+
is_read = fields.Boolean(required=False, allow_none=True, load_default=None, metadata={"description": "Filter by read/unread status"})
646+
is_flagged = fields.Boolean(required=False, allow_none=True, load_default=None, metadata={"description": "Filter by starred (flagged) status"})
647+
folders = fields.List(fields.String(), required=False, allow_none=True, load_default=None, metadata={"description": "Folders to search in (use ['all'] for entire mailbox)"})
648+
include_subfolders = fields.Boolean(required=False, allow_none=True, load_default=True, metadata={"description": "If True (default), also search in the subfolders of each folder listed in 'folders'. If False, search only in the exact folders listed"})
649+
labels = fields.List(fields.String(), required=False, allow_none=True, load_default=None, metadata={"description": "Filter by IMAP keyword labels"})
650+
651+
@classmethod
652+
def example(cls) -> dict:
653+
"""Example data for advanced mail search.
654+
655+
:return: Example search payload
656+
:rtype: dict
657+
"""
658+
return {
659+
"operator": "AND",
660+
"text": "contrat urgent",
661+
"from": "customer@entreprise.com",
662+
"to": "jdoe@domaine.com",
663+
"bcc": "hidden@domaine.com",
664+
"subject": "Projet X",
665+
"has_attachment": True,
666+
"attachment_type": ["pdf", "jpg"],
667+
"date_range": {
668+
"start": "2025-05-01T00:00:00Z",
669+
"end": "2026-05-19T23:59:59Z"
670+
},
671+
"is_read": False,
672+
"is_flagged": True,
673+
"folders": ["INBOX", "Archive"],
674+
"include_subfolders": True,
675+
"labels": ["important", "work"],
676+
}
677+
678+
679+
class MailboxSearchResponseSchema(ApiBaseResponse):
680+
"""
681+
Schema for the response of the advanced mail search endpoint.
682+
"""
683+
data = fields.Dict(required=False, allow_none=True, metadata={"description": "Search results with mails list and total count"})
684+
685+
@classmethod
686+
def example(cls) -> dict:
687+
return {
688+
"error_code": 0,
689+
"error_msg": "",
690+
"data": {
691+
"total": 2,
692+
"mails": [
693+
{
694+
"uid": "42",
695+
"subject": "Projet X - Contrat urgent",
696+
"from": {"name": "Client", "email": "client@entreprise.com"},
697+
"date": "Tue, 19 May 2026 10:00:00 +0000",
698+
"seen": False,
699+
"flagged": True,
700+
"has_attachment": True,
701+
"folder": "INBOX"
702+
}
703+
]
704+
}
705+
}

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:
@@ -296,3 +297,25 @@ def send_mail(self, account_id: str, mail_data: dict, draft_uid: str | None = No
296297
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))
297298

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

0 commit comments

Comments
 (0)