1151 lines
43 KiB
Python
1151 lines
43 KiB
Python
#!/usr/bin/env python3
|
|
"""Local web interface for the macOS email agent."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import threading
|
|
import time
|
|
import webbrowser
|
|
from http import HTTPStatus
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from urllib.parse import unquote, urlparse
|
|
|
|
import email_agent
|
|
|
|
|
|
HOST = "127.0.0.1"
|
|
PORT = 8787
|
|
BASE_DIR = Path(__file__).resolve().parent
|
|
JOBS: dict[str, dict[str, object]] = {}
|
|
|
|
|
|
HTML = r"""
|
|
<!doctype html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Local Email Agent</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: dark;
|
|
--bg: #0c111d;
|
|
--panel: rgba(255, 255, 255, 0.075);
|
|
--panel-strong: rgba(255, 255, 255, 0.12);
|
|
--text: #eef4ff;
|
|
--muted: #9aa8bd;
|
|
--accent: #8ee6b3;
|
|
--accent-2: #8fb7ff;
|
|
--gold: #ffd58a;
|
|
--pink: #ff9fc7;
|
|
--danger: #ff8f8f;
|
|
--border: rgba(255, 255, 255, 0.14);
|
|
--shadow: 0 24px 70px rgba(0, 0, 0, 0.35);
|
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", sans-serif;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
background:
|
|
radial-gradient(circle at 10% 8%, rgba(143, 183, 255, 0.34), transparent 31rem),
|
|
radial-gradient(circle at 82% 16%, rgba(142, 230, 179, 0.25), transparent 27rem),
|
|
radial-gradient(circle at 58% 86%, rgba(255, 159, 199, 0.12), transparent 26rem),
|
|
linear-gradient(145deg, #05070d, var(--bg));
|
|
color: var(--text);
|
|
}
|
|
body::before {
|
|
content: "";
|
|
position: fixed;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
background-image: linear-gradient(rgba(255,255,255,.035) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.03) 1px, transparent 1px);
|
|
background-size: 54px 54px;
|
|
mask-image: linear-gradient(to bottom, rgba(0,0,0,.6), transparent 72%);
|
|
}
|
|
header { display: none; }
|
|
h1 {
|
|
margin: 0;
|
|
font-size: clamp(32px, 6vw, 72px);
|
|
line-height: 0.94;
|
|
letter-spacing: -0.07em;
|
|
max-width: 820px;
|
|
background: linear-gradient(135deg, #ffffff, #c9dcff 45%, #a9ffd0);
|
|
-webkit-background-clip: text;
|
|
background-clip: text;
|
|
color: transparent;
|
|
}
|
|
.subtitle {
|
|
margin: 18px 0 0;
|
|
color: var(--muted);
|
|
font-size: 16px;
|
|
max-width: 680px;
|
|
}
|
|
.chip {
|
|
border: 1px solid var(--border);
|
|
background: rgba(255, 255, 255, 0.08);
|
|
border-radius: 999px;
|
|
padding: 10px 14px;
|
|
color: var(--accent);
|
|
white-space: nowrap;
|
|
box-shadow: var(--shadow);
|
|
}
|
|
main { display: block; }
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(12, 1fr);
|
|
gap: 18px;
|
|
}
|
|
.card {
|
|
border: 1px solid var(--border);
|
|
background: var(--panel);
|
|
border-radius: 24px;
|
|
padding: 20px;
|
|
box-shadow: var(--shadow);
|
|
backdrop-filter: blur(18px);
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
.card::before {
|
|
content: "";
|
|
position: absolute;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
background: linear-gradient(135deg, rgba(255,255,255,.12), transparent 30%);
|
|
opacity: .45;
|
|
}
|
|
.card > * { position: relative; }
|
|
.span-4 { grid-column: span 4; }
|
|
.span-6 { grid-column: span 6; }
|
|
.span-5 { grid-column: span 5; }
|
|
.span-7 { grid-column: span 7; }
|
|
.span-12 { grid-column: span 12; }
|
|
h2 {
|
|
margin: 0 0 14px;
|
|
font-size: 19px;
|
|
letter-spacing: -0.02em;
|
|
}
|
|
.metric {
|
|
font-size: 42px;
|
|
font-weight: 760;
|
|
letter-spacing: -0.06em;
|
|
color: var(--accent);
|
|
}
|
|
.muted { color: var(--muted); }
|
|
.actions {
|
|
display: grid;
|
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|
gap: 12px;
|
|
}
|
|
.app-shell {
|
|
min-height: 100vh;
|
|
display: grid;
|
|
grid-template-columns: 292px minmax(0, 1fr);
|
|
gap: 0;
|
|
}
|
|
.sidebar {
|
|
position: sticky;
|
|
top: 0;
|
|
height: 100vh;
|
|
padding: 22px 18px;
|
|
border-right: 1px solid var(--border);
|
|
background: rgba(4, 7, 14, 0.72);
|
|
backdrop-filter: blur(22px);
|
|
box-shadow: 28px 0 70px rgba(0,0,0,.22);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 18px;
|
|
z-index: 20;
|
|
}
|
|
.brand {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 10px 8px 18px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.brand-mark {
|
|
width: 42px;
|
|
height: 42px;
|
|
border-radius: 15px;
|
|
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
|
box-shadow: 0 18px 45px rgba(142, 230, 179, .22);
|
|
}
|
|
.brand-title { font-size: 15px; font-weight: 850; letter-spacing: -.02em; }
|
|
.brand-subtitle { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
|
.side-nav { display: grid; gap: 8px; }
|
|
.side-nav button {
|
|
width: 100%;
|
|
text-align: left;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 12px 13px;
|
|
border-radius: 16px;
|
|
color: var(--muted);
|
|
background: transparent;
|
|
border-color: transparent;
|
|
}
|
|
.side-nav button.active {
|
|
color: var(--text);
|
|
background: linear-gradient(135deg, rgba(142, 230, 179, .18), rgba(143, 183, 255, .13));
|
|
border-color: rgba(142, 230, 179, .28);
|
|
}
|
|
.side-nav span { color: inherit; }
|
|
.side-nav .count {
|
|
font-size: 11px;
|
|
color: #07110b;
|
|
background: var(--accent);
|
|
border-radius: 999px;
|
|
padding: 2px 7px;
|
|
font-weight: 800;
|
|
}
|
|
.sidebar-footer {
|
|
margin-top: auto;
|
|
display: grid;
|
|
gap: 10px;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
}
|
|
.workspace {
|
|
min-width: 0;
|
|
padding: 22px clamp(18px, 3vw, 42px) 36px;
|
|
display: grid;
|
|
gap: 18px;
|
|
}
|
|
.topbar {
|
|
position: sticky;
|
|
top: 14px;
|
|
z-index: 15;
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
gap: 18px;
|
|
align-items: center;
|
|
border: 1px solid var(--border);
|
|
border-radius: 26px;
|
|
padding: 14px 16px;
|
|
background: rgba(5, 7, 13, .72);
|
|
backdrop-filter: blur(20px);
|
|
box-shadow: var(--shadow);
|
|
}
|
|
.page-title h1 {
|
|
font-size: clamp(28px, 4vw, 46px);
|
|
letter-spacing: -.06em;
|
|
line-height: .95;
|
|
margin: 0;
|
|
}
|
|
.page-title p { margin: 8px 0 0; color: var(--muted); font-size: 13px; }
|
|
.command-row {
|
|
display: flex;
|
|
gap: 9px;
|
|
flex-wrap: wrap;
|
|
justify-content: flex-end;
|
|
}
|
|
.command-row button { padding: 10px 12px; border-radius: 14px; font-size: 13px; }
|
|
.status-card {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
padding: 12px 14px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 18px;
|
|
background: rgba(255,255,255,.055);
|
|
}
|
|
.metric-card {
|
|
min-height: 148px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: space-between;
|
|
}
|
|
.metric-label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }
|
|
.metric-foot { color: var(--muted); font-size: 13px; }
|
|
button {
|
|
appearance: none;
|
|
border: 1px solid var(--border);
|
|
background: var(--panel-strong);
|
|
color: var(--text);
|
|
border-radius: 18px;
|
|
padding: 14px 14px;
|
|
font: inherit;
|
|
font-weight: 680;
|
|
cursor: pointer;
|
|
transition: transform .18s ease, background .18s ease, border-color .18s ease;
|
|
}
|
|
button:hover { transform: translateY(-1px); background: rgba(255, 255, 255, 0.16); }
|
|
button.primary { background: linear-gradient(135deg, rgba(142, 230, 179, .28), rgba(143, 183, 255, .22)); border-color: rgba(142, 230, 179, .45); }
|
|
button:disabled { opacity: .55; cursor: progress; transform: none; }
|
|
pre, .file-body {
|
|
margin: 0;
|
|
white-space: pre-wrap;
|
|
overflow-wrap: anywhere;
|
|
color: #dfe8f8;
|
|
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
|
|
font-size: 13px;
|
|
line-height: 1.55;
|
|
}
|
|
.list {
|
|
display: grid;
|
|
gap: 12px;
|
|
max-height: 390px;
|
|
overflow: auto;
|
|
padding-right: 4px;
|
|
}
|
|
.wide-list { max-height: 68vh; }
|
|
.item {
|
|
border: 1px solid var(--border);
|
|
border-radius: 20px;
|
|
padding: 15px 16px;
|
|
background: linear-gradient(135deg, rgba(0, 0, 0, 0.18), rgba(255, 255, 255, 0.05));
|
|
cursor: pointer;
|
|
}
|
|
.item:hover { border-color: rgba(143, 183, 255, .45); }
|
|
.item strong { display: block; margin-bottom: 4px; }
|
|
.item p { color: #c6d2e6; font-size: 13px; line-height: 1.5; margin: 10px 0 0; }
|
|
.item .muted { font-size: 12px; }
|
|
.reply-preview {
|
|
margin-top: 10px;
|
|
padding: 14px 15px;
|
|
border-radius: 16px;
|
|
background: linear-gradient(135deg, rgba(142, 230, 179, 0.16), rgba(143, 183, 255, 0.08));
|
|
color: #e7fff1;
|
|
white-space: pre-wrap;
|
|
font-size: 14px;
|
|
line-height: 1.55;
|
|
border-left: 3px solid var(--accent);
|
|
}
|
|
.reply-focus {
|
|
border-color: rgba(142, 230, 179, .42);
|
|
background: linear-gradient(135deg, rgba(142, 230, 179, .13), rgba(255, 255, 255, .055));
|
|
box-shadow: 0 18px 55px rgba(142, 230, 179, .12);
|
|
}
|
|
.my-answer-card {
|
|
border: 1px solid rgba(142, 230, 179, .46);
|
|
border-radius: 24px;
|
|
padding: 22px;
|
|
background:
|
|
radial-gradient(circle at 0% 0%, rgba(142, 230, 179, .22), transparent 18rem),
|
|
linear-gradient(135deg, rgba(142, 230, 179, .14), rgba(143, 183, 255, .07));
|
|
box-shadow: 0 28px 80px rgba(142, 230, 179, .13);
|
|
margin-bottom: 18px;
|
|
}
|
|
.my-answer-label {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
color: #081b10;
|
|
background: var(--accent);
|
|
border-radius: 999px;
|
|
padding: 6px 10px;
|
|
font-size: 12px;
|
|
font-weight: 850;
|
|
text-transform: uppercase;
|
|
letter-spacing: .06em;
|
|
margin-bottom: 14px;
|
|
}
|
|
.my-answer-text {
|
|
white-space: pre-wrap;
|
|
color: #f2fff7;
|
|
font-size: 17px;
|
|
line-height: 1.72;
|
|
letter-spacing: -.01em;
|
|
}
|
|
.my-answer-text::first-line {
|
|
color: #ffffff;
|
|
font-weight: 800;
|
|
}
|
|
.reply-meta {
|
|
border: 1px solid var(--border);
|
|
border-radius: 18px;
|
|
padding: 14px;
|
|
background: rgba(0,0,0,.14);
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
line-height: 1.55;
|
|
margin-bottom: 14px;
|
|
}
|
|
.pill-row { display: flex; gap: 7px; flex-wrap: wrap; margin: 8px 0; }
|
|
.pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
border: 1px solid rgba(255,255,255,.16);
|
|
border-radius: 999px;
|
|
padding: 4px 8px;
|
|
font-size: 12px;
|
|
color: #dfe8f8;
|
|
background: rgba(255,255,255,.08);
|
|
}
|
|
.pill.p5 { color: #1b1200; background: var(--gold); border-color: var(--gold); font-weight: 800; }
|
|
.pill.p4 { color: #081b10; background: var(--accent); border-color: var(--accent); font-weight: 800; }
|
|
.criteria-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 10px;
|
|
}
|
|
.criterion {
|
|
border: 1px solid var(--border);
|
|
border-radius: 16px;
|
|
padding: 12px;
|
|
background: rgba(0, 0, 0, 0.12);
|
|
}
|
|
.criterion strong { color: var(--accent); }
|
|
.status-line {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
color: var(--muted);
|
|
font-size: 14px;
|
|
}
|
|
.dot {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 999px;
|
|
background: var(--accent);
|
|
box-shadow: 0 0 22px rgba(142, 230, 179, .8);
|
|
}
|
|
.danger { color: var(--danger); }
|
|
.tabs { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; }
|
|
.tabs button { padding: 9px 12px; border-radius: 999px; font-size: 13px; }
|
|
.nav-tabs {
|
|
display: flex;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
position: sticky;
|
|
top: 12px;
|
|
z-index: 10;
|
|
padding: 10px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 24px;
|
|
background: rgba(5, 7, 13, 0.72);
|
|
backdrop-filter: blur(18px);
|
|
box-shadow: var(--shadow);
|
|
display: none;
|
|
}
|
|
.nav-tabs button {
|
|
padding: 11px 16px;
|
|
border-radius: 999px;
|
|
color: var(--muted);
|
|
}
|
|
.nav-tabs button.active {
|
|
color: #07110b;
|
|
background: var(--accent);
|
|
border-color: var(--accent);
|
|
}
|
|
.tab-panel { display: none; }
|
|
.tab-panel.active { display: block; }
|
|
.reader-layout {
|
|
display: grid;
|
|
grid-template-columns: minmax(360px, 39%) minmax(460px, 1fr);
|
|
gap: 18px;
|
|
align-items: start;
|
|
}
|
|
.reader-pane { min-height: 70vh; max-height: 74vh; overflow: auto; }
|
|
.reader-title {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 14px;
|
|
align-items: baseline;
|
|
margin-bottom: 16px;
|
|
padding-bottom: 14px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.reader-title h2 { margin: 0; }
|
|
.reader-pane .file-body {
|
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
|
|
font-size: 15px;
|
|
line-height: 1.72;
|
|
color: #edf4ff;
|
|
}
|
|
@media (max-width: 980px) {
|
|
header { display: block; }
|
|
.app-shell { grid-template-columns: 1fr; }
|
|
.sidebar { position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--border); }
|
|
.side-nav { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
|
.side-nav button { justify-content: center; text-align: center; }
|
|
.side-nav .count { display: none; }
|
|
.workspace { padding: 16px; }
|
|
.topbar { position: static; grid-template-columns: 1fr; }
|
|
.command-row { justify-content: flex-start; }
|
|
.chip { display: inline-block; margin-top: 20px; }
|
|
.span-4, .span-5, .span-6, .span-7 { grid-column: span 12; }
|
|
.actions { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
.reader-layout { grid-template-columns: 1fr; }
|
|
.reader-pane { min-height: 320px; max-height: 58vh; }
|
|
}
|
|
@media (max-width: 560px) {
|
|
.actions { grid-template-columns: 1fr; }
|
|
.card { border-radius: 22px; padding: 18px; }
|
|
.side-nav { grid-template-columns: 1fr 1fr; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="app-shell">
|
|
<aside class="sidebar">
|
|
<div class="brand">
|
|
<div class="brand-mark"></div>
|
|
<div>
|
|
<div class="brand-title">Mail Agent</div>
|
|
<div class="brand-subtitle">local macOS workspace</div>
|
|
</div>
|
|
</div>
|
|
<nav class="side-nav">
|
|
<button class="active" data-tab="overview" onclick="switchTab('overview')"><span>Обзор</span></button>
|
|
<button data-tab="messages" onclick="switchTab('messages')"><span>Письма</span><span class="count" id="messageCountNav">0</span></button>
|
|
<button data-tab="replies" onclick="switchTab('replies')"><span>Ответы</span><span class="count" id="replyCountNav">0</span></button>
|
|
<button data-tab="files" onclick="switchTab('files')"><span>Файлы</span></button>
|
|
<button data-tab="log" onclick="switchTab('log')"><span>Лог</span></button>
|
|
</nav>
|
|
<div class="sidebar-footer">
|
|
<div class="status-card"><span><span class="dot"></span> <span id="status">Готов</span></span></div>
|
|
<div>Updated: <span id="updated">-</span></div>
|
|
<div class="chip">Apple Silicon · local only</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<main class="workspace">
|
|
<section class="topbar">
|
|
<div class="page-title">
|
|
<h1>Personal Mail Agent</h1>
|
|
<p>Реальные письма, приоритеты, подготовленные ответы и локальная память. Без автоотправки.</p>
|
|
</div>
|
|
<div class="command-row">
|
|
<button class="primary" onclick="startJob('run')">Daily Run</button>
|
|
<button onclick="startJob('summary')">Summary</button>
|
|
<button onclick="startJob('learn')">Learn</button>
|
|
<button onclick="startJob('digest')">Digest</button>
|
|
<button onclick="startJob('drafts')">Drafts</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="tab-overview" class="tab-panel active">
|
|
<section class="grid">
|
|
<article class="card span-4 metric-card">
|
|
<div class="metric-label">Unread Parsed</div>
|
|
<div class="metric" id="total">-</div>
|
|
<p class="metric-foot" id="sources">Источники пока не загружены</p>
|
|
</article>
|
|
<article class="card span-4 metric-card">
|
|
<div class="metric-label">Style Profile</div>
|
|
<div class="metric" id="samples">-</div>
|
|
<p class="metric-foot" id="style">Профиль пока не загружен</p>
|
|
</article>
|
|
<article class="card span-4 metric-card">
|
|
<div class="metric-label">Drafts Today</div>
|
|
<div class="metric" id="draftCount">-</div>
|
|
<p class="metric-foot" id="draftDir">drafts/YYYY-MM-DD</p>
|
|
</article>
|
|
<article class="card span-12">
|
|
<h2>Критерии важности</h2>
|
|
<div class="criteria-grid">
|
|
<div class="criterion"><strong>P5</strong><br><span class="muted">Срочно, deadline, critical, высокий риск.</span></div>
|
|
<div class="criterion"><strong>P4</strong><br><span class="muted">Нужно ответить, согласовать, approve/confirm.</span></div>
|
|
<div class="criterion"><strong>P3</strong><br><span class="muted">Встреча, созвон, календарь, рабочее обсуждение.</span></div>
|
|
<div class="criterion"><strong>P2</strong><br><span class="muted">Низкий приоритет, полезно посмотреть позже.</span></div>
|
|
<div class="criterion"><strong>P1</strong><br><span class="muted">FYI, рассылки, уведомления без действия.</span></div>
|
|
<div class="criterion"><strong>Labels</strong><br><span class="muted">urgent, needs_reply, meeting, finance_or_contract, newsletter, fyi.</span></div>
|
|
</div>
|
|
</article>
|
|
</section>
|
|
</section>
|
|
|
|
<section id="tab-messages" class="tab-panel">
|
|
<div class="reader-layout">
|
|
<article class="card">
|
|
<h2>Письма</h2>
|
|
<div class="list wide-list" id="messages"></div>
|
|
</article>
|
|
<article class="card reader-pane">
|
|
<div class="reader-title"><h2>Просмотр письма</h2><span class="muted">full parsed body</span></div>
|
|
<div class="file-body" id="messageViewer">Выбери письмо слева.</div>
|
|
</article>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="tab-replies" class="tab-panel">
|
|
<div class="reader-layout">
|
|
<article class="card">
|
|
<h2>Подготовленные ответы</h2>
|
|
<div class="list wide-list" id="replies"></div>
|
|
</article>
|
|
<article class="card reader-pane">
|
|
<div class="reader-title"><h2>Просмотр ответа</h2><span class="muted">markdown draft</span></div>
|
|
<div class="file-body" id="replyViewer">Выбери ответ слева.</div>
|
|
</article>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="tab-files" class="tab-panel">
|
|
<div class="reader-layout">
|
|
<article class="card">
|
|
<h2>Структура</h2>
|
|
<div class="list wide-list" id="structure"></div>
|
|
</article>
|
|
<article class="card reader-pane">
|
|
<div class="tabs">
|
|
<button onclick="loadFile('digest')">Digest</button>
|
|
<button onclick="loadFile('report')">Report</button>
|
|
<button onclick="loadFile('profile')">Profile</button>
|
|
</div>
|
|
<div class="file-body" id="viewer">Выбери файл слева или кнопку выше.</div>
|
|
</article>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="tab-log" class="tab-panel">
|
|
<article class="card">
|
|
<h2>Job Output</h2>
|
|
<pre id="log">Пока задач не было.</pre>
|
|
</article>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
|
|
<script>
|
|
let activeJob = null;
|
|
|
|
async function api(path, options = {}) {
|
|
const response = await fetch(path, options);
|
|
const text = await response.text();
|
|
let data;
|
|
try { data = JSON.parse(text); } catch (_) { data = { error: text }; }
|
|
if (!response.ok) throw new Error(data.error || response.statusText);
|
|
return data;
|
|
}
|
|
|
|
async function refresh() {
|
|
const state = await api('/api/state');
|
|
document.getElementById('total').textContent = state.summary?.total ?? '-';
|
|
document.getElementById('sources').textContent = formatCounts(state.summary?.by_source) || 'Нет summary';
|
|
document.getElementById('samples').textContent = state.profile?.samples ?? '-';
|
|
document.getElementById('style').textContent = state.profile ? `${state.profile.language}, ${state.profile.tone}, avg ${state.profile.avg_words} words` : 'Нет профиля';
|
|
document.getElementById('draftCount').textContent = state.drafts.length;
|
|
document.getElementById('draftDir').textContent = state.paths.drafts_dir;
|
|
document.getElementById('messageCountNav').textContent = state.messages.length;
|
|
document.getElementById('replyCountNav').textContent = state.replies.length;
|
|
document.getElementById('updated').textContent = state.updated_at;
|
|
renderDrafts(state.drafts);
|
|
renderMessages(state.messages);
|
|
renderReplies(state.replies);
|
|
renderStructure(state.structure);
|
|
if (!activeJob) document.getElementById('status').textContent = 'Готов';
|
|
}
|
|
|
|
function formatCounts(value) {
|
|
if (!value) return '';
|
|
return Object.entries(value).map(([k, v]) => `${k}: ${v}`).join(' · ');
|
|
}
|
|
|
|
function renderDrafts(drafts) {
|
|
const box = document.getElementById('drafts');
|
|
if (!box) return;
|
|
box.innerHTML = '';
|
|
if (!drafts.length) {
|
|
box.innerHTML = '<p class="muted">Сегодня черновиков нет.</p>';
|
|
return;
|
|
}
|
|
for (const draft of drafts) {
|
|
const div = document.createElement('div');
|
|
div.className = 'item';
|
|
div.innerHTML = `<strong>${escapeHtml(draft.name)}</strong><span class="muted">${draft.path}</span>`;
|
|
div.onclick = () => loadFile('draft', draft.path);
|
|
box.appendChild(div);
|
|
}
|
|
}
|
|
|
|
function renderMessages(messages) {
|
|
const box = document.getElementById('messages');
|
|
if (!box) return;
|
|
box.innerHTML = '';
|
|
if (!messages.length) {
|
|
box.innerHTML = '<p class="muted">Письма еще не сохранены. Нажми Daily Run или Summary.</p>';
|
|
return;
|
|
}
|
|
for (const message of messages) {
|
|
const div = document.createElement('div');
|
|
div.className = 'item';
|
|
div.innerHTML = `<strong>${escapeHtml(message.subject || '(no subject)')}</strong><div class="pill-row"><span class="pill p${message.priority}">P${message.priority}</span>${(message.labels || []).map(label => `<span class="pill">${escapeHtml(label)}</span>`).join('')}</div><span class="muted">${escapeHtml(message.sender || 'unknown')}</span><p>${escapeHtml(message.preview || '')}</p>`;
|
|
div.onclick = () => loadFile('message', message.path, 'messageViewer');
|
|
box.appendChild(div);
|
|
}
|
|
}
|
|
|
|
function renderReplies(replies) {
|
|
const box = document.getElementById('replies');
|
|
if (!box) return;
|
|
box.innerHTML = '';
|
|
if (!replies.length) {
|
|
box.innerHTML = '<p class="muted">Подготовленных ответов пока нет. Нажми Daily Run или Drafts.</p>';
|
|
return;
|
|
}
|
|
for (const reply of replies) {
|
|
const div = document.createElement('div');
|
|
div.className = 'item reply-focus';
|
|
div.innerHTML = `<strong>${escapeHtml(reply.subject || reply.name)}</strong><div class="pill-row"><span class="pill p${reply.priority || ''}">P${reply.priority || '?'}</span>${String(reply.labels || '').split(',').filter(Boolean).map(label => `<span class="pill">${escapeHtml(label.trim())}</span>`).join('')}</div><div class="reply-preview">${escapeHtml(reply.reply || '')}</div>`;
|
|
div.onclick = () => showReply(reply);
|
|
box.appendChild(div);
|
|
}
|
|
const viewer = document.getElementById('replyViewer');
|
|
if (viewer && viewer.textContent.includes('Выбери ответ')) showReply(replies[0]);
|
|
}
|
|
|
|
function showReply(reply) {
|
|
const viewer = document.getElementById('replyViewer');
|
|
const labels = String(reply.labels || '').split(',').filter(Boolean).map(label => `<span class="pill">${escapeHtml(label.trim())}</span>`).join('');
|
|
viewer.innerHTML = `
|
|
<div class="reply-meta">
|
|
<strong>${escapeHtml(reply.subject || reply.name)}</strong>
|
|
<div class="pill-row"><span class="pill p${reply.priority || ''}">P${reply.priority || '?'}</span>${labels}</div>
|
|
<div>${escapeHtml(reply.path || '')}</div>
|
|
</div>
|
|
<div class="my-answer-card">
|
|
<div class="my-answer-label">Мой подготовленный ответ</div>
|
|
<div class="my-answer-text">${escapeHtml(reply.reply || 'Ответ пустой')}</div>
|
|
</div>
|
|
<button onclick="loadFile('draft', '${escapeJs(reply.path || '')}', 'replyViewer')">Показать весь markdown-черновик</button>
|
|
`;
|
|
viewer.scrollTo({ top: 0, behavior: 'smooth' });
|
|
}
|
|
|
|
function renderStructure(items) {
|
|
const box = document.getElementById('structure');
|
|
if (!box) return;
|
|
box.innerHTML = '';
|
|
if (!items.length) {
|
|
box.innerHTML = '<p class="muted">Структура пока пустая.</p>';
|
|
return;
|
|
}
|
|
for (const item of items) {
|
|
const div = document.createElement('div');
|
|
div.className = 'item';
|
|
div.innerHTML = `<strong>${escapeHtml(item.name)}</strong><span class="muted">${escapeHtml(item.path)}</span>`;
|
|
if (item.kind === 'file') div.onclick = () => loadFile('any', item.path, 'viewer');
|
|
box.appendChild(div);
|
|
}
|
|
}
|
|
|
|
function switchTab(name) {
|
|
for (const panel of document.querySelectorAll('.tab-panel')) panel.classList.remove('active');
|
|
for (const button of document.querySelectorAll('.side-nav button')) button.classList.remove('active');
|
|
document.getElementById(`tab-${name}`).classList.add('active');
|
|
document.querySelector(`.side-nav button[data-tab="${name}"]`).classList.add('active');
|
|
}
|
|
|
|
async function startJob(name) {
|
|
setBusy(true, `Запущено: ${name}`);
|
|
try {
|
|
const result = await api(`/api/${name}`, { method: 'POST' });
|
|
activeJob = result.job_id;
|
|
pollJob(result.job_id);
|
|
} catch (error) {
|
|
document.getElementById('log').textContent = error.message;
|
|
setBusy(false, 'Ошибка');
|
|
}
|
|
}
|
|
|
|
async function pollJob(jobId) {
|
|
const job = await api(`/api/job/${jobId}`);
|
|
document.getElementById('log').textContent = job.output || 'Работает...';
|
|
if (job.status === 'running') {
|
|
setTimeout(() => pollJob(jobId), 1200);
|
|
return;
|
|
}
|
|
activeJob = null;
|
|
setBusy(false, job.status === 'ok' ? 'Готов' : 'Ошибка');
|
|
await refresh();
|
|
if (job.status === 'error') document.getElementById('status').classList.add('danger');
|
|
}
|
|
|
|
async function loadFile(kind, path = '', target = 'viewer') {
|
|
const query = path ? `?path=${encodeURIComponent(path)}` : '';
|
|
const data = await api(`/api/file/${kind}${query}`);
|
|
document.getElementById(target).textContent = data.content || 'Файл пустой или еще не создан.';
|
|
}
|
|
|
|
function setBusy(isBusy, text) {
|
|
document.getElementById('status').textContent = text;
|
|
document.getElementById('status').classList.remove('danger');
|
|
for (const button of document.querySelectorAll('button')) button.disabled = isBusy;
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return value.replace(/[&<>'"]/g, char => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[char]));
|
|
}
|
|
|
|
function escapeJs(value) {
|
|
return String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
}
|
|
|
|
refresh().catch(error => document.getElementById('log').textContent = error.message);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
class AppHandler(BaseHTTPRequestHandler):
|
|
server_version = "EmailAgentUI/0.1"
|
|
|
|
def do_GET(self) -> None:
|
|
parsed = urlparse(self.path)
|
|
if parsed.path == "/":
|
|
self.send_html(HTML)
|
|
elif parsed.path == "/api/state":
|
|
self.send_json(build_state())
|
|
elif parsed.path.startswith("/api/job/"):
|
|
job_id = parsed.path.rsplit("/", 1)[-1]
|
|
self.send_json(JOBS.get(job_id, {"status": "missing", "output": "Job not found"}))
|
|
elif parsed.path.startswith("/api/file/"):
|
|
self.handle_file(parsed)
|
|
else:
|
|
self.send_error(HTTPStatus.NOT_FOUND)
|
|
|
|
def do_POST(self) -> None:
|
|
command = self.path.strip("/").split("/")[-1]
|
|
handlers = {
|
|
"run": run_daily,
|
|
"summary": run_summary,
|
|
"learn": run_learn,
|
|
"digest": run_digest,
|
|
"drafts": run_drafts,
|
|
}
|
|
if command not in handlers:
|
|
self.send_error(HTTPStatus.NOT_FOUND)
|
|
return
|
|
job_id = start_job(command, handlers[command])
|
|
self.send_json({"job_id": job_id})
|
|
|
|
def handle_file(self, parsed) -> None:
|
|
kind = parsed.path.rsplit("/", 1)[-1]
|
|
query = parse_query(parsed.query)
|
|
path = resolve_file(kind, query.get("path", ""))
|
|
if not path or not path.exists():
|
|
self.send_json({"content": ""})
|
|
return
|
|
self.send_json({"content": path.read_text(encoding="utf-8", errors="replace")[:60000]})
|
|
|
|
def send_html(self, body: str) -> None:
|
|
payload = body.encode("utf-8")
|
|
self.send_response(HTTPStatus.OK)
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
|
|
def send_json(self, value: object) -> None:
|
|
payload = json.dumps(value, ensure_ascii=False, indent=2).encode("utf-8")
|
|
self.send_response(HTTPStatus.OK)
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
|
|
def log_message(self, format: str, *args: object) -> None:
|
|
return
|
|
|
|
|
|
def start_job(name: str, func) -> str:
|
|
job_id = f"{int(time.time())}-{name}"
|
|
JOBS[job_id] = {"status": "running", "output": ""}
|
|
|
|
def target() -> None:
|
|
try:
|
|
JOBS[job_id] = {"status": "ok", "output": func()}
|
|
except Exception as exc: # UI should show failures instead of crashing the server.
|
|
JOBS[job_id] = {"status": "error", "output": f"{type(exc).__name__}: {exc}"}
|
|
|
|
threading.Thread(target=target, daemon=True).start()
|
|
return job_id
|
|
|
|
|
|
def run_daily() -> str:
|
|
sent_messages, sent_errors = email_agent.fetch_messages("all", "sent", 100, email_agent.DEFAULT_CONTENT_LIMIT)
|
|
sent_messages = email_agent.filter_by_identity(sent_messages, email_agent.DEFAULT_IDENTITY)
|
|
profile = email_agent.learn_style_profile(sent_messages)
|
|
feedback_profile = email_agent.learn_feedback_profile(BASE_DIR / email_agent.DEFAULT_FEEDBACK_DIR)
|
|
profile = email_agent.merge_profiles(profile, feedback_profile)
|
|
profile_path = BASE_DIR / email_agent.DEFAULT_PROFILE_PATH
|
|
email_agent.save_style_profile(profile, profile_path)
|
|
|
|
messages, inbox_errors = email_agent.fetch_messages("all", "unread-mailboxes", 200, email_agent.DEFAULT_CONTENT_LIMIT)
|
|
messages = email_agent.dedupe_messages(messages)
|
|
digest_path = BASE_DIR / email_agent.default_digest_path()
|
|
summary_path = BASE_DIR / email_agent.default_summary_path()
|
|
messages_path = BASE_DIR / email_agent.default_messages_path()
|
|
contacts_path = BASE_DIR / email_agent.DEFAULT_CONTACTS_PATH
|
|
calendar_path = BASE_DIR / email_agent.default_calendar_path()
|
|
report_path = BASE_DIR / email_agent.default_report_path()
|
|
drafts_dir = BASE_DIR / email_agent.default_drafts_dir()
|
|
email_agent.clear_markdown_dir(drafts_dir)
|
|
|
|
digest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
digest_path.write_text(email_agent.build_digest(messages), encoding="utf-8")
|
|
|
|
summary_path.parent.mkdir(parents=True, exist_ok=True)
|
|
summary_path.write_text(json.dumps(email_agent.summarize(messages), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
email_agent.write_messages_jsonl(messages, messages_path)
|
|
email_agent.update_contacts_memory(messages, contacts_path)
|
|
meeting_messages = email_agent.write_calendar_suggestions(messages, calendar_path)
|
|
|
|
drafts = email_agent.write_drafts(email_agent.prioritize_messages(messages)[:10], drafts_dir, "auto", "learned", profile)
|
|
|
|
errors = sent_errors + inbox_errors
|
|
report_path.parent.mkdir(parents=True, exist_ok=True)
|
|
report_path.write_text(email_agent.render_daily_report(messages, digest_path, drafts, profile, errors), encoding="utf-8")
|
|
|
|
return "\n".join(
|
|
[
|
|
f"Profile: {profile_path}",
|
|
f"Identity: {email_agent.DEFAULT_IDENTITY}",
|
|
f"Approved feedback samples: {feedback_profile.samples}",
|
|
f"Summary: {summary_path}",
|
|
f"Messages: {messages_path}",
|
|
f"Contacts: {contacts_path}",
|
|
f"Calendar: {calendar_path} ({len(meeting_messages)} meeting emails)",
|
|
f"Digest: {digest_path}",
|
|
f"Drafts: {drafts_dir} ({len(drafts)} files)",
|
|
f"Report: {report_path}",
|
|
*[f"warning: {error}" for error in errors],
|
|
]
|
|
)
|
|
|
|
|
|
def run_summary() -> str:
|
|
messages, errors = email_agent.fetch_messages("all", "unread-mailboxes", 200, email_agent.DEFAULT_CONTENT_LIMIT)
|
|
messages = email_agent.dedupe_messages(messages)
|
|
summary = email_agent.summarize(messages)
|
|
path = BASE_DIR / email_agent.default_summary_path()
|
|
messages_path = BASE_DIR / email_agent.default_messages_path()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
email_agent.write_messages_jsonl(messages, messages_path)
|
|
return json.dumps(summary, ensure_ascii=False, indent=2) + format_errors(errors)
|
|
|
|
|
|
def run_learn() -> str:
|
|
messages, errors = email_agent.fetch_messages("all", "sent", 100, email_agent.DEFAULT_CONTENT_LIMIT)
|
|
messages = email_agent.filter_by_identity(messages, email_agent.DEFAULT_IDENTITY)
|
|
profile = email_agent.learn_style_profile(messages)
|
|
path = BASE_DIR / email_agent.DEFAULT_PROFILE_PATH
|
|
email_agent.save_style_profile(profile, path)
|
|
return json.dumps(email_agent.asdict(profile), ensure_ascii=False, indent=2) + format_errors(errors)
|
|
|
|
|
|
def run_digest() -> str:
|
|
messages, errors = email_agent.fetch_messages("all", "unread-mailboxes", 200, email_agent.DEFAULT_CONTENT_LIMIT)
|
|
messages = email_agent.dedupe_messages(messages)
|
|
email_agent.write_messages_jsonl(messages, BASE_DIR / email_agent.default_messages_path())
|
|
digest = email_agent.build_digest(messages)
|
|
path = BASE_DIR / email_agent.default_digest_path()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(digest, encoding="utf-8")
|
|
return f"Digest saved to {path}\n\n{digest}" + format_errors(errors)
|
|
|
|
|
|
def run_drafts() -> str:
|
|
messages, errors = email_agent.fetch_messages("all", "unread-mailboxes", 200, email_agent.DEFAULT_CONTENT_LIMIT)
|
|
messages = email_agent.dedupe_messages(messages)
|
|
email_agent.write_messages_jsonl(messages, BASE_DIR / email_agent.default_messages_path())
|
|
profile = email_agent.load_style_profile(BASE_DIR / email_agent.DEFAULT_PROFILE_PATH)
|
|
drafts_dir = BASE_DIR / email_agent.default_drafts_dir()
|
|
email_agent.clear_markdown_dir(drafts_dir)
|
|
drafts = email_agent.write_drafts(email_agent.prioritize_messages(messages)[:10], drafts_dir, "auto", "learned", profile)
|
|
return "\n".join(str(path) for path in drafts) + format_errors(errors)
|
|
|
|
|
|
def format_errors(errors: list[str]) -> str:
|
|
if not errors:
|
|
return ""
|
|
return "\n" + "\n".join(f"warning: {error}" for error in errors)
|
|
|
|
|
|
def build_state() -> dict[str, object]:
|
|
summary = read_json(BASE_DIR / email_agent.default_summary_path()) or read_json(BASE_DIR / "data/reports/2026-06-16-summary.json")
|
|
profile = read_json(BASE_DIR / email_agent.DEFAULT_PROFILE_PATH) or read_json(BASE_DIR / "data/style_profile.json")
|
|
drafts_dir = BASE_DIR / email_agent.default_drafts_dir()
|
|
drafts = []
|
|
if drafts_dir.exists():
|
|
drafts = [
|
|
{"name": path.name, "path": str(path.relative_to(BASE_DIR))}
|
|
for path in sorted(drafts_dir.glob("*.md"))
|
|
]
|
|
messages = read_messages(BASE_DIR / email_agent.default_messages_path())
|
|
return {
|
|
"summary": summary,
|
|
"profile": profile,
|
|
"drafts": drafts,
|
|
"replies": read_reply_summaries(drafts_dir),
|
|
"messages": messages,
|
|
"structure": build_structure(),
|
|
"paths": {
|
|
"drafts_dir": str(drafts_dir.relative_to(BASE_DIR)),
|
|
"digest": email_agent.default_digest_path(),
|
|
"report": email_agent.default_report_path(),
|
|
"messages": email_agent.default_messages_path(),
|
|
},
|
|
"updated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
}
|
|
|
|
|
|
def read_json(path: Path) -> object | None:
|
|
if not path.exists():
|
|
return None
|
|
|
|
|
|
def read_messages(path: Path) -> list[dict[str, object]]:
|
|
if not path.exists():
|
|
return []
|
|
messages: list[dict[str, object]] = []
|
|
for index, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), start=1):
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
row = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
row["path"] = f"{email_agent.default_messages_path()}#{index}"
|
|
messages.append(row)
|
|
return messages
|
|
|
|
|
|
def read_reply_summaries(drafts_dir: Path) -> list[dict[str, object]]:
|
|
if not drafts_dir.exists():
|
|
return []
|
|
replies: list[dict[str, object]] = []
|
|
for path in sorted(drafts_dir.glob("*.md")):
|
|
content = path.read_text(encoding="utf-8", errors="replace")
|
|
replies.append(
|
|
{
|
|
"name": path.name,
|
|
"path": str(path.relative_to(BASE_DIR)),
|
|
"subject": extract_field(content, "Original subject"),
|
|
"priority": extract_field(content, "Priority").removeprefix("P"),
|
|
"labels": extract_field(content, "Labels"),
|
|
"reply": extract_section(content, "## Suggested Reply", "## Original Message Preview")[:900],
|
|
}
|
|
)
|
|
return replies
|
|
|
|
|
|
def extract_field(content: str, field: str) -> str:
|
|
pattern = rf"^\s*{re_escape(field)}:\s*(.+?)\s*$"
|
|
import re
|
|
|
|
match = re.search(pattern, content, flags=re.MULTILINE)
|
|
return match.group(1).strip() if match else ""
|
|
|
|
|
|
def extract_section(content: str, start: str, end: str) -> str:
|
|
start_index = content.find(start)
|
|
if start_index == -1:
|
|
return ""
|
|
start_index += len(start)
|
|
end_index = content.find(end, start_index)
|
|
if end_index == -1:
|
|
end_index = len(content)
|
|
return content[start_index:end_index].strip()
|
|
|
|
|
|
def re_escape(value: str) -> str:
|
|
import re
|
|
|
|
return re.escape(value)
|
|
|
|
|
|
def build_structure() -> list[dict[str, str]]:
|
|
roots = [BASE_DIR / "data", BASE_DIR / "drafts"]
|
|
items: list[dict[str, str]] = []
|
|
for root in roots:
|
|
if not root.exists():
|
|
continue
|
|
for path in sorted(root.rglob("*"))[:120]:
|
|
rel = str(path.relative_to(BASE_DIR))
|
|
items.append({"name": path.name + ("/" if path.is_dir() else ""), "path": rel, "kind": "dir" if path.is_dir() else "file"})
|
|
return items
|
|
try:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return None
|
|
|
|
|
|
def resolve_file(kind: str, request_path: str) -> Path | None:
|
|
if kind == "digest":
|
|
return BASE_DIR / email_agent.default_digest_path()
|
|
if kind == "report":
|
|
return BASE_DIR / email_agent.default_report_path()
|
|
if kind == "profile":
|
|
return BASE_DIR / email_agent.DEFAULT_PROFILE_PATH
|
|
if kind == "draft" and request_path:
|
|
candidate = (BASE_DIR / request_path).resolve()
|
|
if BASE_DIR in candidate.parents and candidate.suffix == ".md":
|
|
return candidate
|
|
if kind == "message" and request_path:
|
|
path_part, _, line_no = request_path.partition("#")
|
|
candidate = (BASE_DIR / path_part).resolve()
|
|
if BASE_DIR in candidate.parents and candidate.suffix == ".jsonl" and candidate.exists():
|
|
try:
|
|
index = int(line_no)
|
|
line = candidate.read_text(encoding="utf-8", errors="replace").splitlines()[index - 1]
|
|
row = json.loads(line)
|
|
tmp = BASE_DIR / ".message-preview.md"
|
|
tmp.write_text(render_message_preview(row), encoding="utf-8")
|
|
return tmp
|
|
except (ValueError, IndexError, json.JSONDecodeError):
|
|
return None
|
|
if kind == "any" and request_path:
|
|
candidate = (BASE_DIR / request_path).resolve()
|
|
if BASE_DIR in candidate.parents and candidate.is_file():
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def render_message_preview(row: dict[str, object]) -> str:
|
|
labels = ", ".join(row.get("labels", [])) if isinstance(row.get("labels"), list) else ""
|
|
return "\n".join(
|
|
[
|
|
"# Parsed Email",
|
|
"",
|
|
f"Source: {row.get('source', '')}",
|
|
f"Sender: {row.get('sender', '')}",
|
|
f"Subject: {row.get('subject', '')}",
|
|
f"Received: {row.get('received_at', '')}",
|
|
f"Priority: P{row.get('priority', '')}",
|
|
f"Labels: {labels}",
|
|
"",
|
|
"## Body",
|
|
"",
|
|
str(row.get("body", ""))[:8000],
|
|
]
|
|
)
|
|
|
|
|
|
def parse_query(query: str) -> dict[str, str]:
|
|
result: dict[str, str] = {}
|
|
for part in query.split("&"):
|
|
if not part:
|
|
continue
|
|
key, _, value = part.partition("=")
|
|
result[unquote(key)] = unquote(value)
|
|
return result
|
|
|
|
|
|
def main() -> int:
|
|
server = ThreadingHTTPServer((HOST, PORT), AppHandler)
|
|
url = f"http://{HOST}:{PORT}"
|
|
print(f"Email Agent UI: {url}")
|
|
webbrowser.open(url)
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
print("\nStopping UI")
|
|
finally:
|
|
server.server_close()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|