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
+21 -8
View File
@@ -15,6 +15,7 @@ and `email_pollers.py` (the background loops):
import os
import imaplib
import smtplib
import ssl
import email as email_mod
import email.header
import email.utils
@@ -50,17 +51,29 @@ def _send_smtp_message(cfg: dict, from_addr: str, recipients: list[str], message
port = int(cfg.get("smtp_port") or 465)
user = cfg.get("smtp_user") or ""
password = cfg.get("smtp_password") or ""
if port == 587:
with smtplib.SMTP(host, port, timeout=timeout) as smtp:
def _send_starttls(starttls_port: int = 587) -> None:
with smtplib.SMTP(host, starttls_port, timeout=timeout) as smtp:
smtp.starttls()
if user and password:
smtp.login(user, password)
smtp.sendmail(from_addr, recipients, message)
if port == 587:
_send_starttls(587)
return
with smtplib.SMTP_SSL(host, port, timeout=timeout) as smtp:
if user and password:
smtp.login(user, password)
smtp.sendmail(from_addr, recipients, message)
try:
with smtplib.SMTP_SSL(host, port, timeout=timeout) as smtp:
if user and password:
smtp.login(user, password)
smtp.sendmail(from_addr, recipients, message)
return
except (TimeoutError, ssl.SSLError) as e:
if port == 465:
logger.warning("SMTP implicit TLS on %s:465 failed (%s); retrying STARTTLS on 587", host, e)
_send_starttls(587)
return
raise
def _strip_think(text: str) -> str:
@@ -82,8 +95,8 @@ def _strip_think(text: str) -> str:
import re as _re_reply
# Accept REPLY / SUMMARY / OUTPUT as the opening fence so the same extractor
# serves replies and summaries (any fenced final-output block).
_REPLY_OPEN_RE = _re_reply.compile(r"<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>>", _re_reply.I)
_REPLY_CLOSE_RE = _re_reply.compile(r"<<<\s*END\s*>>>", _re_reply.I)
_REPLY_OPEN_RE = _re_reply.compile(r"<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+", _re_reply.I)
_REPLY_CLOSE_RE = _re_reply.compile(r"<<<\s*END\s*>>+", _re_reply.I)
def _extract_reply(text: str) -> str:
+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}"
+42 -20
View File
@@ -1198,7 +1198,7 @@ def setup_email_routes():
(message_id.strip(),),
).fetchone()
if _row2:
cached_ai_reply = _row2[0]
cached_ai_reply = _apply_email_style_mechanics(_extract_reply(_row2[0] or ""))
_row3 = _c.execute(
"SELECT sig_start, quote_start, turns_json FROM email_boundaries WHERE message_id = ?",
(message_id.strip(),),
@@ -1254,6 +1254,7 @@ def setup_email_routes():
return {
"uid": uid,
"folder": folder,
"message_id": message_id.strip(),
"subject": subject,
"from_name": sender_name or sender_addr,
@@ -2539,10 +2540,31 @@ def setup_email_routes():
message_id = (data.get("message_id") or "").strip()
source_uid = (data.get("uid") or "").strip()
source_folder = (data.get("folder") or "INBOX").strip()
fast_reply = bool(data.get("fast", False))
if not original_body:
return {"success": False, "error": "No email body provided"}
if message_id:
try:
_c = _sql3.connect(SCHEDULED_DB)
_row = _c.execute(
"SELECT reply, model_used FROM email_ai_replies WHERE message_id = ?",
(message_id,),
).fetchone()
_c.close()
if _row and _row[0]:
cached_reply = _apply_email_style_mechanics(_extract_reply(_row[0] or ""))
if cached_reply:
return {
"success": True,
"reply": cached_reply,
"model_used": _row[1] or "cached",
"cached": True,
}
except Exception as e:
logger.warning(f"AI reply cache lookup failed: {e}")
settings = _load_settings()
style = settings.get("email_writing_style", "")
@@ -2618,8 +2640,12 @@ def setup_email_routes():
logger.info(f"AI reply using model={model} url={url}")
# Pre-retrieval: mine names/topics from the original email, search past mail + contacts
context_snippets, _terms = _pre_retrieve_context(original_body, to)
# Manual AI Reply should feel immediate. The heavier context mining
# can involve multiple IMAP folder searches and attachment parsing;
# reserve that for callers that explicitly opt out of fast mode.
context_snippets, _terms = ([], [])
if not fast_reply:
context_snippets, _terms = _pre_retrieve_context(original_body, to)
# NEW: also pull the last few emails from the original sender +
# their attachments. The "to" field on this endpoint is the
@@ -2627,16 +2653,17 @@ def setup_email_routes():
# sender we're answering. So `to` doubles as the address we want
# the thread context for.
referenced = ""
try:
from_addr_for_ctx = email.utils.parseaddr(to or "")[1]
referenced = _fetch_sender_thread_context(
sender_addr=from_addr_for_ctx,
exclude_uid=source_uid,
exclude_folder=source_folder,
limit=3,
)
except Exception as _e:
logger.warning(f"sender-thread-context failed: {_e}")
if not fast_reply:
try:
from_addr_for_ctx = email.utils.parseaddr(to or "")[1]
referenced = _fetch_sender_thread_context(
sender_addr=from_addr_for_ctx,
exclude_uid=source_uid,
exclude_folder=source_folder,
limit=3,
)
except Exception as _e:
logger.warning(f"sender-thread-context failed: {_e}")
system_prompt = _EMAIL_REPLY_SYS_PROMPT_BASE
if style:
@@ -2705,12 +2732,8 @@ def setup_email_routes():
{"role": "user", "content": user_msg},
],
temperature=0.7,
# Match the background poller's reply budget (16384). The old
# 4096 cap let a local reasoning model (Qwen3 / R1) spend the
# whole budget inside <think>, so _strip_think left nothing —
# surfacing as "LLM returned empty response".
max_tokens=16384,
timeout=300,
max_tokens=1024 if fast_reply else 6144,
timeout=60 if fast_reply else 180,
)
except Exception as e:
detail = getattr(e, "detail", None) or str(e)
@@ -2724,7 +2747,6 @@ def setup_email_routes():
# Cache so next click is instant
if message_id:
try:
import sqlite3 as _sql3
_c = _sql3.connect(SCHEDULED_DB)
_c.execute("""
INSERT OR REPLACE INTO email_ai_replies
+90
View File
@@ -427,6 +427,79 @@ def setup_task_routes(task_scheduler) -> APIRouter:
notes = task_scheduler.pop_notifications(owner=user)
return {"notifications": notes}
@router.post("/{task_id}/clear-cache")
async def clear_task_cache(request: Request, task_id: str):
"""Clear derived cache for one built-in task."""
user = _owner(request)
db = SessionLocal()
try:
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
if not task:
raise HTTPException(404, "Task not found")
if user and task.owner != user:
raise HTTPException(403, "Access denied")
action = task.action or ""
finally:
db.close()
cache_tables = {
"summarize_emails": ("email_summaries",),
"draft_email_replies": ("email_ai_replies",),
"extract_email_events": ("email_calendar_extractions",),
"mark_email_boundaries": ("email_boundaries",),
"learn_sender_signatures": ("sender_signatures",),
"check_email_urgency": ("email_tags", "email_urgency_alerts"),
}
tables = cache_tables.get(action)
if not tables:
raise HTTPException(400, "This task has no clearable cache")
import sqlite3
from pathlib import Path
from routes.email_helpers import SCHEDULED_DB
cleared = {}
conn = sqlite3.connect(SCHEDULED_DB)
try:
for table in tables:
try:
if table == "email_tags" and user:
before = conn.execute(
"SELECT COUNT(*) FROM email_tags WHERE owner = ? OR owner = ''",
(user,),
).fetchone()[0]
conn.execute("DELETE FROM email_tags WHERE owner = ? OR owner = ''", (user,))
else:
before = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
conn.execute(f"DELETE FROM {table}")
cleared[table] = int(before or 0)
except sqlite3.OperationalError:
cleared[table] = 0
conn.commit()
finally:
conn.close()
removed_files = 0
if action == "check_email_urgency":
cache_dir = Path("data/email_urgency_cache")
if cache_dir.exists():
for child in cache_dir.glob("*.json"):
try:
child.unlink()
removed_files += 1
except Exception:
pass
owner_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (user or "default"))
for state_path in [Path(f"data/email_urgency_state_{owner_slug}.json")]:
try:
if state_path.exists():
state_path.unlink()
removed_files += 1
except Exception:
pass
return {"ok": True, "action": action, "cleared": cleared, "files": removed_files}
@router.get("/{task_id}")
async def get_task(request: Request, task_id: str):
user = _owner(request)
@@ -638,6 +711,23 @@ def setup_task_routes(task_scheduler) -> APIRouter:
raise HTTPException(409, "Task is already running")
return {"ok": True, "message": "Task triggered" + (" in parallel" if force else "")}
@router.post("/{task_id}/stop")
async def stop_task_now(request: Request, task_id: str):
user = _owner(request)
db = SessionLocal()
try:
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
if not task:
raise HTTPException(404, "Task not found")
if user and task.owner != user:
raise HTTPException(403, "Access denied")
finally:
db.close()
stopped = await task_scheduler.stop_task(task_id)
if not stopped:
raise HTTPException(404, "Task is not running")
return {"ok": True, "message": "Task stopped"}
@router.get("/runs/recent")
async def list_recent_runs(request: Request, limit: int = 50):
"""Recent task runs across ALL tasks for this owner. Drives the Activity view."""