From 994e545bd0cddf3528a8a37be7d89e0df6a346ad Mon Sep 17 00:00:00 2001 From: Bendt Date: Fri, 19 Dec 2025 16:18:09 -0500 Subject: [PATCH] Add toggle read/unread action with 'u' keybinding in mail app --- PROJECT_PLAN.md | 2 +- src/mail/app.py | 81 +++++++++++++++++++++++++++++++++ src/services/himalaya/client.py | 43 +++++++++++++++++ 3 files changed, 125 insertions(+), 1 deletion(-) diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 0f942bd..e1f10a1 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -452,7 +452,7 @@ Implement `/` keybinding for search across all apps with similar UX: 4. Calendar: Sidebar mini-calendar 5. Calendar: Calendar invites sidebar 6. ~~Mail: Add refresh keybinding~~ (DONE - `r` key) -7. Mail: Add mark read/unread action +7. ~~Mail: Add mark read/unread action~~ (DONE - `u` key) 8. Mail: Folder message counts 9. ~~Mail: URL compression in markdown view~~ (DONE) 10. Mail: Enhance subject styling diff --git a/src/mail/app.py b/src/mail/app.py index 503b699..60047cf 100644 --- a/src/mail/app.py +++ b/src/mail/app.py @@ -133,6 +133,7 @@ class EmailViewerApp(App): Binding("space", "toggle_selection", "Toggle selection"), Binding("escape", "clear_selection", "Clear selection"), Binding("/", "search", "Search"), + Binding("u", "toggle_read", "Toggle read/unread"), ] ) @@ -905,6 +906,86 @@ class EmailViewerApp(App): self.refresh_list_view_items() # Refresh all items to uncheck checkboxes self._update_list_view_subtitle() + async def action_toggle_read(self) -> None: + """Toggle read/unread status for the current or selected messages.""" + folder = self.folder if self.folder else None + account = self.current_account if self.current_account else None + + if self.selected_messages: + # Toggle multiple selected messages + for message_id in self.selected_messages: + await self._toggle_message_read_status(message_id, folder, account) + self.show_status( + f"Toggled read status for {len(self.selected_messages)} messages" + ) + self.selected_messages.clear() + else: + # Toggle current message + if self.current_message_id: + await self._toggle_message_read_status( + self.current_message_id, folder, account + ) + + # Refresh the list to show updated read status + await self.fetch_envelopes().wait() + + async def _toggle_message_read_status( + self, message_id: int, folder: str | None, account: str | None + ) -> None: + """Toggle read status for a single message.""" + # Find the message in the store to check current status + metadata = self.message_store.get_metadata(message_id) + if not metadata: + return + + index = metadata.get("index", -1) + if index < 0 or index >= len(self.message_store.envelopes): + return + + envelope_data = self.message_store.envelopes[index] + if not envelope_data or envelope_data.get("type") == "header": + return + + flags = envelope_data.get("flags", []) + is_read = "Seen" in flags + + if is_read: + # Mark as unread + result, success = await himalaya_client.mark_as_unread( + message_id, folder=folder, account=account + ) + if success: + if "Seen" in envelope_data.get("flags", []): + envelope_data["flags"].remove("Seen") + self.show_status(f"Marked message {message_id} as unread") + self._update_envelope_read_state(index, is_read=False) + else: + # Mark as read + result, success = await himalaya_client.mark_as_read( + message_id, folder=folder, account=account + ) + if success: + if "flags" not in envelope_data: + envelope_data["flags"] = [] + if "Seen" not in envelope_data["flags"]: + envelope_data["flags"].append("Seen") + self.show_status(f"Marked message {message_id} as read") + self._update_envelope_read_state(index, is_read=True) + + def _update_envelope_read_state(self, index: int, is_read: bool) -> None: + """Update the visual state of an envelope in the list.""" + try: + list_view = self.query_one("#envelopes_list", ListView) + list_item = list_view.children[index] + envelope_widget = list_item.query_one(EnvelopeListItem) + envelope_widget.is_read = is_read + if is_read: + envelope_widget.remove_class("unread") + else: + envelope_widget.add_class("unread") + except Exception: + pass # Widget may not exist + def action_oldest(self) -> None: self.fetch_envelopes() if self.reload_needed else None self.show_message(self.message_store.get_oldest_id()) diff --git a/src/services/himalaya/client.py b/src/services/himalaya/client.py index 65313ab..4aa6bb4 100644 --- a/src/services/himalaya/client.py +++ b/src/services/himalaya/client.py @@ -312,6 +312,49 @@ async def mark_as_read( return str(e), False +async def mark_as_unread( + message_id: int, + folder: Optional[str] = None, + account: Optional[str] = None, +) -> Tuple[Optional[str], bool]: + """ + Mark a message as unread by removing the 'seen' flag. + + Args: + message_id: The ID of the message to mark as unread + folder: The folder containing the message + account: The account to use + + Returns: + Tuple containing: + - Result message or error + - Success status (True if operation was successful) + """ + try: + cmd = f"himalaya flag remove seen {message_id}" + if folder: + cmd += f" -f '{folder}'" + if account: + cmd += f" -a '{account}'" + + process = await asyncio.create_subprocess_shell( + cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + if process.returncode == 0: + return stdout.decode().strip() or "Marked as unread", True + else: + error_msg = stderr.decode().strip() + logging.error(f"Error marking message as unread: {error_msg}") + return error_msg or "Unknown error", False + except Exception as e: + logging.error(f"Exception during marking message as unread: {e}") + return str(e), False + + async def search_envelopes( query: str, folder: Optional[str] = None,