⚙️ FEATURE-#40: Use Optional[bool] for Query flags to remove ambiguity - #60
Conversation
FernandoCelmer
left a comment
There was a problem hiding this comment.
🔍 Code Review
Code issues found: 2
See inline comments below.
| deleted: Optional[bool] = None | ||
| draft: Optional[bool] = None | ||
|
|
||
| unseen: bool = False |
There was a problem hiding this comment.
[Blocking]
Problem — This is a silent breaking change for existing consumers. Previously, Query(unseen=False) was the default and meant "no unseen filter" — the _flag_clauses method only checked if self.unseen: which was falsy for False. After this PR, Query(unseen=False) now emits (SEEN), actively filtering for seen-only messages.
Failure scenario —
# Existing user code (pre-PR behavior):
q = Query(subject="report", unseen=False)
# Before: searches ALL emails with subject 'report'
# After: searches ONLY SEEN emails with subject 'report'
# -> silently drops unread emails from results!Since the library is at version 1.0.0.dev1, there may not be many external callers yet, but this still changes documented behavior without a deprecation path.
Fix — Document this as a breaking change in the PR description and changelog. Consider if the default change from False to None is sufficient, or if unseen=False should remain a no-op for backward compatibility.
| @@ -243,8 +243,10 @@ def _flag_clauses(self) -> list[str]: | |||
| parts.append(f"({name})") | |||
There was a problem hiding this comment.
[Suggestion]
Problem — Query(seen=True, unseen=True) now produces both (SEEN) and (UNSEEN) in the IMAP search command — a contradictory query that will return zero results on most servers.
The unseen field is the logical inverse of the seen field, but both can be set independently. There's also redundancy: Query(unseen=False) and Query(seen=True) now produce identical IMAP queries.
Fix — Add a Pydantic model_validator to reject contradictory combinations:
@model_validator(mode='after')
def _check_seen_unseen(self) -> 'Query':
if self.seen is not None and self.unseen is not None:
raise ValueError(
'Cannot set both seen and unseen. Use seen=True or unseen=True, not both.'
)
return self
Summary
unseenfield inQuerymodel frombool = FalsetoOptional[bool] = None, matching the other flag fields_flag_clauses()to use explicitis True/is Falsechecks forunseen, mappingunseen=Falseto(SEEN)andunseen=Trueto(UNSEEN)unseen=None(default) now correctly means "no filter", removing the ambiguity whereQuery(unseen=False)was indistinguishable from the defaultCloses #40
Test plan
Query(unseen=True).mount()returns(UNSEEN)Query(unseen=False).mount()returns(SEEN)Query().mount()returnsALL(no filter applied)