Compare commits

...

3 Commits

Author SHA1 Message Date
Bendt
37be42884f fix link shortcut and mark as read 2025-12-18 13:53:55 -05:00
Bendt
4a21eef6f8 selection and archive 2025-12-18 13:44:17 -05:00
Bendt
8244bd94c9 bug fix display and load 2025-12-18 13:29:56 -05:00
10 changed files with 475 additions and 69 deletions

BIN
.coverage

Binary file not shown.

View File

@@ -22,7 +22,7 @@ async def delete_current(app):
next_id, next_idx = app.message_store.find_prev_valid_id(current_index) next_id, next_idx = app.message_store.find_prev_valid_id(current_index)
# Delete the message using our Himalaya client module # Delete the message using our Himalaya client module
success = await himalaya_client.delete_message(current_message_id) message, success = await himalaya_client.delete_message(current_message_id)
if success: if success:
app.show_status(f"Message {current_message_id} deleted.", "success") app.show_status(f"Message {current_message_id} deleted.", "success")
@@ -38,4 +38,6 @@ async def delete_current(app):
app.current_message_id = 0 app.current_message_id = 0
app.show_status("No more messages available.", "warning") app.show_status("No more messages available.", "warning")
else: else:
app.show_status(f"Failed to delete message {current_message_id}.", "error") app.show_status(
f"Failed to delete message {current_message_id}: {message}", "error"
)

View File

