Skip to content

Commit a61a54a

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

12 files changed

Lines changed: 1149 additions & 27 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={"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+
* **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/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: 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:
@@ -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)