commit ca71befb493001383e7753eb38c2f4167c2a0e8b Author: serhei_t Date: Tue Jun 16 15:56:33 2026 +0300 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..91d2bd9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +drafts/ +data/ +.env +.message-preview.md +feedback/approved/*.md +!feedback/approved/.gitkeep +__pycache__/ +*.pyc diff --git a/Launch Email Agent UI.command b/Launch Email Agent UI.command new file mode 100755 index 0000000..1d505d7 --- /dev/null +++ b/Launch Email Agent UI.command @@ -0,0 +1,3 @@ +#!/bin/zsh +cd "$(dirname "$0")" +python3 web_app.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..577c252 --- /dev/null +++ b/README.md @@ -0,0 +1,280 @@ +# Local Email Agent + +Минимальный локальный агент для macOS: читает письма из Apple Mail и Microsoft Outlook через AppleScript, считает письма, экспортирует их и готовит безопасные черновики ответов. Автоматически письма не отправляет. + +## Быстрый старт + +Запуск интерфейса под macOS: + +```bash +python3 web_app.py +``` + +Или двойной клик по файлу: + +```text +Launch Email Agent UI.command +``` + +Откроется локальный интерфейс: + +```text +http://127.0.0.1:8787 +``` + +Интерфейс работает локально на ноутбуке, подходит для Apple Silicon/Mac и ничего не отправляет автоматически. + +CLI-запуск: + +```bash +python3 email_agent.py doctor + python3 email_agent.py run --source all --mode unread-mailboxes --limit 200 --draft-limit 10 +``` + +При первом запуске macOS может спросить разрешение на управление `Mail` или `Microsoft Outlook`. Разрешение нужно дать для приложения, из которого запускается скрипт: Terminal, iTerm, VS Code или другой shell. + +## Команды + +## Интерфейс + +В `web_app.py` есть первый UI для Mac: + +- `Daily Run` - полный сценарий: learn, summary, digest, drafts, report. +- `Summary` - пересчитать непрочитанные. +- `Learn` - обновить профиль стиля из отправленных писем. +- `Digest` - создать digest за день. +- `Drafts` - создать черновики в `drafts/YYYY-MM-DD/`. +- `Письма` - список реально распарсенных писем из `data/messages/YYYY-MM-DD.jsonl`. +- `Подготовленные ответы` - отдельное поле с темой, приоритетом, метками и текстом `Suggested Reply` из черновиков. +- `Критерии важности` - расшифровка P1-P5 и labels. +- `Структура` - дерево рабочих файлов `data/` и `drafts/`. + +Правая панель показывает `Digest`, `Report`, `Profile`, выбранное письмо или выбранный черновик. Нижняя панель показывает лог последней операции. + +Интерфейс сделан как современный desktop app-shell: + +- Левый sidebar с навигацией и счетчиками писем/ответов. +- Верхняя command bar с быстрыми действиями `Daily Run`, `Summary`, `Learn`, `Digest`, `Drafts`. +- Широкие master-detail экраны для чтения писем и ответов. +- Отдельные reader-панели с полным телом письма или markdown-черновиком. + +Разделы интерфейса: + +- `Обзор` - метрики и критерии важности. +- `Письма` - список писем слева и широкий просмотр выбранного письма справа. +- `Ответы` - список подготовленных ответов слева и широкий просмотр выбранного ответа справа. +- `Файлы` - структура `data/` и `drafts/`, digest, report, profile. +- `Лог` - вывод последней операции. + +Критерии важности: + +- `P5` - срочно, deadline, critical, высокий риск. +- `P4` - нужно ответить, согласовать, approve/confirm. +- `P3` - встреча, созвон, календарь, рабочее обсуждение. +- `P2` - низкий приоритет, полезно посмотреть позже. +- `P1` - FYI, рассылки, уведомления без действия. + +Обучение по умолчанию фильтрует отправленные письма по identity: + +```text +s.tomashev@a1.by +``` + +Если нужно поменять адрес: + +```bash +python3 email_agent.py run --identity another.email@example.com +python3 email_agent.py learn --identity another.email@example.com +``` + +Черновики текущего дня по умолчанию очищаются перед новой генерацией, чтобы UI не показывал старые ответы вперемешку с новыми. Чтобы сохранить старые файлы, используй `--no-clean`. + +Запуск: + +```bash +python3 web_app.py +``` + +Остановка: `Ctrl+C` в терминале, где запущен интерфейс. + +```bash +python3 email_agent.py run --source all --mode unread-mailboxes --limit 200 --draft-limit 10 --identity s.tomashev@a1.by +``` + +Запускает ежедневный сценарий целиком: обучение на отправленных письмах, summary, digest, черновики и daily report. + +По умолчанию агент читает `unread-mailboxes`: сначала `Входящие/INBOX`, затем остальные папки правил. Это нужно для Exchange, где часть писем лежит не в главном inbox, а в `Monitoring`, `Deeplog`, `HybrisSync`, `svc1cDocflow` и других mailbox. + +Диагностика mailbox: + +```bash +python3 email_agent.py mailboxes --source all +``` + +Режимы чтения: + +- `unread` - только системный inbox клиента. +- `unread-all` - inbox всех аккаунтов. +- `unread-mailboxes` - inbox плюс рабочие папки правил, без архивов/удаленных/отправленных/спама. +- `all-mailboxes` - все рабочие папки с прочитанными и непрочитанными, кроме исключенных системных. + +Файлы раскладываются так: + +```text +data/profiles/style_profile.json +data/messages/YYYY-MM-DD.jsonl +data/contacts/contacts.json +data/calendar/YYYY-MM-DD.md +data/digests/YYYY-MM-DD.md +data/reports/YYYY-MM-DD-summary.json +data/reports/YYYY-MM-DD.md +drafts/YYYY-MM-DD/*.md +``` + +```bash +python3 email_agent.py summary +``` + +Показывает количество писем, разбивку по источникам, отправителям и доменам. + +```bash +python3 email_agent.py learn --source all --limit 100 +``` + +Читает последние отправленные письма и сохраняет локальный профиль твоего стиля в `data/profiles/style_profile.json`. Агент фильтрует пересланные цепочки, календарные инвайты, служебные подписи и рассылки, чтобы не учиться на мусоре. + +```bash +python3 email_agent.py digest --source all --mode unread --limit 50 +``` + +Классифицирует письма по приоритету и меткам: `urgent`, `needs_reply`, `meeting`, `finance_or_contract`, `newsletter`, `fyi`. + +По умолчанию сохраняет digest в `data/digests/YYYY-MM-DD.md`. Для вывода в консоль используй `--output -`. + +```bash +python3 email_agent.py export --output data/exports/messages.jsonl +``` + +Экспортирует письма в JSONL для дальнейшего анализа. + +```bash +python3 email_agent.py drafts +``` + +Создает Markdown-черновики ответов в папке `drafts/YYYY-MM-DD/`. + +По умолчанию использует профиль из `data/profiles/style_profile.json`, если он уже создан командой `learn` или `run`. + +Создать черновики прямо в Apple Mail без отправки: + +```bash +python3 email_agent.py publish-drafts --client apple-mail --input-dir drafts/YYYY-MM-DD --limit 5 +``` + +Для Outlook: + +```bash +python3 email_agent.py publish-drafts --client outlook --input-dir drafts/YYYY-MM-DD --limit 5 +``` + +Команда создает draft messages в почтовом клиенте, но не отправляет их. + +## Календарь, Контакты, Feedback + +Календарные предложения для писем про встречи: + +```bash +python3 email_agent.py calendar --source all --mode unread --limit 50 +``` + +Файл сохраняется в `data/calendar/YYYY-MM-DD.md`. Если нужно создать локальные события в Calendar без отправки приглашений: + +```bash +python3 email_agent.py calendar --source all --mode unread --limit 50 --create-events +``` + +Память по контактам: + +```bash +python3 email_agent.py contacts --source all --mode unread --limit 100 +``` + +Файл сохраняется в `data/contacts/contacts.json`. Там хранится частота контакта, важность, последний subject и базовый `reply_style`. + +Approved-feedback: + +```bash +python3 email_agent.py feedback +``` + +Чтобы агент учился на исправленных ответах, положи markdown-файлы в `feedback/approved/`. Лучше использовать секцию: + +```markdown +## Approved Reply + +Финальная версия ответа, которую реально отправил. +``` + +`run` автоматически подхватывает approved-feedback и смешивает его с профилем отправленных писем. + +## Источники + +Можно выбрать конкретный источник: + +```bash +python3 email_agent.py summary --source apple-mail +python3 email_agent.py summary --source outlook +python3 email_agent.py summary --source all +``` + +Режимы чтения: + +```bash +--mode unread +--mode inbox +--mode sent +--mode unread-all +--mode unread-mailboxes +--mode all-mailboxes +``` + +`unread` читает непрочитанные письма из системного inbox. `inbox` читает письма из входящих. `sent` читает отправленные письма и используется для обучения стилю. `unread-mailboxes` читает непрочитанные из inbox и рабочих папок правил. + +## AI-ответы + +По умолчанию агент делает осторожные шаблонные ответы. Если задать OpenAI-compatible API, он будет готовить более осмысленные черновики: + +```bash +export OPENAI_API_KEY="your-key" +export OPENAI_MODEL="gpt-4o-mini" +export OPENAI_BASE_URL="https://api.openai.com/v1" +python3 email_agent.py drafts --limit 10 +``` + +Если профиль стиля уже создан, он автоматически добавляется в LLM-промпт: + +```bash +python3 email_agent.py drafts --style learned --language auto --limit 10 +``` + +Отключить профиль можно так: + +```bash +python3 email_agent.py drafts --no-profile --style neutral --language ru +``` + +Для подписи можно задать: + +```bash +export EMAIL_AGENT_SIGNATURE="Ваше имя" +``` + +## Безопасность + +Агент не отправляет письма и не меняет статус прочтения намеренно. Он читает доступные письма, создает локальные файлы с черновиками и может создавать draft messages в Mail/Outlook без отправки. + +## Частые проблемы + +Если видите ошибку доступа AppleScript, откройте `System Settings -> Privacy & Security -> Automation` и разрешите вашему терминалу управлять `Mail` или `Microsoft Outlook`. + +Если Outlook не отвечает на AppleScript, проверьте, что приложение установлено, запущено и использует классический режим с AppleScript-поддержкой. В некоторых версиях нового Outlook для macOS AppleScript ограничен. diff --git a/email_agent.py b/email_agent.py new file mode 100644 index 0000000..4a75581 --- /dev/null +++ b/email_agent.py @@ -0,0 +1,1679 @@ +#!/usr/bin/env python3 +"""Local macOS email assistant for Apple Mail and Microsoft Outlook. + +The agent reads mail via AppleScript, summarizes counts, and prepares safe +reply drafts as Markdown files. It never sends email automatically. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import json +import os +import re +import subprocess +import sys +import textwrap +import urllib.error +import urllib.request +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Iterable + + +FIELD_SEP = "__EMAIL_AGENT_FIELD__" +RECORD_SEP = "__EMAIL_AGENT_RECORD__" +DEFAULT_CONTENT_LIMIT = 6000 +DEFAULT_PROFILE_PATH = "data/profiles/style_profile.json" +DEFAULT_IDENTITY = "s.tomashev@a1.by" +DEFAULT_CONTACTS_PATH = "data/contacts/contacts.json" +DEFAULT_FEEDBACK_DIR = "feedback/approved" + + +@dataclass +class EmailMessage: + source: str + uid: str + sender: str + subject: str + received_at: str + body: str + + @property + def sender_domain(self) -> str: + match = re.search(r"@([A-Za-z0-9.-]+)", self.sender) + return match.group(1).lower() if match else "unknown" + + +@dataclass +class StyleProfile: + samples: int + language: str + avg_words: int + greeting_examples: list[str] + closing_examples: list[str] + frequent_phrases: list[str] + tone: str + updated_at: str + + +class MailSourceError(RuntimeError): + pass + + +APPLE_MAIL_SCRIPT = r''' +on joinList(theList, theDelimiter) + set oldDelimiters to AppleScript's text item delimiters + set AppleScript's text item delimiters to theDelimiter + set joinedText to theList as text + set AppleScript's text item delimiters to oldDelimiters + return joinedText +end joinList + +on safeText(theValue) + try + set theText to theValue as text + on error + set theText to "" + end try + set theText to my replaceText(theText, "__EMAIL_AGENT_FIELD__", " ") + set theText to my replaceText(theText, "__EMAIL_AGENT_RECORD__", " ") + return theText +end safeText + +on replaceText(theText, searchText, replacementText) + set oldDelimiters to AppleScript's text item delimiters + set AppleScript's text item delimiters to searchText + set textItems to text items of theText + set AppleScript's text item delimiters to replacementText + set newText to textItems as text + set AppleScript's text item delimiters to oldDelimiters + return newText +end replaceText + +on clipText(theText, maxLen) + set cleanText to my safeText(theText) + if (length of cleanText) > maxLen then + return text 1 thru maxLen of cleanText + end if + return cleanText +end clipText + +on shouldSkipMailbox(mailboxName) + set skipNames to {"all", "Archive", "Архив", "Архив диск ", "Deleted Messages", "Удаленные", "Trash", "Junk", "Нежелательная почта", "Sent Messages", "Отправленные", "Drafts", "Черновики", "Outbox", "Исходящие", "Spam", "Quarantine", "RSS-подписки"} + if skipNames contains mailboxName then return true + return false +end shouldSkipMailbox + +on isPriorityMailbox(mailboxName) + if mailboxName is "INBOX" then return true + if mailboxName is "Входящие" then return true + return false +end isPriorityMailbox + +on run argv + set fetchMode to item 1 of argv + set maxMessages to (item 2 of argv) as integer + set maxBodyLen to (item 3 of argv) as integer + set fieldSep to "__EMAIL_AGENT_FIELD__" + set recordSep to "__EMAIL_AGENT_RECORD__" + set rows to {} + + tell application "Mail" + if fetchMode is "unread-mailboxes" or fetchMode is "all-mailboxes" then + repeat with theAccount in accounts + set accountName to my safeText(name of theAccount) + repeat with theMailbox in mailboxes of theAccount + set mailboxName to my safeText(name of theMailbox) + if my isPriorityMailbox(mailboxName) is true then + try + if fetchMode is "unread-mailboxes" then + set targetMessages to messages of theMailbox whose read status is false + else + set targetMessages to messages of theMailbox + end if + set accountTotal to count of targetMessages + repeat with i from 1 to accountTotal + if (count of rows) is greater than or equal to maxMessages then exit repeat + set theMessage to item i of targetMessages + set rowFields to {"apple_mail:" & accountName & ":" & mailboxName, my safeText(id of theMessage), my safeText(sender of theMessage), my safeText(subject of theMessage), my safeText(date received of theMessage), my clipText(content of theMessage, maxBodyLen)} + set end of rows to my joinList(rowFields, fieldSep) + end repeat + end try + end if + if (count of rows) is greater than or equal to maxMessages then exit repeat + end repeat + if (count of rows) is greater than or equal to maxMessages then exit repeat + end repeat + repeat with theAccount in accounts + set accountName to my safeText(name of theAccount) + repeat with theMailbox in mailboxes of theAccount + set mailboxName to my safeText(name of theMailbox) + if (my shouldSkipMailbox(mailboxName) is false) and (my isPriorityMailbox(mailboxName) is false) then + try + if fetchMode is "unread-mailboxes" then + set targetMessages to messages of theMailbox whose read status is false + else + set targetMessages to messages of theMailbox + end if + set accountTotal to count of targetMessages + repeat with i from 1 to accountTotal + if (count of rows) is greater than or equal to maxMessages then exit repeat + set theMessage to item i of targetMessages + set rowFields to {"apple_mail:" & accountName & ":" & mailboxName, my safeText(id of theMessage), my safeText(sender of theMessage), my safeText(subject of theMessage), my safeText(date received of theMessage), my clipText(content of theMessage, maxBodyLen)} + set end of rows to my joinList(rowFields, fieldSep) + end repeat + end try + end if + if (count of rows) is greater than or equal to maxMessages then exit repeat + end repeat + if (count of rows) is greater than or equal to maxMessages then exit repeat + end repeat + return my joinList(rows, recordSep) + else if fetchMode is "all-inboxes" or fetchMode is "unread-all" then + repeat with theAccount in accounts + set accountName to my safeText(name of theAccount) + try + set accountInbox to mailbox "INBOX" of theAccount + if fetchMode is "unread-all" then + set targetMessages to messages of accountInbox whose read status is false + else + set targetMessages to messages of accountInbox + end if + set accountTotal to count of targetMessages + repeat with i from 1 to accountTotal + if (count of rows) is greater than or equal to maxMessages then exit repeat + set theMessage to item i of targetMessages + set rowFields to {"apple_mail:" & accountName, my safeText(id of theMessage), my safeText(sender of theMessage), my safeText(subject of theMessage), my safeText(date received of theMessage), my clipText(content of theMessage, maxBodyLen)} + set end of rows to my joinList(rowFields, fieldSep) + end repeat + end try + if (count of rows) is greater than or equal to maxMessages then exit repeat + end repeat + return my joinList(rows, recordSep) + else if fetchMode is "sent" then + set targetMessages to messages of sent mailbox + else if fetchMode is "unread" then + set targetMessages to messages of inbox whose read status is false + else + set targetMessages to messages of inbox + end if + + set totalMessages to count of targetMessages + if totalMessages > maxMessages then set totalMessages to maxMessages + + repeat with i from 1 to totalMessages + set theMessage to item i of targetMessages + set rowFields to {"apple_mail", my safeText(id of theMessage), my safeText(sender of theMessage), my safeText(subject of theMessage), my safeText(date received of theMessage), my clipText(content of theMessage, maxBodyLen)} + set end of rows to my joinList(rowFields, fieldSep) + end repeat + end tell + + return my joinList(rows, recordSep) +end run +''' + + +OUTLOOK_SCRIPT = r''' +on joinList(theList, theDelimiter) + set oldDelimiters to AppleScript's text item delimiters + set AppleScript's text item delimiters to theDelimiter + set joinedText to theList as text + set AppleScript's text item delimiters to oldDelimiters + return joinedText +end joinList + +on safeText(theValue) + try + set theText to theValue as text + on error + set theText to "" + end try + set theText to my replaceText(theText, "__EMAIL_AGENT_FIELD__", " ") + set theText to my replaceText(theText, "__EMAIL_AGENT_RECORD__", " ") + return theText +end safeText + +on replaceText(theText, searchText, replacementText) + set oldDelimiters to AppleScript's text item delimiters + set AppleScript's text item delimiters to searchText + set textItems to text items of theText + set AppleScript's text item delimiters to replacementText + set newText to textItems as text + set AppleScript's text item delimiters to oldDelimiters + return newText +end replaceText + +on clipText(theText, maxLen) + set cleanText to my safeText(theText) + if (length of cleanText) > maxLen then + return text 1 thru maxLen of cleanText + end if + return cleanText +end clipText + +on run argv + set fetchMode to item 1 of argv + set maxMessages to (item 2 of argv) as integer + set maxBodyLen to (item 3 of argv) as integer + set fieldSep to "__EMAIL_AGENT_FIELD__" + set recordSep to "__EMAIL_AGENT_RECORD__" + set rows to {} + + tell application "Microsoft Outlook" + if fetchMode is "sent" then + set targetMessages to messages of sent items + else if fetchMode is "unread" then + set targetMessages to messages of inbox whose is read is false + else + set targetMessages to messages of inbox + end if + + set totalMessages to count of targetMessages + if totalMessages > maxMessages then set totalMessages to maxMessages + + repeat with i from 1 to totalMessages + set theMessage to item i of targetMessages + set senderText to "" + try + set senderText to sender of theMessage as text + end try + set bodyText to "" + try + set bodyText to plain text content of theMessage as text + on error + try + set bodyText to content of theMessage as text + end try + end try + set receivedText to "" + try + set receivedText to time received of theMessage as text + end try + set rowFields to {"outlook", my safeText(id of theMessage), my safeText(senderText), my safeText(subject of theMessage), my safeText(receivedText), my clipText(bodyText, maxBodyLen)} + set end of rows to my joinList(rowFields, fieldSep) + end repeat + end tell + + return my joinList(rows, recordSep) +end run +''' + + +APPLE_MAILBOXES_SCRIPT = r''' +on joinList(theList, theDelimiter) + set oldDelimiters to AppleScript's text item delimiters + set AppleScript's text item delimiters to theDelimiter + set joinedText to theList as text + set AppleScript's text item delimiters to oldDelimiters + return joinedText +end joinList + +on safeText(theValue) + try + return theValue as text + on error + return "" + end try +end safeText + +on run argv + set fieldSep to "__EMAIL_AGENT_FIELD__" + set recordSep to "__EMAIL_AGENT_RECORD__" + set rows to {} + tell application "Mail" + repeat with theAccount in accounts + set accountName to my safeText(name of theAccount) + repeat with theMailbox in mailboxes of theAccount + set mailboxName to my safeText(name of theMailbox) + set totalCount to 0 + set unreadCount to 0 + try + set totalCount to count of messages of theMailbox + set unreadCount to count of (messages of theMailbox whose read status is false) + end try + set end of rows to my joinList({"apple_mail", accountName, mailboxName, totalCount as text, unreadCount as text}, fieldSep) + end repeat + end repeat + end tell + return my joinList(rows, recordSep) +end run +''' + + +OUTLOOK_MAILBOXES_SCRIPT = r''' +on joinList(theList, theDelimiter) + set oldDelimiters to AppleScript's text item delimiters + set AppleScript's text item delimiters to theDelimiter + set joinedText to theList as text + set AppleScript's text item delimiters to oldDelimiters + return joinedText +end joinList + +on safeText(theValue) + try + return theValue as text + on error + return "" + end try +end safeText + +on run argv + set fieldSep to "__EMAIL_AGENT_FIELD__" + set recordSep to "__EMAIL_AGENT_RECORD__" + set rows to {} + tell application "Microsoft Outlook" + try + set totalCount to count of messages of inbox + set unreadCount to count of (messages of inbox whose is read is false) + set end of rows to my joinList({"outlook", "default", "inbox", totalCount as text, unreadCount as text}, fieldSep) + end try + try + set totalCount to count of messages of sent items + set end of rows to my joinList({"outlook", "default", "sent items", totalCount as text, ""}, fieldSep) + end try + end tell + return my joinList(rows, recordSep) +end run +''' + + +def run_osascript(script: str, args: list[str], source_name: str) -> str: + try: + result = subprocess.run( + ["osascript", "-", *args], + input=script, + text=True, + capture_output=True, + check=False, + timeout=90, + ) + except FileNotFoundError as exc: + raise MailSourceError("osascript is not available on this system") from exc + except subprocess.TimeoutExpired as exc: + raise MailSourceError(f"{source_name}: timed out while reading mail") from exc + + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip() + raise MailSourceError(f"{source_name}: {detail}") + return result.stdout.strip() + + +def parse_messages(raw: str) -> list[EmailMessage]: + if not raw: + return [] + + messages: list[EmailMessage] = [] + for record in raw.split(RECORD_SEP): + fields = record.split(FIELD_SEP) + if len(fields) < 6: + continue + source, uid, sender, subject, received_at = fields[:5] + body = FIELD_SEP.join(fields[5:]) + messages.append( + EmailMessage( + source=source.strip(), + uid=uid.strip(), + sender=sender.strip(), + subject=subject.strip(), + received_at=received_at.strip(), + body=normalize_body(body), + ) + ) + return messages + + +def normalize_body(value: str) -> str: + value = value.replace("\r", "\n") + value = re.sub(r"\n{3,}", "\n\n", value) + value = re.sub(r"[ \t]{2,}", " ", value) + return value.strip() + + +def fetch_messages(source: str, mode: str, limit: int, content_limit: int) -> tuple[list[EmailMessage], list[str]]: + scripts = { + "apple-mail": ("Apple Mail", APPLE_MAIL_SCRIPT), + "outlook": ("Microsoft Outlook", OUTLOOK_SCRIPT), + } + selected = list(scripts) if source == "all" else [source] + messages: list[EmailMessage] = [] + errors: list[str] = [] + + for key in selected: + label, script = scripts[key] + try: + raw = run_osascript(script, [mode, str(limit), str(content_limit)], label) + messages.extend(parse_messages(raw)) + except MailSourceError as exc: + errors.append(str(exc)) + + return messages, errors + + +def dedupe_messages(messages: list[EmailMessage]) -> list[EmailMessage]: + seen: set[str] = set() + result: list[EmailMessage] = [] + for message in messages: + key = f"{message.source}:{message.uid}" + if key in seen: + continue + seen.add(key) + result.append(message) + return result + + +def prioritize_messages(messages: list[EmailMessage]) -> list[EmailMessage]: + return sorted( + messages, + key=lambda message: ( + -int(classify_message(message)["priority"]), + "newsletter" in classify_message(message)["labels"], + message.sender_domain, + message.subject.lower(), + ), + ) + + +def fetch_mailboxes(source: str) -> tuple[list[dict[str, object]], list[str]]: + scripts = { + "apple-mail": ("Apple Mail", APPLE_MAILBOXES_SCRIPT), + "outlook": ("Microsoft Outlook", OUTLOOK_MAILBOXES_SCRIPT), + } + selected = list(scripts) if source == "all" else [source] + rows: list[dict[str, object]] = [] + errors: list[str] = [] + for key in selected: + label, script = scripts[key] + try: + raw = run_osascript(script, [], label) + except MailSourceError as exc: + errors.append(str(exc)) + continue + if not raw: + continue + for record in raw.split(RECORD_SEP): + fields = record.split(FIELD_SEP) + if len(fields) < 5: + continue + source_name, account, mailbox, total, unread = fields[:5] + rows.append( + { + "source": source_name, + "account": account, + "mailbox": mailbox, + "total": parse_int(total), + "unread": parse_int(unread), + } + ) + return rows, errors + + +def parse_int(value: str) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None + + +def filter_by_identity(messages: list[EmailMessage], identity: str) -> list[EmailMessage]: + identity = identity.strip().lower() + if not identity: + return messages + return [message for message in messages if identity in message.sender.lower()] + + +def summarize(messages: list[EmailMessage]) -> dict[str, object]: + by_source: dict[str, int] = {} + by_sender: dict[str, int] = {} + by_domain: dict[str, int] = {} + + for message in messages: + by_source[message.source] = by_source.get(message.source, 0) + 1 + sender = message.sender or "unknown" + by_sender[sender] = by_sender.get(sender, 0) + 1 + by_domain[message.sender_domain] = by_domain.get(message.sender_domain, 0) + 1 + + return { + "total": len(messages), + "by_source": sort_counts(by_source), + "top_senders": sort_counts(by_sender, limit=10), + "top_domains": sort_counts(by_domain, limit=10), + } + + +def sort_counts(values: dict[str, int], limit: int | None = None) -> dict[str, int]: + items = sorted(values.items(), key=lambda item: (-item[1], item[0].lower())) + if limit is not None: + items = items[:limit] + return dict(items) + + +def classify_message(message: EmailMessage) -> dict[str, object]: + text = f"{message.sender}\n{message.subject}\n{message.body}".lower() + labels: list[str] = [] + score = 1 + + if any(word in text for word in ("срочно", "urgent", "asap", "важно", "critical", "deadline")): + labels.append("urgent") + score = max(score, 5) + if any(word in text for word in ("?", "подтверд", "confirm", "approve", "соглас", "ответ", "reply")): + labels.append("needs_reply") + score = max(score, 4) + if any(word in text for word in ("встреч", "созвон", "meeting", "call", "calendar", "invite")): + labels.append("meeting") + score = max(score, 3) + if any(word in text for word in ("invoice", "payment", "счет", "оплат", "contract", "договор")): + labels.append("finance_or_contract") + score = max(score, 3) + if any(word in text for word in ("unsubscribe", "newsletter", "webinar", "digest", "рассылка")): + labels.append("newsletter") + score = min(score, 2) + if any(word in text for word in ("mim access request", "approval required", "quality check", "jira", "docflow", "согласование", "требует согласования")): + if "needs_reply" not in labels: + labels.append("needs_reply") + score = max(score, 4) + if not labels: + labels.append("fyi") + + return {"priority": score, "labels": labels} + + +def build_digest(messages: list[EmailMessage]) -> str: + enriched = [(message, classify_message(message)) for message in messages] + enriched.sort(key=lambda item: (-int(item[1]["priority"]), item[0].sender_domain, item[0].subject.lower())) + counts: dict[str, int] = {} + for _, classification in enriched: + for label in classification["labels"]: + counts[label] = counts.get(label, 0) + 1 + + lines = ["# Email Digest", "", f"Total messages: {len(messages)}", "", "## Labels"] + for label, count in sort_counts(counts).items(): + lines.append(f"- {label}: {count}") + + lines.extend(["", "## Top Items"]) + for message, classification in enriched[:20]: + labels = ", ".join(classification["labels"]) + preview = re.sub(r"\s+", " ", message.body).strip()[:180] + lines.append(f"- P{classification['priority']} [{labels}] {message.subject} — {message.sender}") + if preview: + lines.append(f" Preview: {preview}") + return "\n".join(lines).strip() + "\n" + + +def learn_style_profile(messages: list[EmailMessage]) -> StyleProfile: + clean_messages = [message for message in messages if is_usable_style_sample(message)] + bodies = [strip_signature(strip_quoted_history(message.body)) for message in clean_messages] + bodies = [body for body in bodies if len(body.split()) >= 3] + word_counts = [len(body.split()) for body in bodies] + avg_words = round(sum(word_counts) / len(word_counts)) if word_counts else 0 + all_text = "\n".join(bodies) + ru_chars = len(re.findall(r"[А-Яа-яЁё]", all_text)) + en_chars = len(re.findall(r"[A-Za-z]", all_text)) + language = "ru" if ru_chars >= en_chars else "en" + + greetings = unique_keep_order(extract_edge_lines(bodies, "start"))[:8] + closings = unique_keep_order(extract_edge_lines(bodies, "end"))[:8] + phrases = extract_frequent_phrases(bodies, language)[:12] + tone = infer_tone(all_text, avg_words) + + return StyleProfile( + samples=len(bodies), + language=language, + avg_words=avg_words, + greeting_examples=greetings, + closing_examples=closings, + frequent_phrases=phrases, + tone=tone, + updated_at=dt.datetime.now().isoformat(timespec="seconds"), + ) + + +def is_usable_style_sample(message: EmailMessage) -> bool: + body = strip_quoted_history(message.body) + text = f"{message.subject}\n{body}".lower() + reject_markers = ( + "запланировано с", + "where:", + "где:", + "organizer:", + "meeting invitation", + "teams meeting", + "BEGIN:VCALENDAR".lower(), + "unsubscribe", + "открыта вакансия", + "internal recruitment", + "begin forwarded message", + "forwarded message", + ) + if any(marker in text for marker in reject_markers): + return False + if len(re.findall(r"https?://|www\.", body.lower())) > 2: + return False + if len(body.split()) < 8: + return False + prose_lines = [line.strip() for line in body.splitlines() if line.strip()] + if not prose_lines: + return False + service_lines = sum(1 for line in prose_lines if re.search(r"тел\.|phone|\+\d|@|2200\d|минск", line.lower())) + return service_lines < max(2, len(prose_lines) // 2) + + +def strip_quoted_history(body: str) -> str: + stop_patterns = ( + r"\nOn .+ wrote:\n", + r"\nFrom: .+\nSent: .+\n", + r"\nFrom: .+\nTo: .+\n", + r"\nОт: .+\nОтправлено: .+\n", + r"\nBegin forwarded message:\n", + r"\n-{2,}\s*Original Message\s*-{2,}\n", + ) + result = body + for pattern in stop_patterns: + match = re.search(pattern, result, flags=re.IGNORECASE | re.DOTALL) + if match: + result = result[: match.start()] + lines = [line for line in result.splitlines() if not line.strip().startswith(">")] + return normalize_body("\n".join(lines)) + + +def strip_signature(body: str) -> str: + lines = body.splitlines() + kept: list[str] = [] + for line in lines: + lower = line.strip().lower() + if re.search(r"^(тел\.|phone|mobile|моб\.|ул\.|address)", lower): + break + if any(marker in lower for marker in ("унитарное предприятие", "a1-центр", "220030", "минск, беларусь")): + break + if any(marker in lower for marker in ("email disclaimer", "confidential", "конфиденциаль")): + break + kept.append(line) + return normalize_body("\n".join(kept)) + + +def extract_edge_lines(bodies: list[str], edge: str) -> list[str]: + result: list[str] = [] + for body in bodies: + lines = [line.strip() for line in body.splitlines() if line.strip()] + if not lines: + continue + candidates = lines[:3] if edge == "start" else lines[-4:] + for line in candidates: + if not is_profile_line(line): + continue + if edge == "start" and not looks_like_greeting(line): + continue + if edge == "end" and not looks_like_closing(line): + continue + if 2 <= len(line.split()) <= 10 and len(line) <= 90: + result.append(line) + return result + + +def is_profile_line(line: str) -> bool: + lower = line.lower() + if re.search(r"\d|https?://|www\.|@", lower): + return False + if any(marker in lower for marker in ("минск", "тел.", "phone", "унитарное предприятие", "a1-центр", "begin forwarded")): + return False + return True + + +def looks_like_closing(line: str) -> bool: + lower = line.lower().strip(" .,!;:") + markers = ( + "с уважением", + "спасибо", + "благодарю", + "хорошего дня", + "до связи", + "best regards", + "regards", + "thank you", + "thanks", + ) + return any(marker in lower for marker in markers) + + +def looks_like_greeting(line: str) -> bool: + lower = line.lower().strip(" .,!;:") + markers = ( + "добрый день", + "доброе утро", + "добрый вечер", + "здравствуйте", + "коллеги", + "привет", + "hello", + "hi", + "dear", + "good morning", + "good afternoon", + ) + return any(marker in lower for marker in markers) + + +def extract_frequent_phrases(bodies: list[str], language: str) -> list[str]: + counts: dict[str, int] = {} + stop = { + "ru": {"что", "это", "как", "для", "или", "если", "при", "the", "and", "you"}, + "en": {"the", "and", "you", "that", "for", "with", "this", "are", "not"}, + }[language] + for body in bodies: + words = re.findall(r"[A-Za-zА-Яа-яЁё]{3,}", body.lower()) + words = [word for word in words if word not in stop] + for size in (2, 3): + for index in range(0, max(0, len(words) - size + 1)): + phrase = " ".join(words[index : index + size]) + counts[phrase] = counts.get(phrase, 0) + 1 + return [phrase for phrase, count in sorted(counts.items(), key=lambda item: (-item[1], item[0])) if count > 1] + + +def infer_tone(text: str, avg_words: int) -> str: + lower = text.lower() + if any(word in lower for word in ("коллеги", "уважаемые", "с уважением", "dear", "regards")): + return "formal" + if avg_words and avg_words < 45: + return "direct" + return "neutral" + + +def unique_keep_order(values: Iterable[str]) -> list[str]: + seen: set[str] = set() + result: list[str] = [] + for value in values: + key = value.lower() + if key in seen: + continue + seen.add(key) + result.append(value) + return result + + +def save_style_profile(profile: StyleProfile, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(asdict(profile), ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def load_style_profile(path: Path) -> StyleProfile | None: + if not path.exists(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + profile = StyleProfile(**data) + return profile if profile.samples > 0 else None + except (OSError, TypeError, json.JSONDecodeError): + return None + + +def render_profile_notes(profile: StyleProfile | None) -> str: + if not profile: + return "No learned style profile is available yet." + parts = [ + f"samples: {profile.samples}", + f"language: {profile.language}", + f"tone: {profile.tone}", + f"average length: {profile.avg_words} words", + ] + if profile.greeting_examples: + parts.append("greetings: " + "; ".join(profile.greeting_examples[:3])) + if profile.closing_examples: + parts.append("closings: " + "; ".join(profile.closing_examples[:3])) + if profile.frequent_phrases: + parts.append("phrases: " + "; ".join(profile.frequent_phrases[:6])) + return "\n".join(parts) + + +def build_reply(message: EmailMessage, language: str, style: str, profile: StyleProfile | None = None) -> str: + if profile and language == "auto": + language = profile.language + if profile and style == "learned": + style = profile.tone if profile.tone in {"neutral", "formal", "casual"} else "neutral" + llm_reply = build_reply_with_llm(message, language, style, profile) + if llm_reply: + return llm_reply + return build_heuristic_reply(message, language, style, profile) + + +def build_reply_with_llm(message: EmailMessage, language: str, style: str, profile: StyleProfile | None) -> str | None: + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + return None + + base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") + model = os.getenv("OPENAI_MODEL", "gpt-4o-mini") + url = base_url.rstrip("/") + "/chat/completions" + prompt = textwrap.dedent( + f""" + Prepare a concise email reply draft. + Language: {language} + Style: {style} + + Learned user style profile: + {render_profile_notes(profile)} + + Rules: + - Do not invent facts, promises, dates, attachments, prices, or decisions. + - If information is missing, ask a clear follow-up question. + - Do not include a subject line. + - Do not claim the email was sent. + + From: {message.sender} + Subject: {message.subject} + Body: + {message.body[:5000]} + """ + ).strip() + payload = { + "model": model, + "messages": [ + {"role": "system", "content": "You write safe, practical email reply drafts."}, + {"role": "user", "content": prompt}, + ], + "temperature": 0.3, + } + request = urllib.request.Request( + url, + data=json.dumps(payload).encode("utf-8"), + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + method="POST", + ) + + try: + with urllib.request.urlopen(request, timeout=45) as response: + data = json.loads(response.read().decode("utf-8")) + except (urllib.error.URLError, TimeoutError, KeyError, json.JSONDecodeError): + return None + + try: + return data["choices"][0]["message"]["content"].strip() + except (KeyError, IndexError, TypeError): + return None + + +def build_heuristic_reply(message: EmailMessage, language: str, style: str, profile: StyleProfile | None = None) -> str: + text = f"{message.subject}\n{message.body}".lower() + if language == "auto": + language = profile.language if profile else "ru" + is_ru = language.lower().startswith("ru") + signature = os.getenv("EMAIL_AGENT_SIGNATURE", "").strip() + scenario = detect_reply_scenario(message) + + if is_ru: + greeting = select_greeting(profile, is_ru) or "Здравствуйте!" + thanks = "Спасибо за письмо." + close = "С уважением," if style != "casual" else "Хорошего дня," + close = select_closing(profile, is_ru) or close + middle_lines = build_ru_reply_lines(message, scenario) + lines = [greeting, "", thanks, *middle_lines, "", close] + else: + greeting = select_greeting(profile, is_ru) or "Hello," + thanks = "Thank you for your email." + close = "Best regards," + close = select_closing(profile, is_ru) or close + middle_lines = build_en_reply_lines(message, scenario) + lines = [greeting, "", thanks, *middle_lines, "", close] + + if signature: + lines.append(signature) + return "\n".join(lines).strip() + + +def detect_reply_scenario(message: EmailMessage) -> str: + text = f"{message.sender}\n{message.subject}\n{message.body}".lower() + if "mim access request" in text or "запрос mim" in text or "запрос ролей" in text: + return "access_approval" + if "approval required" in text or "docflow" in text or "требует согласования" in text: + return "approval" + if "quality check" in text or "ics.dms" in text: + return "quality_check" + if "jira" in text or "updates for" in text or "customer product request" in text: + return "jira_update" + if any(word in text for word in ("вакансия", "internal recruitment", "newsletter", "announcement", "unsubscribe")): + return "no_reply" + if any(word in text for word in ("встреч", "созвон", "meeting", "call", "calendar", "invite")): + return "meeting" + if any(word in text for word in ("срочно", "urgent", "asap", "critical", "deadline")): + return "urgent" + if any(word in text for word in ("счет", "invoice", "оплат", "payment", "contract", "договор")): + return "finance" + if "?" in text: + return "question" + return "general" + + +def build_ru_reply_lines(message: EmailMessage, scenario: str) -> list[str]: + subject = message.subject.strip() or "письму" + if scenario == "access_approval": + return [ + f"Запрос по теме «{subject}» получил.", + "Проверю обоснование доступа, владельца процесса и возможные риски по продуктивной среде. Если все корректно, согласую; если будут вопросы, вернусь отдельно с уточнениями.", + ] + if scenario == "approval": + return [ + f"Задачу на согласование по теме «{subject}» увидел.", + "Посмотрю детали, комментарии и приложенные материалы. После проверки приму решение в системе или задам уточняющие вопросы.", + ] + if scenario == "quality_check": + return [ + "Уведомление по Quality Check получил.", + "Проверю контроль, срок выполнения и необходимые подтверждения. После этого выполню действие в ICS или вернусь с вопросами, если потребуется дополнительная информация.", + ] + if scenario == "jira_update": + return [ + f"Обновление по задаче «{subject}» получил.", + "Посмотрю комментарии и текущий статус. Если от меня требуется действие, отдельно отпишусь по результату или обновлю задачу в Jira.", + ] + if scenario == "no_reply": + return [ + "Похоже, это информационное письмо или рассылка.", + "Отдельный ответ, скорее всего, не требуется. Оставляю как FYI, при необходимости вернусь к письму позже.", + ] + if scenario == "meeting": + return [ + "Готов обсудить.", + "Пришлите, пожалуйста, удобные варианты времени или ссылку на календарь. Если уже есть материалы к встрече, лучше отправить их заранее, чтобы можно было подготовиться предметно.", + ] + if scenario == "urgent": + return [ + "Вижу, что вопрос срочный.", + "Сначала проверю фактуру и возможные ограничения, после этого вернусь с конкретным ответом или следующим шагом.", + ] + if scenario == "finance": + return [ + "Информацию по оплате/договору получил.", + "Проверю детали, ответственных и следующий шаг. Если потребуется подтверждение с нашей стороны, отдельно сообщу после проверки.", + ] + if scenario == "question": + return [ + "Вопрос получил.", + "Проверю детали и отвечу предметно. Если нужно принять решение быстро, напишите, пожалуйста, желаемый срок ответа.", + ] + return [ + f"Письмо по теме «{subject}» получил.", + "Посмотрю детали и вернусь с ответом после проверки контекста.", + ] + + +def build_en_reply_lines(message: EmailMessage, scenario: str) -> list[str]: + subject = message.subject.strip() or "this topic" + if scenario == "meeting": + return ["I am open to discussing this.", "Please send a few suitable time options or a calendar link, and any materials that would help me prepare." ] + if scenario == "urgent": + return ["I see this is urgent.", "I will check the facts and constraints first, then come back with a specific answer or next step." ] + if scenario == "no_reply": + return ["This looks informational.", "No separate reply seems required at this point; I will keep it as FYI." ] + if scenario in {"approval", "access_approval", "quality_check", "jira_update"}: + return [f"I received the update on “{subject}”.", "I will review the details and take the required action in the corresponding system if needed." ] + return [f"I received your message about “{subject}”.", "I will review the details and get back with a specific answer." ] + + +def select_greeting(profile: StyleProfile | None, is_ru: bool) -> str | None: + if not profile: + return None + generic = ("добрый день", "добрый день.", "доброе утро", "доброе утро,") if is_ru else ("hello", "hi", "good morning") + for greeting in profile.greeting_examples: + if greeting.lower().strip() in generic: + return greeting + preferred = ("добрый день", "здравствуйте") if is_ru else ("hello", "hi", "good morning") + for greeting in profile.greeting_examples: + lower = greeting.lower() + if "коллеги" in lower: + continue + if "," in greeting and is_ru: + continue + if any(marker in lower for marker in preferred): + return greeting + return None + + +def select_closing(profile: StyleProfile | None, is_ru: bool) -> str | None: + if not profile: + return None + preferred_groups = (("с уважением",), ("kind regards", "best regards", "regards"), ("спасибо", "thank you")) if is_ru else (("best regards", "regards"), ("thank you", "thanks")) + for preferred in preferred_groups: + for closing in profile.closing_examples: + lower = closing.lower() + if any(marker in lower for marker in preferred): + return closing + return None + + +def write_drafts( + messages: list[EmailMessage], + output_dir: Path, + language: str, + style: str, + profile: StyleProfile | None = None, +) -> list[Path]: + output_dir.mkdir(parents=True, exist_ok=True) + paths: list[Path] = [] + for index, message in enumerate(messages, start=1): + reply = build_reply(message, language, style, profile) + digest = hashlib.sha1(f"{message.source}:{message.uid}:{message.subject}".encode("utf-8")).hexdigest()[:10] + filename = f"{index:03d}-{slugify(message.subject) or 'no-subject'}-{digest}.md" + path = output_dir / filename + path.write_text(render_draft(message, reply, profile), encoding="utf-8") + paths.append(path) + return paths + + +def clear_markdown_dir(output_dir: Path) -> None: + output_dir.mkdir(parents=True, exist_ok=True) + for path in output_dir.glob("*.md"): + path.unlink() + + +def render_draft(message: EmailMessage, reply: str, profile: StyleProfile | None = None) -> str: + classification = classify_message(message) + profile_line = f"Learned style: {profile.samples} sent samples, {profile.tone}" if profile else "Learned style: not loaded" + return textwrap.dedent( + f""" + # Draft Reply + + Source: {message.source} + To/From original sender: {message.sender} + Original subject: {message.subject} + Received: {message.received_at} + Priority: P{classification['priority']} + Labels: {', '.join(classification['labels'])} + {profile_line} + + ## Suggested Reply + + {reply} + + ## Original Message Preview + + {message.body[:2500]} + """ + ).strip() + "\n" + + +def slugify(value: str) -> str: + value = value.lower().strip() + value = re.sub(r"[^a-z0-9а-яё]+", "-", value, flags=re.IGNORECASE) + value = value.strip("-")[:60] + return value or "message" + + +def today_stamp() -> str: + return dt.date.today().isoformat() + + +def default_digest_path() -> str: + return f"data/digests/{today_stamp()}.md" + + +def default_report_path() -> str: + return f"data/reports/{today_stamp()}.md" + + +def default_summary_path() -> str: + return f"data/reports/{today_stamp()}-summary.json" + + +def default_messages_path() -> str: + return f"data/messages/{today_stamp()}.jsonl" + + +def default_calendar_path() -> str: + return f"data/calendar/{today_stamp()}.md" + + +def default_drafts_dir() -> str: + return f"drafts/{today_stamp()}" + + +def write_messages_jsonl(messages: list[EmailMessage], output_path: Path) -> None: + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w", encoding="utf-8") as file: + for message in messages: + classification = classify_message(message) + row = asdict(message) + row["priority"] = classification["priority"] + row["labels"] = classification["labels"] + row["preview"] = re.sub(r"\s+", " ", message.body).strip()[:500] + file.write(json.dumps(row, ensure_ascii=False) + "\n") + + +def parse_email_address(value: str) -> str: + match = re.search(r"<([^>]+)>", value) + if match: + return match.group(1).strip() + match = re.search(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", value) + return match.group(0) if match else value.strip() + + +def parse_markdown_draft(path: Path) -> dict[str, str]: + content = path.read_text(encoding="utf-8", errors="replace") + return { + "path": str(path), + "sender": extract_markdown_field(content, "To/From original sender"), + "subject": extract_markdown_field(content, "Original subject"), + "reply": extract_markdown_section(content, "## Suggested Reply", "## Original Message Preview"), + } + + +def extract_markdown_field(content: str, field: str) -> str: + match = re.search(rf"^\s*{re.escape(field)}:\s*(.+?)\s*$", content, flags=re.MULTILINE) + return match.group(1).strip() if match else "" + + +def extract_markdown_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 create_mail_draft(client: str, recipient: str, subject: str, body: str) -> None: + recipient = parse_email_address(recipient) + subject = subject if subject.lower().startswith("re:") else f"Re: {subject}" + if client == "apple-mail": + script = r''' +on run argv + set recipientAddress to item 1 of argv + set messageSubject to item 2 of argv + set messageBody to item 3 of argv + tell application "Mail" + set draftMessage to make new outgoing message with properties {subject:messageSubject, content:messageBody, visible:false} + tell draftMessage + make new to recipient at end of to recipients with properties {address:recipientAddress} + end tell + save draftMessage + end tell +end run +''' + run_osascript(script, [recipient, subject, body], "Apple Mail draft") + return + if client == "outlook": + script = r''' +on run argv + set recipientAddress to item 1 of argv + set messageSubject to item 2 of argv + set messageBody to item 3 of argv + tell application "Microsoft Outlook" + set draftMessage to make new outgoing message with properties {subject:messageSubject, content:messageBody} + make new recipient at draftMessage with properties {email address:{address:recipientAddress}} + save draftMessage + end tell +end run +''' + run_osascript(script, [recipient, subject, body], "Outlook draft") + return + raise MailSourceError(f"unsupported draft client: {client}") + + +def publish_drafts_to_mail(drafts_dir: Path, client: str, limit: int) -> tuple[int, list[str]]: + errors: list[str] = [] + count = 0 + for path in sorted(drafts_dir.glob("*.md"))[:limit]: + draft = parse_markdown_draft(path) + if not draft["sender"] or not draft["subject"] or not draft["reply"]: + errors.append(f"{path}: skipped, missing sender/subject/reply") + continue + try: + create_mail_draft(client, draft["sender"], draft["subject"], draft["reply"]) + count += 1 + except MailSourceError as exc: + errors.append(str(exc)) + return count, errors + + +def write_calendar_suggestions(messages: list[EmailMessage], output_path: Path) -> list[EmailMessage]: + meeting_messages = [message for message in messages if "meeting" in classify_message(message)["labels"]] + output_path.parent.mkdir(parents=True, exist_ok=True) + lines = [f"# Calendar Suggestions - {today_stamp()}", ""] + if not meeting_messages: + lines.append("No meeting-related emails found.") + for message in meeting_messages: + lines.extend( + [ + f"## {message.subject or '(no subject)'}", + "", + f"- Sender: {message.sender}", + f"- Received: {message.received_at}", + f"- Suggested action: review email and create/confirm meeting manually if needed.", + f"- Calendar title: {build_calendar_title(message)}", + "", + ] + ) + output_path.write_text("\n".join(lines).strip() + "\n", encoding="utf-8") + return meeting_messages + + +def build_calendar_title(message: EmailMessage) -> str: + subject = re.sub(r"^(re|fw|fwd):\s*", "", message.subject.strip(), flags=re.IGNORECASE) + return f"Email follow-up: {subject[:80] or parse_email_address(message.sender)}" + + +def create_calendar_reminders(messages: list[EmailMessage], calendar_name: str) -> tuple[int, list[str]]: + errors: list[str] = [] + count = 0 + for message in messages: + script = r''' +on run argv + set calendarName to item 1 of argv + set eventSummary to item 2 of argv + set eventNotes to item 3 of argv + set startDate to (current date) + (1 * days) + set hours of startDate to 10 + set minutes of startDate to 0 + set seconds of startDate to 0 + set endDate to startDate + (30 * minutes) + tell application "Calendar" + if not (exists calendar calendarName) then + make new calendar with properties {name:calendarName} + end if + tell calendar calendarName + make new event with properties {summary:eventSummary, start date:startDate, end date:endDate, description:eventNotes} + end tell + end tell +end run +''' + notes = f"From: {message.sender}\nSubject: {message.subject}\n\n{message.body[:1000]}" + try: + run_osascript(script, [calendar_name, build_calendar_title(message), notes], "Calendar") + count += 1 + except MailSourceError as exc: + errors.append(str(exc)) + return count, errors + + +def update_contacts_memory(messages: list[EmailMessage], output_path: Path) -> dict[str, object]: + existing = read_json_file(output_path) if output_path.exists() else {} + contacts = existing.get("contacts", {}) if isinstance(existing, dict) else {} + for message in messages: + email = parse_email_address(message.sender).lower() + if not email: + continue + classification = classify_message(message) + contact = contacts.get(email, {}) + contact["sender"] = message.sender + contact["domain"] = message.sender_domain + contact["count"] = int(contact.get("count", 0)) + 1 + contact["last_subject"] = message.subject + contact["last_seen"] = dt.datetime.now().isoformat(timespec="seconds") + contact["important_count"] = int(contact.get("important_count", 0)) + (1 if int(classification["priority"]) >= 4 else 0) + contact.setdefault("importance", "high" if contact["important_count"] else "normal") + contact.setdefault("reply_style", "detailed" if contact["importance"] == "high" else "short") + contacts[email] = contact + payload = {"updated_at": dt.datetime.now().isoformat(timespec="seconds"), "contacts": contacts} + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return payload + + +def read_json_file(path: Path) -> dict[str, object]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except (OSError, json.JSONDecodeError): + return {} + + +def learn_feedback_profile(feedback_dir: Path) -> StyleProfile: + bodies: list[str] = [] + if feedback_dir.exists(): + for path in sorted(feedback_dir.glob("*.md")): + content = path.read_text(encoding="utf-8", errors="replace") + body = extract_markdown_section(content, "## Approved Reply", "##") or extract_markdown_section(content, "## Suggested Reply", "##") or content + body = strip_signature(strip_quoted_history(body)) + if len(body.split()) >= 3: + bodies.append(body) + messages = [EmailMessage("feedback", str(index), DEFAULT_IDENTITY, "approved feedback", "", body) for index, body in enumerate(bodies)] + return learn_style_profile(messages) + + +def merge_profiles(base: StyleProfile, feedback: StyleProfile) -> StyleProfile: + if feedback.samples == 0: + return base + total = base.samples + feedback.samples + avg_words = round(((base.avg_words * base.samples) + (feedback.avg_words * feedback.samples)) / total) if total else base.avg_words + return StyleProfile( + samples=total, + language=feedback.language or base.language, + avg_words=avg_words, + greeting_examples=unique_keep_order(feedback.greeting_examples + base.greeting_examples)[:8], + closing_examples=unique_keep_order(feedback.closing_examples + base.closing_examples)[:8], + frequent_phrases=unique_keep_order(feedback.frequent_phrases + base.frequent_phrases)[:12], + tone=feedback.tone if feedback.samples else base.tone, + updated_at=dt.datetime.now().isoformat(timespec="seconds"), + ) + + +def render_daily_report( + messages: list[EmailMessage], + digest_path: Path, + drafts_paths: list[Path], + profile: StyleProfile, + errors: list[str], +) -> str: + summary = summarize(messages) + lines = [ + f"# Daily Email Run - {today_stamp()}", + "", + "## Summary", + "", + f"- Total unread parsed: {summary['total']}", + f"- Digest: {digest_path}", + f"- Drafts created: {len(drafts_paths)}", + f"- Style samples: {profile.samples}", + f"- Style language: {profile.language}", + f"- Style tone: {profile.tone}", + "", + "## By Source", + ] + for source, count in summary["by_source"].items(): + lines.append(f"- {source}: {count}") + lines.extend(["", "## Draft Files"]) + for path in drafts_paths: + lines.append(f"- {path}") + if errors: + lines.extend(["", "## Warnings"]) + for error in errors: + lines.append(f"- {error}") + return "\n".join(lines).strip() + "\n" + + +def print_errors(errors: Iterable[str]) -> None: + for error in errors: + print(f"warning: {error}", file=sys.stderr) + + +def command_summary(args: argparse.Namespace) -> int: + messages, errors = fetch_messages(args.source, args.mode, args.limit, args.content_limit) + messages = dedupe_messages(messages) + print(json.dumps(summarize(messages), ensure_ascii=False, indent=2)) + print_errors(errors) + return 0 if messages or not errors else 1 + + +def command_export(args: argparse.Namespace) -> int: + messages, errors = fetch_messages(args.source, args.mode, args.limit, args.content_limit) + messages = dedupe_messages(messages) + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w", encoding="utf-8") as file: + for message in messages: + file.write(json.dumps(asdict(message), ensure_ascii=False) + "\n") + print(f"exported {len(messages)} messages to {output_path}") + print_errors(errors) + return 0 if messages or not errors else 1 + + +def command_drafts(args: argparse.Namespace) -> int: + messages, errors = fetch_messages(args.source, args.mode, args.limit, args.content_limit) + messages = prioritize_messages(dedupe_messages(messages)) + profile = load_style_profile(Path(args.profile)) if args.use_profile else None + output_dir = Path(args.output_dir) + if args.clean: + clear_markdown_dir(output_dir) + paths = write_drafts(messages, output_dir, args.language, args.style, profile) + print(f"created {len(paths)} drafts in {output_dir}") + if profile: + print(f"used learned profile: {args.profile} ({profile.samples} samples, {profile.tone})") + for path in paths: + print(path) + print_errors(errors) + return 0 if messages or not errors else 1 + + +def command_learn(args: argparse.Namespace) -> int: + messages, errors = fetch_messages(args.source, "sent", args.limit, args.content_limit) + messages = filter_by_identity(messages, args.identity) + profile = learn_style_profile(messages) + save_style_profile(profile, Path(args.output)) + print(f"learned from {profile.samples} sent messages") + print(f"saved profile to {args.output}") + print(json.dumps(asdict(profile), ensure_ascii=False, indent=2)) + print_errors(errors) + return 0 if profile.samples or not errors else 1 + + +def command_digest(args: argparse.Namespace) -> int: + messages, errors = fetch_messages(args.source, args.mode, args.limit, args.content_limit) + messages = dedupe_messages(messages) + digest = build_digest(messages) + if args.output == "-": + print(digest, end="") + elif args.output: + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(digest, encoding="utf-8") + print(f"digest saved to {output_path}") + print_errors(errors) + return 0 if messages or not errors else 1 + + +def command_run(args: argparse.Namespace) -> int: + sent_messages, sent_errors = fetch_messages(args.source, "sent", args.learn_limit, args.content_limit) + sent_messages = filter_by_identity(sent_messages, args.identity) + profile = learn_style_profile(sent_messages) + feedback_profile = learn_feedback_profile(Path(args.feedback_dir)) + profile = merge_profiles(profile, feedback_profile) + profile_path = Path(args.profile) + save_style_profile(profile, profile_path) + + unread_messages, unread_errors = fetch_messages(args.source, args.mode, args.limit, args.content_limit) + unread_messages = dedupe_messages(unread_messages) + digest_path = Path(args.digest_output) + summary_path = Path(args.summary_output) + messages_path = Path(args.messages_output) + contacts_path = Path(args.contacts_output) + calendar_path = Path(args.calendar_output) + report_path = Path(args.report_output) + drafts_dir = Path(args.output_dir) + + digest_path.parent.mkdir(parents=True, exist_ok=True) + digest_path.write_text(build_digest(unread_messages), encoding="utf-8") + + summary_path.parent.mkdir(parents=True, exist_ok=True) + summary_path.write_text(json.dumps(summarize(unread_messages), ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + write_messages_jsonl(unread_messages, messages_path) + update_contacts_memory(unread_messages, contacts_path) + meeting_messages = write_calendar_suggestions(unread_messages, calendar_path) + + if args.clean: + clear_markdown_dir(drafts_dir) + draft_messages = prioritize_messages(unread_messages)[: args.draft_limit] + drafts_paths = write_drafts(draft_messages, drafts_dir, args.language, args.style, profile) + + errors = sent_errors + unread_errors + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(render_daily_report(unread_messages, digest_path, drafts_paths, profile, errors), encoding="utf-8") + + print(f"profile saved to {profile_path}") + print(f"identity used for learning: {args.identity}") + if feedback_profile.samples: + print(f"approved feedback samples used: {feedback_profile.samples}") + print(f"summary saved to {summary_path}") + print(f"messages saved to {messages_path}") + print(f"contacts saved to {contacts_path}") + print(f"calendar suggestions saved to {calendar_path} ({len(meeting_messages)} meeting emails)") + print(f"digest saved to {digest_path}") + print(f"drafts saved to {drafts_dir} ({len(drafts_paths)} files)") + print(f"report saved to {report_path}") + print_errors(errors) + return 0 if unread_messages or not errors else 1 + + +def command_publish_drafts(args: argparse.Namespace) -> int: + count, errors = publish_drafts_to_mail(Path(args.input_dir), args.client, args.limit) + print(f"created {count} {args.client} draft messages from {Path(args.input_dir)}") + print("No messages were sent automatically.") + print_errors(errors) + return 0 if count or not errors else 1 + + +def command_mailboxes(args: argparse.Namespace) -> int: + rows, errors = fetch_mailboxes(args.source) + rows = sorted(rows, key=lambda row: (str(row["source"]), str(row["account"]), str(row["mailbox"]))) + print(json.dumps(rows, ensure_ascii=False, indent=2)) + print_errors(errors) + return 0 if rows or not errors else 1 + + +def command_calendar(args: argparse.Namespace) -> int: + messages, errors = fetch_messages(args.source, args.mode, args.limit, args.content_limit) + messages = dedupe_messages(messages) + output_path = Path(args.output) + meeting_messages = write_calendar_suggestions(messages, output_path) + print(f"calendar suggestions saved to {output_path} ({len(meeting_messages)} meeting emails)") + if args.create_events: + count, calendar_errors = create_calendar_reminders(meeting_messages, args.calendar_name) + print(f"created {count} local Calendar events in '{args.calendar_name}'") + errors.extend(calendar_errors) + print_errors(errors) + return 0 if meeting_messages or not errors else 1 + + +def command_contacts(args: argparse.Namespace) -> int: + messages, errors = fetch_messages(args.source, args.mode, args.limit, args.content_limit) + messages = dedupe_messages(messages) + memory = update_contacts_memory(messages, Path(args.output)) + contacts = memory.get("contacts", {}) if isinstance(memory, dict) else {} + print(f"contacts saved to {args.output} ({len(contacts)} contacts)") + print_errors(errors) + return 0 if contacts or not errors else 1 + + +def command_feedback(args: argparse.Namespace) -> int: + profile = learn_feedback_profile(Path(args.input_dir)) + save_style_profile(profile, Path(args.output)) + print(f"learned from {profile.samples} approved feedback drafts") + print(f"saved feedback profile to {args.output}") + print(json.dumps(asdict(profile), ensure_ascii=False, indent=2)) + return 0 + + +def command_doctor(args: argparse.Namespace) -> int: + print(f"python: {sys.version.split()[0]}") + print(f"date: {dt.datetime.now().isoformat(timespec='seconds')}") + for source in ("apple-mail", "outlook"): + messages, errors = fetch_messages(source, "unread", 1, 500) + if errors: + print(f"{source}: not ready") + print_errors(errors) + else: + print(f"{source}: ok, accessible messages: {len(messages)}") + return 0 + + +def add_common_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--source", choices=("apple-mail", "outlook", "all"), default="all") + parser.add_argument("--mode", choices=("unread", "inbox", "sent", "all-inboxes", "unread-all", "unread-mailboxes", "all-mailboxes"), default="unread-mailboxes") + parser.add_argument("--limit", type=int, default=200) + parser.add_argument("--content-limit", type=int, default=DEFAULT_CONTENT_LIMIT) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Local macOS email assistant") + subparsers = parser.add_subparsers(dest="command", required=True) + + summary_parser = subparsers.add_parser("summary", help="Count and group messages") + add_common_args(summary_parser) + summary_parser.set_defaults(func=command_summary) + + export_parser = subparsers.add_parser("export", help="Export messages to JSONL") + add_common_args(export_parser) + export_parser.add_argument("--output", default="data/exports/messages.jsonl") + export_parser.set_defaults(func=command_export) + + digest_parser = subparsers.add_parser("digest", help="Classify and prioritize messages") + add_common_args(digest_parser) + digest_parser.add_argument("--output", default=default_digest_path(), help="Use '-' to print to stdout") + digest_parser.set_defaults(func=command_digest) + + mailboxes_parser = subparsers.add_parser("mailboxes", help="List accounts/mailboxes and unread counts") + mailboxes_parser.add_argument("--source", choices=("apple-mail", "outlook", "all"), default="all") + mailboxes_parser.set_defaults(func=command_mailboxes) + + run_parser = subparsers.add_parser("run", help="Run daily learn + digest + drafts workflow") + run_parser.add_argument("--source", choices=("apple-mail", "outlook", "all"), default="all") + run_parser.add_argument("--mode", choices=("unread", "inbox", "all-inboxes", "unread-all", "unread-mailboxes", "all-mailboxes"), default="unread-mailboxes") + run_parser.add_argument("--limit", type=int, default=200) + run_parser.add_argument("--draft-limit", type=int, default=10) + run_parser.add_argument("--learn-limit", type=int, default=100) + run_parser.add_argument("--identity", default=DEFAULT_IDENTITY) + run_parser.add_argument("--content-limit", type=int, default=DEFAULT_CONTENT_LIMIT) + run_parser.add_argument("--profile", default=DEFAULT_PROFILE_PATH) + run_parser.add_argument("--summary-output", default=default_summary_path()) + run_parser.add_argument("--messages-output", default=default_messages_path()) + run_parser.add_argument("--contacts-output", default=DEFAULT_CONTACTS_PATH) + run_parser.add_argument("--calendar-output", default=default_calendar_path()) + run_parser.add_argument("--feedback-dir", default=DEFAULT_FEEDBACK_DIR) + run_parser.add_argument("--digest-output", default=default_digest_path()) + run_parser.add_argument("--report-output", default=default_report_path()) + run_parser.add_argument("--output-dir", default=default_drafts_dir()) + run_parser.add_argument("--language", default="auto") + run_parser.add_argument("--style", choices=("neutral", "formal", "casual", "learned"), default="learned") + run_parser.add_argument("--no-clean", action="store_false", dest="clean") + run_parser.set_defaults(clean=True) + run_parser.set_defaults(func=command_run) + + learn_parser = subparsers.add_parser("learn", help="Learn your reply style from sent mail") + learn_parser.add_argument("--source", choices=("apple-mail", "outlook", "all"), default="all") + learn_parser.add_argument("--limit", type=int, default=100) + learn_parser.add_argument("--identity", default=DEFAULT_IDENTITY) + learn_parser.add_argument("--content-limit", type=int, default=DEFAULT_CONTENT_LIMIT) + learn_parser.add_argument("--output", default=DEFAULT_PROFILE_PATH) + learn_parser.set_defaults(func=command_learn) + + drafts_parser = subparsers.add_parser("drafts", help="Prepare reply drafts") + add_common_args(drafts_parser) + drafts_parser.add_argument("--output-dir", default=default_drafts_dir()) + drafts_parser.add_argument("--language", default="auto") + drafts_parser.add_argument("--style", choices=("neutral", "formal", "casual", "learned"), default="learned") + drafts_parser.add_argument("--profile", default=DEFAULT_PROFILE_PATH) + drafts_parser.add_argument("--no-clean", action="store_false", dest="clean") + drafts_parser.add_argument("--no-profile", action="store_false", dest="use_profile") + drafts_parser.set_defaults(use_profile=True, clean=True) + drafts_parser.set_defaults(func=command_drafts) + + publish_parser = subparsers.add_parser("publish-drafts", help="Create safe drafts in Mail or Outlook without sending") + publish_parser.add_argument("--input-dir", default=default_drafts_dir()) + publish_parser.add_argument("--client", choices=("apple-mail", "outlook"), default="apple-mail") + publish_parser.add_argument("--limit", type=int, default=5) + publish_parser.set_defaults(func=command_publish_drafts) + + calendar_parser = subparsers.add_parser("calendar", help="Create meeting suggestions or local Calendar events") + add_common_args(calendar_parser) + calendar_parser.add_argument("--output", default=default_calendar_path()) + calendar_parser.add_argument("--create-events", action="store_true") + calendar_parser.add_argument("--calendar-name", default="Email Agent") + calendar_parser.set_defaults(func=command_calendar) + + contacts_parser = subparsers.add_parser("contacts", help="Update local contact memory") + add_common_args(contacts_parser) + contacts_parser.add_argument("--output", default=DEFAULT_CONTACTS_PATH) + contacts_parser.set_defaults(func=command_contacts) + + feedback_parser = subparsers.add_parser("feedback", help="Learn from manually approved/corrected drafts") + feedback_parser.add_argument("--input-dir", default=DEFAULT_FEEDBACK_DIR) + feedback_parser.add_argument("--output", default="data/profiles/approved_feedback_profile.json") + feedback_parser.set_defaults(func=command_feedback) + + doctor_parser = subparsers.add_parser("doctor", help="Check local access") + doctor_parser.set_defaults(func=command_doctor) + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/feedback/approved/.gitkeep b/feedback/approved/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web_app.py b/web_app.py new file mode 100644 index 0000000..a93446c --- /dev/null +++ b/web_app.py @@ -0,0 +1,1150 @@ +#!/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""" + + + + + + Local Email Agent + + + +
+ + +
+
+
+

Personal Mail Agent

+

Реальные письма, приоритеты, подготовленные ответы и локальная память. Без автоотправки.

+
+
+ + + + + +
+
+ +
+
+
+
Unread Parsed
+
-
+

Источники пока не загружены

+
+
+
Style Profile
+
-
+

Профиль пока не загружен

+
+
+
Drafts Today
+
-
+

drafts/YYYY-MM-DD

+
+
+

Критерии важности

+
+
P5
Срочно, deadline, critical, высокий риск.
+
P4
Нужно ответить, согласовать, approve/confirm.
+
P3
Встреча, созвон, календарь, рабочее обсуждение.
+
P2
Низкий приоритет, полезно посмотреть позже.
+
P1
FYI, рассылки, уведомления без действия.
+
Labels
urgent, needs_reply, meeting, finance_or_contract, newsletter, fyi.
+
+
+
+
+ +
+
+
+

Письма

+
+
+
+

Просмотр письма

full parsed body
+
Выбери письмо слева.
+
+
+
+ +
+
+
+

Подготовленные ответы

+
+
+
+

Просмотр ответа

markdown draft
+
Выбери ответ слева.
+
+
+
+ +
+
+
+

Структура

+
+
+
+
+ + + +
+
Выбери файл слева или кнопку выше.
+
+
+
+ +
+
+

Job Output

+
Пока задач не было.
+
+
+
+
+ + + + +""" + + +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())