#!/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())