Skip to content

Commit bef485a

Browse files
authored
✨(search) improve recipient search filters (#476)
- `to` query key now looks up within `to`, `cc` and `bcc` message fields - Add a `to_exact` query key to looks up only in `to` message field Resolve #467
1 parent 812cf51 commit bef485a

5 files changed

Lines changed: 84 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ and this project adheres to
1616
- Add `is_trashed` flag to thread model
1717
- Add to select multiple threads in thread panel
1818
- Add image proxy endpoint to display external images in messages
19+
- Add `to_exact` modifier to search query
1920

2021
### Changed
2122

2223
- Configure Drive App Name through environment variable (DRIVE_APP_NAME)
2324
- Inherit OIDC Authentication backend from django-lasuite #408
2425
- Exclude `is_trashed` and `is_spam` threads from search results by default
26+
- `to` search modifier now looks for messages where recipient fields (to, cc, bcc) contain the given email address.
2527

src/backend/core/services/search/parse.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ def parse_search_query(query: str) -> Dict[str, Any]:
1010
1111
Supports Gmail-style modifiers:
1212
- from: (de:) - sender email/name
13-
- to: (a:) - recipient
13+
- to: (a:) - recipient (includes `to`, `cc`, `bcc` fields)
14+
- to_exact: (a_exact:) - exact recipient match (`to` field only)
1415
- cc: (copie:) - carbon copy
1516
- bcc: (cci:) - blind carbon copy
1617
- subject: (sujet:) - subject text
@@ -41,6 +42,7 @@ def parse_search_query(query: str) -> Dict[str, Any]:
4142
# Value-taking modifiers
4243
"from": ["from:", "de:", "van:"],
4344
"to": ["to:", "a:", "à:", "aan:"],
45+
"to_exact": ["to_exact:", "a_exact:", "à_exact:", "aan_exact:"],
4446
"cc": ["cc:", "copie:"],
4547
"bcc": ["bcc:", "cci:"],
4648
"subject": ["subject:", "sujet:", "objet:", "onderwerp:"],
@@ -56,7 +58,7 @@ def parse_search_query(query: str) -> Dict[str, Any]:
5658
}
5759