@@ -3,6 +3,7 @@ from .message_store import MessageStore
from .widgets.ContentContainer import ContentContainer from .widgets.ContentContainer import ContentContainer
from .widgets.EnvelopeListItem import EnvelopeListItem, GroupHeader from .widgets.EnvelopeListItem import EnvelopeListItem, GroupHeader
from .screens.LinkPanel import LinkPanel from .screens.LinkPanel import LinkPanel
from .screens.ConfirmDialog import ConfirmDialog
from .actions.task import action_create_task from .actions.task import action_create_task
from .actions.open import action_open from .actions.open import action_open
from .actions.delete import delete_current from .actions.delete import delete_current
@@ -116,10 +117,11 @@ class EmailViewerApp(App):
BINDINGS.extend( BINDINGS.extend(
[ [
Binding("space", "scroll_page_down", "Scroll page down"), Binding("pagedown", "scroll_page_down", "Scroll page down"),
Binding("b", "scroll_page_up", "Scroll page up"), Binding("b", "scroll_page_up", "Scroll page up"),
Binding("s", "toggle_sort_order", "Toggle Sort Order"), Binding("s", "toggle_sort_order", "Toggle Sort Order"),
Binding("x", "toggle_selection", "Toggle selection"), Binding("x", "toggle_selection", "Toggle selection", show=False),
Binding("space", "toggle_selection", "Toggle selection"),
Binding("escape", "clear_selection", "Clear selection"), Binding("escape", "clear_selection", "Clear selection"),
] ]
) )
@@ -242,16 +244,57 @@ class EmailViewerApp(App):
list_view = self.query_one("#envelopes_list", ListView) list_view = self.query_one("#envelopes_list", ListView)
if list_view.index != metadata["index"]: if list_view.index != metadata["index"]:
list_view.index = metadata["index"] list_view.index = metadata["index"]
# Mark message as read
await self._mark_message_as_read(message_id, metadata["index"])
else: else:
logging.warning(f"Message ID {message_id} not found in metadata.") logging.warning(f"Message ID {message_id} not found in metadata.")
async def _mark_message_as_read(self, message_id: int, index: int) -> None:
"""Mark a message as read and update the UI."""
# Check if already read
envelope_data = self.message_store.envelopes[index]
if envelope_data and envelope_data.get("type") != "header":
flags = envelope_data.get("flags", [])
if "Seen" in flags:
return # Already read
# Mark as read via himalaya
_, success = await himalaya_client.mark_as_read(message_id)
if success:
# Update the envelope flags in the store
if envelope_data:
if "flags" not in envelope_data:
envelope_data["flags"] = []
if "Seen" not in envelope_data["flags"]:
envelope_data["flags"].append("Seen")
# Update the visual state of the list item
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 = True
envelope_widget.remove_class("unread")
except Exception:
pass # Widget may not exist
def on_list_view_selected(self, event: ListView.Selected) -> None: def on_list_view_selected(self, event: ListView.Selected) -> None:
"""Called when an item in the list view is selected.""" """Called when an item in the list view is selected."""
if event.list_view.index is None: if event.list_view.index is None:
return return
# Only handle selection from the envelopes list
if event.list_view.id != "envelopes_list":
return
selected_index = event.list_view.index selected_index = event.list_view.index
# Check bounds before accessing
if selected_index < 0 or selected_index >= len(self.message_store.envelopes):
return
current_item = self.message_store.envelopes[selected_index] current_item = self.message_store.envelopes[selected_index]
if current_item is None or current_item.get("type") == "header": if current_item is None or current_item.get("type") == "header":
@@ -261,13 +304,26 @@ class EmailViewerApp(App):
self.current_message_id = message_id self.current_message_id = message_id
self.current_message_index = selected_index self.current_message_index = selected_index
# Focus the main content panel after selecting a message
self.action_focus_4()
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None: def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
"""Called when an item in the list view is highlighted (e.g., via arrow keys).""" """Called when an item in the list view is highlighted (e.g., via arrow keys)."""
if event.list_view.index is None: if event.list_view.index is None:
return return
# Only handle highlights from the envelopes list
if event.list_view.id != "envelopes_list":
return
highlighted_index = event.list_view.index highlighted_index = event.list_view.index
# Check bounds before accessing
if highlighted_index < 0 or highlighted_index >= len(
self.message_store.envelopes
):
return
current_item = self.message_store.envelopes[highlighted_index] current_item = self.message_store.envelopes[highlighted_index]
if current_item is None or current_item.get("type") == "header": if current_item is None or current_item.get("type") == "header":
@@ -277,6 +333,16 @@ class EmailViewerApp(App):
# self.current_message_id = message_id # self.current_message_id = message_id
self.highlighted_message_index = highlighted_index self.highlighted_message_index = highlighted_index
def on_key(self, event) -> None:
"""Handle key events to intercept space on the envelopes list."""
# Intercept space key when envelopes list is focused to prevent default select behavior
if event.key == "space":
focused = self.focused
if focused and focused.id == "envelopes_list":
event.prevent_default()
event.stop()
self.action_toggle_selection()
@work(exclusive=False) @work(exclusive=False)
async def fetch_envelopes(self) -> None: async def fetch_envelopes(self) -> None:
msglist = self.query_one("#envelopes_list", ListView) msglist = self.query_one("#envelopes_list", ListView)
@@ -455,66 +521,165 @@ class EmailViewerApp(App):
self.current_message_index = prev_idx self.current_message_index = prev_idx
async def action_delete(self) -> None: async def action_delete(self) -> None:
"""Delete the current message and update UI consistently.""" """Delete the current or selected messages."""
# Call the delete_current function which uses our Himalaya client module if self.selected_messages:
worker = delete_current(self) # --- Multi-message delete: show confirmation ---
count = len(self.selected_messages)
def do_delete(confirmed: bool) -> None:
if confirmed:
self.run_worker(self._delete_selected_messages())
self.push_screen(
ConfirmDialog(
title="Delete Messages",
message=f"Delete {count} selected message{'s' if count > 1 else ''}?",
confirm_label="Delete",
cancel_label="Cancel",
),
do_delete,
)
else:
# --- Single message delete (no confirmation needed) ---
worker = delete_current(self)
await worker.wait()
async def _delete_selected_messages(self) -> None:
"""Delete all selected messages."""
message_ids_to_delete = list(self.selected_messages)
next_id_to_select = None
if message_ids_to_delete:
highest_deleted_id = max(message_ids_to_delete)
metadata = self.message_store.get_metadata(highest_deleted_id)
if metadata:
next_id, _ = self.message_store.find_next_valid_id(metadata["index"])
if next_id is None:
next_id, _ = self.message_store.find_prev_valid_id(
metadata["index"]
)
next_id_to_select = next_id
# Delete each message
success_count = 0
for mid in message_ids_to_delete:
message, success = await himalaya_client.delete_message(mid)
if success:
success_count += 1
else:
self.show_status(f"Failed to delete message {mid}: {message}", "error")
if success_count > 0:
self.show_status(
f"Deleted {success_count} message{'s' if success_count > 1 else ''}"
)
self.selected_messages.clear()
self.refresh_list_view_items()
# Refresh the envelope list
worker = self.fetch_envelopes()
await worker.wait() await worker.wait()
# After refresh, select the next message
if next_id_to_select:
new_metadata = self.message_store.get_metadata(next_id_to_select)
if new_metadata:
self.current_message_id = next_id_to_select
else:
self.action_oldest()
else:
self.action_oldest()
async def action_archive(self) -> None: async def action_archive(self) -> None:
"""Archive the current or selected messages and update UI consistently.""" """Archive the current or selected messages and update UI consistently."""
if self.selected_messages:
# --- Multi-message archive: show confirmation ---
count = len(self.selected_messages)
def do_archive(confirmed: bool) -> None:
if confirmed:
self.run_worker(self._archive_selected_messages())
self.push_screen(
ConfirmDialog(
title="Archive Messages",
message=f"Archive {count} selected message{'s' if count > 1 else ''}?",
confirm_label="Archive",
cancel_label="Cancel",
),
do_archive,
)
else:
# --- Single message archive (no confirmation needed) ---
await self._archive_single_message()
async def _archive_selected_messages(self) -> None:
"""Archive all selected messages."""
message_ids_to_archive = list(self.selected_messages)
next_id_to_select = None next_id_to_select = None
if self.selected_messages: if message_ids_to_archive:
# --- Multi-message archive --- highest_archived_id = max(message_ids_to_archive)
message_ids_to_archive = list(self.selected_messages) metadata = self.message_store.get_metadata(highest_archived_id)
if metadata:
if message_ids_to_archive: next_id, _ = self.message_store.find_next_valid_id(metadata["index"])
highest_archived_id = max(message_ids_to_archive) if next_id is None:
metadata = self.message_store.get_metadata(highest_archived_id) next_id, _ = self.message_store.find_prev_valid_id(
if metadata:
next_id, _ = self.message_store.find_next_valid_id(
metadata["index"] metadata["index"]
) )
if next_id is None: next_id_to_select = next_id
next_id, _ = self.message_store.find_prev_valid_id(
metadata["index"]
)
next_id_to_select = next_id
message, success = await himalaya_client.archive_messages( message, success = await himalaya_client.archive_messages(
[str(mid) for mid in message_ids_to_archive] [str(mid) for mid in message_ids_to_archive]
)
if success:
self.show_status(
message or f"Archived {len(message_ids_to_archive)} messages"
) )
self.selected_messages.clear()
if success: self.refresh_list_view_items()
self.show_status(message or "Success archived")
self.selected_messages.clear()
else:
self.show_status(f"Failed to archive messages: {message}", "error")
return
else: else:
# --- Single message archive --- self.show_status(f"Failed to archive messages: {message}", "error")
if not self.current_message_id: return
self.show_status("No message selected to archive.", "error")
return
current_id = self.current_message_id # Refresh the envelope list
current_idx = self.current_message_index worker = self.fetch_envelopes()
await worker.wait()
next_id, _ = self.message_store.find_next_valid_id(current_idx) # After refresh, select the next message
if next_id is None: if next_id_to_select:
next_id, _ = self.message_store.find_prev_valid_id(current_idx) new_metadata = self.message_store.get_metadata(next_id_to_select)
next_id_to_select = next_id if new_metadata:
self.current_message_id = next_id_to_select
message, success = await himalaya_client.archive_messages([str(current_id)])
if success:
self.show_status(message or "Archived")
else: else:
self.show_status( self.action_oldest()
f"Failed to archive message {current_id}: {message}", "error" else:
) self.action_oldest()
return
async def _archive_single_message(self) -> None:
"""Archive the current single message."""
if not self.current_message_id:
self.show_status("No message selected to archive.", "error")
return
current_id = self.current_message_id
current_idx = self.current_message_index
next_id, _ = self.message_store.find_next_valid_id(current_idx)
if next_id is None:
next_id, _ = self.message_store.find_prev_valid_id(current_idx)
next_id_to_select = next_id
message, success = await himalaya_client.archive_messages([str(current_id)])
if success:
self.show_status(message or "Archived")
else:
self.show_status(
f"Failed to archive message {current_id}: {message}", "error"
)
return
# Refresh the envelope list # Refresh the envelope list
worker = self.fetch_envelopes() worker = self.fetch_envelopes()

