1680 lines
68 KiB
Python
1680 lines
68 KiB
Python
#!/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())
|