5860
# Split modifiers into value-taking and flag modifiers
59-
value_modifiers = ["from", "to", "cc", "bcc", "subject"]
61+
value_modifiers = ["from", "to", "cc", "bcc", "to_exact", "subject"]
6062
flag_modifiers = {
6163
"in_trash": "in_trash",
6264
"in_sent": "in_sent",

src/backend/core/services/search/search.py

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -116,27 +116,39 @@ def search_threads(
116116

117117
# Add recipient filters (to, cc, bcc) using new mapping fields
118118
recipient_fields = {
119-
"to": ("to_email", "to_name"),
120-
"cc": ("cc_email", "cc_name"),
121-
"bcc": ("bcc_email", "bcc_name"),
119+
"to": (
120+
("to_email", "cc_email", "bcc_email"),
121+
("to_name", "cc_name", "bcc_name"),
122+
),
123+
"to_exact": (("to_email",), ("to_name",)),
124+
"cc": (("cc_email",), ("cc_name",)),
125+
"bcc": (("bcc_email",), ("bcc_name",)),
122126
}
123-
for recipient_type, (email_field, name_field) in recipient_fields.items():
127+
for recipient_type, (email_fields, name_fields) in recipient_fields.items():
124128
if recipient_type in parsed_query:
125129
for recipient in parsed_query[recipient_type]:
126130
if "@" in recipient and not recipient.startswith("@"):
127-
# Exact email match
128-
search_body["query"]["bool"]["filter"].append(
129-
{"term": {email_field: recipient.lower()}}
130-
)
131-
else:
132-
# Substring match on email and name
131+
# Exact email match - must match at least one of the email fields
133132
search_body["query"]["bool"]["should"].extend(
134133
[
135-
{"match": {email_field + ".text": recipient.lower()}},
136-
{"wildcard": {name_field: f"*{recipient}*"}},
134+
{"term": {field: recipient.lower()}}
135+
for field in email_fields
137136
]
138137
)
139138
search_body["query"]["bool"]["minimum_should_match"] = 1
139+
else:
140+
# Substring match on email and name fields
141+
should_clauses = []
142+
for field in email_fields:
143+
should_clauses.append(
144+
{"match": {field + ".text": recipient.lower()}}
145+
)
146+
for field in name_fields:
147+
should_clauses.append(
148+
{"wildcard": {field: f"*{recipient}*"}}
149+
)
150+
search_body["query"]["bool"]["should"].extend(should_clauses)
151+
search_body["query"]["bool"]["minimum_should_match"] = 1
140152

141153
# Add subject filter
142154
if "subject" in parsed_query:

src/backend/core/tests/search/test_e2e_modifiers.py

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -483,29 +483,37 @@ def test_search_e2e_modifiers_from_search_modifier(
483483
def test_search_e2e_modifiers_to_search_modifier(
484484
self, setup_search, api_client, test_url, test_threads
485485
):
486-
"""Test searching with the 'to:' modifier."""
486+
"""
487+
Test searching with the 'to:' modifier.
488+
It looks for messages where recipient fields (to, cc, bcc) contain the given email address.
489+
"""
487490

488491
# Test English version
489-
response = api_client.get(f"{test_url}?search=to:sarah@example.com")
492+
response = api_client.get(f"{test_url}?search=to:robert@example.com")
490493

491494
# Verify response
492495
assert response.status_code == 200
493496

494497
# Check if the correct threads are found
498+
assert len(response.data["results"]) == 2
495499
thread_ids = [t["id"] for t in response.data["results"]]
496-
assert str(test_threads["thread1"].id) in thread_ids
500+
assert str(test_threads["thread2"].id) in thread_ids
501+
assert str(test_threads["thread10"].id) in thread_ids
497502

498503
# Test French version
499-
response = api_client.get(f"{test_url}?search=à:sarah@example.com")
504+
response = api_client.get(f"{test_url}?search=à:robert@example.com")
500505

501506
# Verify the same results
502507
assert response.status_code == 200
508+
assert len(response.data["results"]) == 2
503509
thread_ids = [t["id"] for t in response.data["results"]]
504-
assert str(test_threads["thread1"].id) in thread_ids
510+
assert str(test_threads["thread2"].id) in thread_ids
511+
assert str(test_threads["thread10"].id) in thread_ids
505512

506513
def test_search_e2e_modifiers_to_search_modifier_substring(
507514
self, setup_search, api_client, test_url, test_threads
508515
):
516+
"""Test searching with the 'to:' modifier with substring search."""
509517
# Test substring search
510518
response = api_client.get(f"{test_url}?search=to:@example.com")
511519
assert response.status_code == 200
@@ -519,6 +527,31 @@ def test_search_e2e_modifiers_to_search_modifier_substring(
519527
assert response.status_code == 200
520528
assert len(response.data["results"]) == 0
521529

530+
def test_search_e2e_modifiers_to_exact_search_modifier(
531+
self, setup_search, api_client, test_url, test_threads
532+
):
533+
"""Test searching with the 'to_exact:' modifier."""
534+
535+
# Test English version
536+
response = api_client.get(f"{test_url}?search=to_exact:robert@example.com")
537+
538+
# Verify response
539+
assert response.status_code == 200
540+
541+
# Check if the correct threads are found
542+
assert len(response.data["results"]) == 1
543+
thread_ids = [t["id"] for t in response.data["results"]]
544+
assert str(test_threads["thread10"].id) in thread_ids
545+
546+
# Test French version
547+
response = api_client.get(f"{test_url}?search=à_exact:robert@example.com")
548+
549+
# Verify the same results
550+
assert response.status_code == 200
551+
assert len(response.data["results"]) == 1
552+
thread_ids = [t["id"] for t in response.data["results"]]
553+
assert str(test_threads["thread10"].id) in thread_ids
554+
522555
def test_search_e2e_modifiers_cc_search_modifier(
523556
self, setup_search, api_client, test_url, test_threads
524557
):

src/backend/core/tests/search/test_search_modifiers.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,24 @@ def test_search_threads_with_multiple_modifiers(mock_es_client):
8787
assert sender_query_found, "Sender query was not found in the OpenSearch query"
8888

8989
# Check for to filter
90-
to_query_found = False
91-
for filter_item in call_args["body"]["query"]["bool"]["filter"]:
92-
if "term" in filter_item and "to_email" in filter_item["term"]:
93-
to_query_found = True
94-
assert filter_item["term"]["to_email"] == "sarah@example.com"
90+
to_query_found = 0
91+
for filter_item in call_args["body"]["query"]["bool"]["should"]:
92+
if "term" in filter_item:
93+
if "to_email" in filter_item["term"]:
94+
to_query_found += 1
95+
assert filter_item["term"]["to_email"] == "sarah@example.com"
96+
elif "cc_email" in filter_item["term"]:
97+
to_query_found += 1
98+
assert filter_item["term"]["cc_email"] == "sarah@example.com"
99+
elif "bcc_email" in filter_item["term"]:
100+
to_query_found += 1
101+
assert filter_item["term"]["bcc_email"] == "sarah@example.com"
102+
if to_query_found == 3:
95103
break
96104

97-
assert to_query_found, "To query was not found in the OpenSearch query"
105+
assert to_query_found == 3, (
106+
"Not all expected queries were found in the OpenSearch query (3 expected - to, cc, bcc)"
107+
)
98108

99109
# Check for subject filter
100110
subject_query_found = False

0 commit comments

Comments
 (0)