View File

@@ -66,8 +66,9 @@ class KeybindingsConfig(BaseModel):
create_task: str = "t" create_task: str = "t"
reload: str = "%" reload: str = "%"
toggle_sort: str = "s" toggle_sort: str = "s"
toggle_selection: str = "x" toggle_selection: str = "space"
clear_selection: str = "escape" clear_selection: str = "escape"
scroll_page_down: str = "pagedown"
scroll_page_down: str = "space" scroll_page_down: str = "space"
scroll_page_up: str = "b" scroll_page_up: str = "b"
toggle_main_content: str = "w" toggle_main_content: str = "w"
@@ -89,6 +90,13 @@ class LinkPanelConfig(BaseModel):
close_on_open: bool = False close_on_open: bool = False
class MailConfig(BaseModel):
"""Configuration for mail operations."""
# Folder to move messages to when archiving
archive_folder: str = "Archive"
class ThemeConfig(BaseModel): class ThemeConfig(BaseModel):
"""Theme/appearance settings.""" """Theme/appearance settings."""
@@ -104,6 +112,7 @@ class MaildirGTDConfig(BaseModel):
) )
content_display: ContentDisplayConfig = Field(default_factory=ContentDisplayConfig) content_display: ContentDisplayConfig = Field(default_factory=ContentDisplayConfig)
link_panel: LinkPanelConfig = Field(default_factory=LinkPanelConfig) link_panel: LinkPanelConfig = Field(default_factory=LinkPanelConfig)
mail: MailConfig = Field(default_factory=MailConfig)
keybindings: KeybindingsConfig = Field(default_factory=KeybindingsConfig) keybindings: KeybindingsConfig = Field(default_factory=KeybindingsConfig)
theme: ThemeConfig = Field(default_factory=ThemeConfig) theme: ThemeConfig = Field(default_factory=ThemeConfig)

