Files
mailagent/email_agent.py
2026-06-16 15:56:33 +03:00

1680 lines
68 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())