Files
2026-06-16 15:56:33 +03:00

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 => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[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())