View File

@@ -11,17 +11,23 @@
width: 1fr width: 1fr
} }
.list_view {
height: 3;
}
#main_content { #main_content {
width: 2fr; width: 2fr;
} }
.envelope-selected { .envelope-selected {
tint: $accent 20%; tint: $accent 20%;
} }
#sidebar:focus-within { #sidebar:focus-within {
background: $panel; background: $panel;
.list_view:blur { .list_view:blur {
height: 3; height: 3;
} }
@@ -30,6 +36,11 @@
} }
} }
#envelopes_list {
height: 2fr;
}
#main_content:focus, .list_view:focus { #main_content:focus, .list_view:focus {
border: round $secondary; border: round $secondary;
background: rgb(55, 53, 57); background: rgb(55, 53, 57);

View File

@@ -0,0 +1,108 @@
"""Confirmation dialog screen for destructive actions."""
from textual import on
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, Container
from textual.screen import ModalScreen
from textual.widgets import Button, Label, Static
class ConfirmDialog(ModalScreen[bool]):
"""A modal confirmation dialog that returns True if confirmed, False otherwise."""
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
Binding("enter", "confirm", "Confirm"),
Binding("y", "confirm", "Yes"),
Binding("n", "cancel", "No"),
]
DEFAULT_CSS = """
ConfirmDialog {
align: center middle;
}
ConfirmDialog #confirm-container {
width: 50;
height: auto;
min-height: 7;
background: $surface;
border: thick $primary;
padding: 1 2;
}
ConfirmDialog #confirm-title {
text-style: bold;
width: 1fr;
height: 1;
text-align: center;
margin-bottom: 1;
}
ConfirmDialog #confirm-message {
width: 1fr;
height: 1;
text-align: center;
margin-bottom: 1;
}
ConfirmDialog #confirm-buttons {
width: 1fr;
height: 3;
align: center middle;
margin-top: 1;
}
ConfirmDialog Button {
margin: 0 1;
}
"""
def __init__(
self,
title: str = "Confirm",
message: str = "Are you sure?",
confirm_label: str = "Yes",
cancel_label: str = "No",
**kwargs,
):
"""Initialize the confirmation dialog.
Args:
title: The dialog title
message: The confirmation message
confirm_label: Label for the confirm button
cancel_label: Label for the cancel button
"""
super().__init__(**kwargs)
self._title = title
self._message = message
self._confirm_label = confirm_label
self._cancel_label = cancel_label
def compose(self) -> ComposeResult:
with Container(id="confirm-container"):
yield Label(self._title, id="confirm-title")
yield Label(self._message, id="confirm-message")
with Horizontal(id="confirm-buttons"):
yield Button(self._cancel_label, id="cancel", variant="default")
yield Button(self._confirm_label, id="confirm", variant="error")
def on_mount(self) -> None:
"""Focus the cancel button by default (safer option)."""
self.query_one("#cancel", Button).focus()
@on(Button.Pressed, "#confirm")
def handle_confirm(self) -> None:
self.dismiss(True)
@on(Button.Pressed, "#cancel")
def handle_cancel(self) -> None:
self.dismiss(False)
def action_confirm(self) -> None:
self.dismiss(True)
def action_cancel(self) -> None:
self.dismiss(False)

