mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-08 11:56:59 +00:00
Polish mobile UI and editor workflows
This commit is contained in:
+163
-3
@@ -538,6 +538,148 @@ def _get_cached_summaries():
|
||||
return {}
|
||||
|
||||
|
||||
def _fixture_email_file() -> Path:
|
||||
return DATA_DIR / "fixture_email_messages.json"
|
||||
|
||||
|
||||
def _fixture_email_enabled() -> bool:
|
||||
return _fixture_email_file().exists()
|
||||
|
||||
|
||||
def _parse_fixture_date(raw_date: str) -> tuple[str, float]:
|
||||
if not raw_date:
|
||||
return "", 0.0
|
||||
parsed = None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(raw_date).replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
try:
|
||||
parsed = email.utils.parsedate_to_datetime(str(raw_date))
|
||||
except Exception:
|
||||
parsed = None
|
||||
if parsed:
|
||||
return parsed.isoformat(), parsed.timestamp()
|
||||
return str(raw_date), 0.0
|
||||
|
||||
|
||||
def _fixture_email_record(row: dict, uid_num: int, owner: str) -> dict:
|
||||
sender = str(row.get("from") or "Fixture Sender <fixture@example.invalid>")
|
||||
sender_name, sender_addr = email.utils.parseaddr(sender)
|
||||
date_str, date_epoch = _parse_fixture_date(str(row.get("date") or ""))
|
||||
subject = str(row.get("subject") or "(no subject)")
|
||||
body = str(row.get("body") or "")
|
||||
owner_key = re.sub(r"[^A-Za-z0-9_.-]", "-", owner or "default")
|
||||
uid = str(uid_num)
|
||||
return {
|
||||
"uid": uid,
|
||||
"message_id": f"<fixture-email-{uid}-{owner_key}@fixtures.odysseus.local>",
|
||||
"subject": subject,
|
||||
"from": sender_name or sender_addr or sender,
|
||||
"from_address": sender_addr,
|
||||
"date": date_str,
|
||||
"date_epoch": date_epoch,
|
||||
"summary": body[:240],
|
||||
"body": body,
|
||||
"account": "Fixture Inbox",
|
||||
"account_email": owner or str(row.get("owner") or ""),
|
||||
"account_id": "fixture-email",
|
||||
"attachments": [],
|
||||
}
|
||||
|
||||
|
||||
def _fixture_email_rows(owner: str | None = None) -> list[dict]:
|
||||
path = _fixture_email_file()
|
||||
if not path.exists():
|
||||
return []
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
rows = raw.get("messages") if isinstance(raw, dict) else raw
|
||||
out = []
|
||||
owner = str(owner or "").strip()
|
||||
for i, row in enumerate(rows if isinstance(rows, list) else [], start=1):
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
row_owner = str(row.get("owner") or "").strip()
|
||||
if owner and row_owner and row_owner != owner:
|
||||
continue
|
||||
out.append(_fixture_email_record(row, i, owner or row_owner))
|
||||
out.sort(key=lambda item: item.get("date_epoch") or 0, reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
def _fixture_account_rows() -> list[dict]:
|
||||
if not _fixture_email_enabled():
|
||||
return []
|
||||
owner = _current_owner()
|
||||
owners = []
|
||||
for row in _fixture_email_rows(owner or None):
|
||||
email_addr = row.get("account_email") or owner or "fixture@fixtures.odysseus.local"
|
||||
if email_addr not in owners:
|
||||
owners.append(email_addr)
|
||||
if not owners:
|
||||
owners = [owner or "fixture@fixtures.odysseus.local"]
|
||||
return [
|
||||
{
|
||||
"id": "fixture-email",
|
||||
"owner": owner or owners[0],
|
||||
"name": "Fixture Inbox",
|
||||
"is_default": True,
|
||||
"imap_user": owners[0],
|
||||
"from_address": owners[0],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _fixture_email_matches(item: dict, query: str) -> bool:
|
||||
if not query:
|
||||
return True
|
||||
terms = [term for term in re.split(r"\W+", str(query).lower()) if term]
|
||||
haystack = "\n".join(
|
||||
str(item.get(key) or "")
|
||||
for key in ("subject", "from", "from_address", "body", "summary")
|
||||
).lower()
|
||||
return all(term in haystack for term in terms)
|
||||
|
||||
|
||||
def _fixture_list_emails(folder="INBOX", max_results=20, unresponded_only=False,
|
||||
unread_only=False, account=None) -> list[dict] | None:
|
||||
if not _fixture_email_enabled():
|
||||
return None
|
||||
if account and str(account).strip().lower() not in {
|
||||
"fixture-email",
|
||||
"fixture inbox",
|
||||
"fixture",
|
||||
str(_current_owner()).lower(),
|
||||
}:
|
||||
return []
|
||||
if (folder or "INBOX").upper() not in {"INBOX", "ALL", "ALL MAIL"}:
|
||||
return []
|
||||
return _fixture_email_rows(_current_owner())[: int(max_results or 20)]
|
||||
|
||||
|
||||
def _fixture_search_emails(query, folders=None, max_results=20, account=None) -> list[dict] | None:
|
||||
if not _fixture_email_enabled():
|
||||
return None
|
||||
rows = _fixture_list_emails("INBOX", max_results=1000, account=account) or []
|
||||
out = [dict(row, _folder="INBOX") for row in rows if _fixture_email_matches(row, str(query or ""))]
|
||||
return out[: int(max_results or 20)]
|
||||
|
||||
|
||||
def _fixture_read_email(uid=None, message_id=None, folder="INBOX", account=None) -> dict | None:
|
||||
if not _fixture_email_enabled():
|
||||
return None
|
||||
if (folder or "INBOX").upper() not in {"INBOX", "ALL", "ALL MAIL"}:
|
||||
return {"error": f"Email UID {uid or message_id} not found"}
|
||||
for item in _fixture_email_rows(_current_owner()):
|
||||
if uid and str(item.get("uid")) == str(uid):
|
||||
return item
|
||||
if message_id and str(item.get("message_id")) == str(message_id):
|
||||
return item
|
||||
return {"error": f"Email not found with UID/Message-ID: {uid or message_id}"}
|
||||
|
||||
|
||||
# ── Tool implementations ──
|
||||
|
||||
|
||||
@@ -548,6 +690,9 @@ def _list_emails(folder="INBOX", max_results=20, unresponded_only=False,
|
||||
Pass unread_only=True and/or unresponded_only=True for attention scans.
|
||||
account selects mailbox (None = default).
|
||||
"""
|
||||
fixture = _fixture_list_emails(folder, max_results, unresponded_only, unread_only, account)
|
||||
if fixture is not None:
|
||||
return fixture
|
||||
conn = None
|
||||
try:
|
||||
conn = _imap_connect(account)
|
||||
@@ -629,6 +774,9 @@ def _result_sort_time(result: dict) -> datetime:
|
||||
|
||||
def _list_emails_across_accounts(folder="INBOX", max_results=20,
|
||||
unresponded_only=False, unread_only=False):
|
||||
fixture = _fixture_list_emails(folder, max_results, unresponded_only, unread_only, None)
|
||||
if fixture is not None:
|
||||
return fixture, []
|
||||
rows = _list_accounts_raw()
|
||||
combined = []
|
||||
errors = []
|
||||
@@ -662,6 +810,9 @@ def _search_emails(query, folders=None, max_results=20, account=None):
|
||||
_list_emails plus an `_folder` tag."""
|
||||
if not query or not str(query).strip():
|
||||
return []
|
||||
fixture = _fixture_search_emails(query, folders=folders, max_results=max_results, account=account)
|
||||
if fixture is not None:
|
||||
return fixture
|
||||
q = str(query).replace("\\", "\\\\").replace('"', '\\"')
|
||||
# Mail clients commonly use OR FROM/SUBJECT/TEXT to match either field.
|
||||
# IMAP SEARCH OR is binary, so we nest it.
|
||||
@@ -784,6 +935,9 @@ def _extract_attachment_to_disk(msg, index, target_dir):
|
||||
|
||||
def _read_email(uid=None, message_id=None, folder="INBOX", account=None):
|
||||
"""Read full email content by UID or message-ID. account = mailbox selector."""
|
||||
fixture = _fixture_read_email(uid=uid, message_id=message_id, folder=folder, account=account)
|
||||
if fixture is not None:
|
||||
return fixture
|
||||
cfg = _load_config(account)
|
||||
conn = None
|
||||
try:
|
||||
@@ -837,6 +991,9 @@ def _read_email(uid=None, message_id=None, folder="INBOX", account=None):
|
||||
|
||||
|
||||
def _read_email_across_accounts(uid=None, message_id=None, folder="INBOX"):
|
||||
fixture = _fixture_read_email(uid=uid, message_id=message_id, folder=folder, account=None)
|
||||
if fixture is not None:
|
||||
return fixture
|
||||
rows = _list_accounts_raw()
|
||||
matches = []
|
||||
errors = []
|
||||
@@ -1775,9 +1932,10 @@ async def list_tools() -> list[Tool]:
|
||||
Tool(
|
||||
name="reply_to_email",
|
||||
description=(
|
||||
"Reply to an existing email by UID. This sends immediately; for normal "
|
||||
"assistant-written replies, prefer draft_email_reply so the user can "
|
||||
"review and send from Odysseus. Automatically threads the reply with "
|
||||
"Reply to an existing email by UID. This sends immediately. Do NOT use "
|
||||
"for normal 'write/draft a reply saying X' requests; use "
|
||||
"draft_email_reply so the user can review and send from Odysseus. "
|
||||
"Only use this when the user explicitly says to send now. Automatically threads the reply with "
|
||||
"In-Reply-To and References headers, prefixes 'Re:' on the subject, and "
|
||||
"uses the original sender as the recipient. Set reply_all=true to also CC "
|
||||
"the original To/Cc recipients. For follow-up 'reply ...' requests, use "
|
||||
@@ -1991,6 +2149,8 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
|
||||
if name == "list_email_accounts":
|
||||
rows = _filter_accounts_for_owner(all_db_accounts)
|
||||
if not rows:
|
||||
rows = _fixture_account_rows()
|
||||
if not rows:
|
||||
if all_db_accounts and owner:
|
||||
return [TextContent(type="text", text="No email accounts configured for this owner.")]
|
||||
|
||||
Reference in New Issue
Block a user