From e8a5e97e7ed07394954f2b69fb09e5559579670a Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Thu, 16 Apr 2026 18:34:29 -0300 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20FEATURE-#40:=20Use=20O?= =?UTF-8?q?ptional[bool]=20for=20Query=20flags=20to=20remove=20ambiguity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- email_profile/clients/imap/query.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/email_profile/clients/imap/query.py b/email_profile/clients/imap/query.py index f3a78ff..4170f97 100644 --- a/email_profile/clients/imap/query.py +++ b/email_profile/clients/imap/query.py @@ -194,7 +194,7 @@ class Query(BaseModel): deleted: Optional[bool] = None draft: Optional[bool] = None - unseen: bool = False + unseen: Optional[bool] = None def _date_clauses(self) -> list[str]: fields = ( @@ -243,8 +243,10 @@ def _flag_clauses(self) -> list[str]: parts.append(f"({name})") elif flag is False: parts.append(f"(UN{name})") - if self.unseen: + if self.unseen is True: parts.append("(UNSEEN)") + elif self.unseen is False: + parts.append("(SEEN)") return parts def _clauses(self) -> list[str]: From 669bbe0b2f2d6935f9e0e8107e8038d0ade51db6 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Thu, 16 Apr 2026 19:03:55 -0300 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8=20FEATURE-#40:=20Only=20filter=20?= =?UTF-8?q?on=20True,=20reject=20contradictory=20seen/unseen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- email_profile/clients/imap/query.py | 12 +++++++----- tests/clients/imap/test_query.py | 11 +++++++++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/email_profile/clients/imap/query.py b/email_profile/clients/imap/query.py index 4170f97..f536e63 100644 --- a/email_profile/clients/imap/query.py +++ b/email_profile/clients/imap/query.py @@ -14,7 +14,7 @@ from datetime import date from typing import Optional, Union -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator def _ascii(value: str) -> str: @@ -229,6 +229,12 @@ def _size_clauses(self) -> list[str]: f"({name} {value})" for name, value in fields if value is not None ] + @model_validator(mode="after") + def _check_seen_unseen(self) -> Query: + if self.seen is True and self.unseen is True: + raise ValueError("Cannot set both seen=True and unseen=True.") + return self + def _flag_clauses(self) -> list[str]: fields = ( (self.seen, "SEEN"), @@ -241,12 +247,8 @@ def _flag_clauses(self) -> list[str]: for flag, name in fields: if flag is True: parts.append(f"({name})") - elif flag is False: - parts.append(f"(UN{name})") if self.unseen is True: parts.append("(UNSEEN)") - elif self.unseen is False: - parts.append("(SEEN)") return parts def _clauses(self) -> list[str]: diff --git a/tests/clients/imap/test_query.py b/tests/clients/imap/test_query.py index 3fdc04f..1eec3d2 100644 --- a/tests/clients/imap/test_query.py +++ b/tests/clients/imap/test_query.py @@ -23,8 +23,15 @@ def test_date_format(self): def test_flags(self): self.assertEqual(Query(seen=True).mount(), "(SEEN)") - self.assertEqual(Query(seen=False).mount(), "(UNSEEN)") - self.assertEqual(Query(answered=False).mount(), "(UNANSWERED)") + self.assertEqual(Query(seen=False).mount(), "ALL") + self.assertEqual(Query(answered=True).mount(), "(ANSWERED)") + self.assertEqual(Query(answered=False).mount(), "ALL") + self.assertEqual(Query(unseen=True).mount(), "(UNSEEN)") + self.assertEqual(Query(unseen=False).mount(), "ALL") + + def test_contradictory_seen_unseen_rejected(self): + with self.assertRaises(ValidationError): + Query(seen=True, unseen=True) def test_size_filters(self): out = Query(larger=1024, smaller=4096).mount()