View File

@@ -411,6 +411,10 @@ class LinkPanel(ModalScreen):
self._mnemonic_map: dict[str, LinkItem] = { self._mnemonic_map: dict[str, LinkItem] = {
link.mnemonic: link for link in links if link.mnemonic link.mnemonic: link for link in links if link.mnemonic
} }
self._key_buffer: str = ""
self._key_timer = None
# Check if we have any multi-char mnemonics
self._has_multi_char = any(len(m) > 1 for m in self._mnemonic_map.keys())
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Container(id="link-panel-container"): with Container(id="link-panel-container"):
@@ -436,18 +440,68 @@ class LinkPanel(ModalScreen):
self.query_one("#link-list").focus() self.query_one("#link-list").focus()
def on_key(self, event) -> None: def on_key(self, event) -> None:
"""Handle mnemonic key presses.""" """Handle mnemonic key presses with buffering for multi-char mnemonics."""
key = event.key.lower() key = event.key.lower()
# Check for single-char mnemonic # Only buffer alphabetic keys
if key in self._mnemonic_map: if not key.isalpha() or len(key) != 1:
self._open_link(self._mnemonic_map[key]) return
# Cancel any pending timer
if self._key_timer:
self._key_timer.stop()
self._key_timer = None
# Add key to buffer
self._key_buffer += key
# Check for exact match with buffered keys
if self._key_buffer in self._mnemonic_map:
# If no multi-char mnemonics exist, open immediately
if not self._has_multi_char:
self._open_link(self._mnemonic_map[self._key_buffer])
self._key_buffer = ""
event.prevent_default()
return
# Check if any longer mnemonic starts with our buffer
has_longer_match = any(
m.startswith(self._key_buffer) and len(m) > len(self._key_buffer)
for m in self._mnemonic_map.keys()
)
if has_longer_match:
# Wait for possible additional keys
self._key_timer = self.set_timer(0.4, self._flush_key_buffer)
else:
# No longer matches possible, open immediately
self._open_link(self._mnemonic_map[self._key_buffer])
self._key_buffer = ""
event.prevent_default() event.prevent_default()
return return
# Check for two-char mnemonics (accumulate?) # Check if buffer could still match something
# For simplicity, we'll just support single-char for now could_match = any(
# A more sophisticated approach would use a timeout buffer m.startswith(self._key_buffer) for m in self._mnemonic_map.keys()
)
if could_match:
# Wait for more keys
self._key_timer = self.set_timer(0.4, self._flush_key_buffer)
event.prevent_default()
else:
# No possible match, clear buffer
self._key_buffer = ""
def _flush_key_buffer(self) -> None:
"""Called after timeout to process buffered keys."""
self._key_timer = None
if self._key_buffer and self._key_buffer in self._mnemonic_map:
self._open_link(self._mnemonic_map[self._key_buffer])
self._key_buffer = ""
def action_open_selected(self) -> None: def action_open_selected(self) -> None:
"""Open the currently selected link.""" """Open the currently selected link."""

