Polish email tasks and window controls

This commit is contained in:
pewdiepie-archdaemon
2026-06-01 20:56:11 +09:00
parent 5c390d6b3e
commit 5ed9b74cd0
14 changed files with 919 additions and 203 deletions
+94 -24
View File
@@ -23,6 +23,7 @@ import json
import re
import html
import logging
import inspect
from datetime import datetime
from email.mime.text import MIMEText
@@ -46,10 +47,22 @@ logger = logging.getLogger(__name__)
# ── Routes ──
async def _emit_progress(progress_cb, message: str):
if not progress_cb:
return
try:
res = progress_cb(message)
if inspect.isawaitable(res):
await res
except Exception:
logger.debug("Email task progress callback failed", exc_info=True)
async def _run_auto_summarize_once(do_summary: bool = True, do_reply: bool = True,
do_tag: bool = False, do_spam: bool = False,
do_calendar: bool = False,
days_back: int = 1) -> str:
days_back: int = 1,
progress_cb=None) -> str:
"""One iteration of the email scan. Temporarily flips settings flags
so the existing background-loop logic runs exactly once for the requested ops."""
settings = _load_settings()
@@ -63,7 +76,7 @@ async def _run_auto_summarize_once(do_summary: bool = True, do_reply: bool = Tru
settings["email_auto_calendar"] = bool(do_calendar)
_save_settings(settings)
try:
return await _auto_summarize_pass(days_back=days_back)
return await _auto_summarize_pass(days_back=days_back, progress_cb=progress_cb)
finally:
s2 = _load_settings()
for k, v in prev.items():
@@ -71,7 +84,7 @@ async def _run_auto_summarize_once(do_summary: bool = True, do_reply: bool = Tru
_save_settings(s2)
async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None) -> str:
async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None, progress_cb=None) -> str:
"""Single pass of the auto-summarize/reply scan.
When account_id is None, iterates over every enabled account in
@@ -98,20 +111,21 @@ async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None
names = {}
if len(ids) <= 1:
# Single-account (or zero rows — fallback to legacy settings.json lookup)
return await _auto_summarize_pass_single(days_back=days_back, account_id=(ids[0] if ids else None))
return await _auto_summarize_pass_single(days_back=days_back, account_id=(ids[0] if ids else None), progress_cb=progress_cb)
outs = []
for aid in ids:
for idx, aid in enumerate(ids, start=1):
try:
result = await _auto_summarize_pass_single(days_back=days_back, account_id=aid)
await _emit_progress(progress_cb, f"{names.get(aid, aid[:8])}: starting ({idx}/{len(ids)})")
result = await _auto_summarize_pass_single(days_back=days_back, account_id=aid, progress_cb=progress_cb)
outs.append(f"[{names.get(aid, aid[:8])}] {result}")
except Exception as e:
logger.warning(f"auto-summarize pass failed for account {aid}: {e}")
outs.append(f"[{names.get(aid, aid[:8])}] error: {e}")
return "\n".join(outs)
return await _auto_summarize_pass_single(days_back=days_back, account_id=account_id)
return await _auto_summarize_pass_single(days_back=days_back, account_id=account_id, progress_cb=progress_cb)
async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None = None) -> str:
async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None = None, progress_cb=None) -> str:
"""Single pass of the auto-summarize/reply scan for ONE account.
Reads current settings flags."""
import asyncio
@@ -130,11 +144,13 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
return "Nothing to do"
try:
await _emit_progress(progress_cb, "Connecting to mail…")
conn = _imap_connect(account_id)
from datetime import timedelta as _td
since = (datetime.utcnow() - _td(days=max(1, days_back))).strftime("%d-%b-%Y")
# uid_list now carries (folder, uid) tuples — for calendar extraction we
# also scan Sent so the LLM sees confirmation/cancellation replies the user wrote.
# uid_list carries real IMAP UIDs, matching the email UI/read routes.
# Using sequence numbers here made background-cached replies miss when
# the user clicked the same visible message in the UI.
uid_list = []
folders_to_scan = ["INBOX"]
if auto_cal:
@@ -149,17 +165,33 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
for folder in folders_to_scan:
try:
conn.select(_q(folder), readonly=True)
status, data = conn.search(None, f'(SINCE {since})')
status, data = conn.uid("SEARCH", None, f'(SINCE {since})')
if status == "OK" and data[0]:
for u in data[0].split()[-30:]:
for u in reversed(data[0].split()[-30:]):
uid_list.append((folder, u))
except Exception as _e:
logger.warning(f"Folder {folder} scan failed: {_e}")
# Some IMAP servers/accounts give unreliable results for SINCE
# because of INTERNALDATE/date-header quirks. If the user manually
# runs a cacheable email task and SINCE finds nothing, fall back to
# the latest visible inbox messages so Clear cache -> Run again can
# actually repopulate AI reply/summary/tag caches.
if not uid_list:
try:
conn.select("INBOX", readonly=True)
status, data = conn.uid("SEARCH", None, "ALL")
if status == "OK" and data and data[0]:
for u in reversed(data[0].split()[-8:]):
uid_list.append(("INBOX", u))
logger.info("Email task SINCE scan found no messages; fell back to latest INBOX messages")
except Exception as _e:
logger.warning(f"Latest-INBOX fallback scan failed: {_e}")
# Re-select INBOX as default for downstream code
conn.select("INBOX", readonly=True)
if not uid_list:
conn.logout()
return "No recent emails"
await _emit_progress(progress_cb, f"Found {len(uid_list)} recent email(s); checking cache…")
_c = _sql3.connect(SCHEDULED_DB)
_sum_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_summaries").fetchall()}
@@ -198,10 +230,15 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
too_short = 0
no_msgid = 0
examined = 0
_summaries_created = 0
_events_created = 0
_replies_drafted = 0
_reply_failed = 0
_detail_lines = []
_current_folder = "INBOX"
_max_process = 5
for _entry in uid_list:
if processed >= 10:
if processed >= _max_process:
break
# entry can be either a bare UID (legacy callers) or (folder, uid) tuple (new code)
if isinstance(_entry, tuple):
@@ -212,7 +249,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
if _folder != _current_folder:
conn.select(_q(_folder), readonly=True)
_current_folder = _folder
st, msg_data = conn.fetch(uid, "(RFC822)")
st, msg_data = conn.uid("FETCH", uid if isinstance(uid, bytes) else str(uid).encode(), "(RFC822)")
if st != "OK":
continue
examined += 1
@@ -253,6 +290,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
and not _is_self_mail)
if not need_sum and not need_reply and not need_class and not need_cal and not need_urgent:
already_cached += 1
await _emit_progress(progress_cb, f"Checked {examined}/{len(uid_list)} · {already_cached} already cached")
continue
subject = _decode_header(msg.get("Subject", ""))
sender = _decode_header(msg.get("From", ""))
@@ -267,12 +305,16 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
att_text = _extract_attachment_text(msg, max_chars=6000)
except Exception as _ae:
logger.debug(f"attachment text extraction failed for uid={uid}: {_ae}")
# No threshold for calendar — even "see you tmrw 5pm" matters.
# Summary/reply/classify still need ≥100 chars to be worth the LLM cost.
# No threshold for calendar or reply drafting — even "can you
# confirm?" needs a reply. Summary/classify still need enough
# text to be worth the LLM cost.
# If body is short but attachments have content, treat it as enough.
if need_cal:
if not body:
body = subject # at minimum send the subject line
elif need_reply:
if not body:
body = subject
elif (not body or len(body) < 100) and not att_text:
too_short += 1
continue
@@ -317,16 +359,26 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
_c.execute("""
INSERT OR REPLACE INTO email_summaries
(message_id, uid, folder, subject, sender, summary, model_used, created_at)
VALUES (?, ?, 'INBOX', ?, ?, ?, ?, ?)
""", (message_id, uid.decode(), subject, sender, summary, model, datetime.utcnow().isoformat()))
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (message_id, uid.decode() if isinstance(uid, bytes) else str(uid), _folder, subject, sender, summary, model, datetime.utcnow().isoformat()))
_c.commit()
_c.close()
_sum_existing.add(message_id)
_summaries_created += 1
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
_detail_lines.append(f"summary · {_folder}#{_uid_text} · {subject or '(no subject)'}{sender or '(unknown sender)'}")
except Exception as e:
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
_detail_lines.append(f"summary failed · {_folder}#{_uid_text} · {subject or '(no subject)'}{sender or '(unknown sender)'}")
logger.warning(f"Auto-summary {uid} failed: {e}")
if need_reply:
context_snippets, _terms = _pre_retrieve_context(body, sender)
await _emit_progress(progress_cb, f"Drafting reply {processed + 1}/{_max_process} · checked {examined}/{len(uid_list)}")
# Background reply drafting should not make the whole app
# feel busy. Keep it lightweight: no extra IMAP context
# mining here; manual AI Reply can still do that when the
# user explicitly asks for a draft on one email.
context_snippets, _terms = [], []
sys_prompt = _EMAIL_REPLY_SYS_PROMPT_BASE
if att_text:
sys_prompt += "\n\nThe email has attachments (PDFs / docs) — their contents follow the body marked '--- ATTACHMENTS ---'. Reference them in your reply when relevant (e.g. acknowledge the invoice/contract, address specific clauses or amounts)."
@@ -341,8 +393,8 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
{"role": "system", "content": sys_prompt},
{"role": "user", "content": f"Original email:\nFrom: {sender}\nSubject: {subject}\n\n{body_for_llm[:12000]}\n\nDraft a reply. Return only the reply body text."},
],
temperature=0.7, max_tokens=16384,
headers=req_headers, timeout=240,
temperature=0.7, max_tokens=1024,
headers=req_headers, timeout=90,
)
reply = _apply_email_style_mechanics(_extract_reply(reply or ""))
if reply:
@@ -350,12 +402,20 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
_c.execute("""
INSERT OR REPLACE INTO email_ai_replies
(message_id, uid, folder, reply, model_used, created_at)
VALUES (?, ?, 'INBOX', ?, ?, ?)
""", (message_id, uid.decode(), reply, model, datetime.utcnow().isoformat()))
VALUES (?, ?, ?, ?, ?, ?)
""", (message_id, uid.decode() if isinstance(uid, bytes) else str(uid), _folder, reply, model, datetime.utcnow().isoformat()))
_c.commit()
_c.close()
_reply_existing.add(message_id)
_replies_drafted += 1
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
_detail_lines.append(f"reply · {_folder}#{_uid_text} · {subject or '(no subject)'}{sender or '(unknown sender)'}")
await _emit_progress(progress_cb, f"Drafted {_replies_drafted} repl" + ("y" if _replies_drafted == 1 else "ies") + f" · checked {examined}/{len(uid_list)}")
except Exception as e:
_reply_failed += 1
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
_detail_lines.append(f"reply failed · {_folder}#{_uid_text} · {subject or '(no subject)'}{sender or '(unknown sender)'}")
await _emit_progress(progress_cb, f"Reply failed {_reply_failed} · checked {examined}/{len(uid_list)}")
logger.warning(f"Auto-reply {uid} failed: {e}")
# ── Calendar event extraction (independent of reply drafting) ──
@@ -805,6 +865,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
continue
conn.logout()
await _emit_progress(progress_cb, "Finishing…")
if processed > 0:
logger.info(f"Auto-processed {processed} new email(s) for summary/reply/classify")
# Build a clear status message
@@ -817,6 +878,12 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
parts = [f"Scanned {len(uid_list)} email(s) ({ops_label})"]
if processed:
parts.append(f"processed {processed} new")
if auto_sum:
parts.append(f"summarized {_summaries_created}")
if auto_reply:
parts.append(f"drafted {_replies_drafted} repl" + ("y" if _replies_drafted == 1 else "ies"))
if _reply_failed:
parts.append(f"{_reply_failed} reply failed")
if already_cached:
parts.append(f"{already_cached} already cached")
if too_short:
@@ -827,7 +894,10 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
parts.append(f"created {_events_created} calendar event(s)")
if processed == 0 and already_cached == 0 and too_short == 0:
parts.append("nothing to do")
return " · ".join(parts)
summary = " · ".join(parts)
if _detail_lines:
summary += "\n\nProcessed:\n" + "\n".join(f"- {line}" for line in _detail_lines[:20])
return summary
except Exception as e:
logger.warning(f"Auto-summarize pass error: {e}")
return f"Error: {e}"