View File

@@ -3,6 +3,7 @@ from .CreateTask import CreateTaskScreen
from .OpenMessage import OpenMessageScreen from .OpenMessage import OpenMessageScreen
from .DocumentViewer import DocumentViewerScreen from .DocumentViewer import DocumentViewerScreen
from .LinkPanel import LinkPanel, LinkItem, extract_links_from_content from .LinkPanel import LinkPanel, LinkItem, extract_links_from_content
from .ConfirmDialog import ConfirmDialog
__all__ = [ __all__ = [
"CreateTaskScreen", "CreateTaskScreen",
@@ -11,4 +12,5 @@ __all__ = [
"LinkPanel", "LinkPanel",
"LinkItem", "LinkItem",
"extract_links_from_content", "extract_links_from_content",
"ConfirmDialog",
] ]

View File

@@ -54,18 +54,19 @@ class EnvelopeListItem(Static):
EnvelopeListItem .sender-name { EnvelopeListItem .sender-name {
width: 1fr; width: 1fr;
color: $text-muted;
} }
EnvelopeListItem .message-datetime { EnvelopeListItem .message-datetime {
width: auto; width: auto;
padding: 0 1; padding: 0 1;
color: $text-muted; color: $text-disabled;
} }
EnvelopeListItem .email-subject { EnvelopeListItem .email-subject {
width: 1fr; width: 1fr;
padding: 0 3; padding: 0 3;
text-style: bold; color: $text-muted;
} }
EnvelopeListItem .email-preview { EnvelopeListItem .email-preview {
@@ -76,10 +77,16 @@ class EnvelopeListItem(Static):
EnvelopeListItem.unread .sender-name { EnvelopeListItem.unread .sender-name {
text-style: bold; text-style: bold;
color: $text;
}
EnvelopeListItem.unread .message-datetime {
color: $text-muted;
} }
EnvelopeListItem.unread .email-subject { EnvelopeListItem.unread .email-subject {
text-style: bold; text-style: bold;
color: $text;
} }
""" """
@@ -147,6 +154,9 @@ class EnvelopeListItem(Static):
date_str = date_str.replace("Z", "+00:00") date_str = date_str.replace("Z", "+00:00")
dt = datetime.fromisoformat(date_str) dt = datetime.fromisoformat(date_str)
# Convert to local timezone
dt = dt.astimezone()
parts = [] parts = []
if self.config.show_date: if self.config.show_date:
parts.append(dt.strftime(self.config.date_format)) parts.append(dt.strftime(self.config.date_format))

View File

@@ -4,6 +4,8 @@ import json
import logging import logging
import subprocess import subprocess
from src.maildir_gtd.config import get_config
async def list_envelopes(limit: int = 9999) -> Tuple[List[Dict[str, Any]], bool]: async def list_envelopes(limit: int = 9999) -> Tuple[List[Dict[str, Any]], bool]:
""" """
@@ -92,7 +94,7 @@ async def list_folders() -> Tuple[List[Dict[str, Any]], bool]:
return [], False return [], False
async def delete_message(message_id: int) -> bool: async def delete_message(message_id: int) -> Tuple[Optional[str], bool]:
""" """
Delete a message by its ID. Delete a message by its ID.
@@ -100,7 +102,9 @@ async def delete_message(message_id: int) -> bool:
message_id: The ID of the message to delete message_id: The ID of the message to delete
Returns: Returns:
True if deletion was successful, False otherwise Tuple containing:
- Result message or error
- Success status (True if deletion was successful)
""" """
try: try:
process = await asyncio.create_subprocess_shell( process = await asyncio.create_subprocess_shell(
@@ -110,10 +114,15 @@ async def delete_message(message_id: int) -> bool:
) )
stdout, stderr = await process.communicate() stdout, stderr = await process.communicate()
return process.returncode == 0 if process.returncode == 0:
return stdout.decode().strip() or "Deleted successfully", True
else:
error_msg = stderr.decode().strip()
logging.error(f"Error deleting message: {error_msg}")
return error_msg or "Unknown error", False
except Exception as e: except Exception as e:
logging.error(f"Exception during message deletion: {e}") logging.error(f"Exception during message deletion: {e}")
return False return str(e), False
# async def archive_message(message_id: int) -> [str, bool]: # async def archive_message(message_id: int) -> [str, bool]:
@@ -151,8 +160,10 @@ async def archive_messages(message_ids: List[str]) -> Tuple[Optional[str], bool]
A tuple containing an optional output string and a boolean indicating success. A tuple containing an optional output string and a boolean indicating success.
""" """
try: try:
config = get_config()
archive_folder = config.mail.archive_folder
ids_str = " ".join(message_ids) ids_str = " ".join(message_ids)
cmd = f"himalaya message move Archives {ids_str}" cmd = f"himalaya message move {archive_folder} {ids_str}"
process = await asyncio.create_subprocess_shell( process = await asyncio.create_subprocess_shell(
cmd, cmd,
@@ -162,13 +173,14 @@ async def archive_messages(message_ids: List[str]) -> Tuple[Optional[str], bool]
stdout, stderr = await process.communicate() stdout, stderr = await process.communicate()
if process.returncode == 0: if process.returncode == 0:
return stdout.decode(), True return stdout.decode().strip() or "Archived successfully", True
else: else:
logging.error(f"Error archiving messages: {stderr.decode()}") error_msg = stderr.decode().strip()
return None, False logging.error(f"Error archiving messages: {error_msg}")
return error_msg or "Unknown error", False
except Exception as e: except Exception as e:
logging.error(f"Exception during message archiving: {e}") logging.error(f"Exception during message archiving: {e}")
return None, False return str(e), False
async def get_message_content(message_id: int) -> Tuple[Optional[str], bool]: async def get_message_content(message_id: int) -> Tuple[Optional[str], bool]:
@@ -204,6 +216,39 @@ async def get_message_content(message_id: int) -> Tuple[Optional[str], bool]:
return None, False return None, False
async def mark_as_read(message_id: int) -> Tuple[Optional[str], bool]:
"""
Mark a message as read by adding the 'seen' flag.
Args:
message_id: The ID of the message to mark as read
Returns:
Tuple containing:
- Result message or error
- Success status (True if operation was successful)
"""
try:
cmd = f"himalaya flag add seen {message_id}"
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 read", True
else:
error_msg = stderr.decode().strip()
logging.error(f"Error marking message as read: {error_msg}")
return error_msg or "Unknown error", False
except Exception as e:
logging.error(f"Exception during marking message as read: {e}")
return str(e), False
def sync_himalaya(): def sync_himalaya():
"""This command does not exist. Halucinated by AI.""" """This command does not exist. Halucinated by AI."""
try: try: