feat(mail): add IMAP/STP email subcommand
New `myclaude mail` group with: - config: interactive account setup - inbox: list recent messages - read: show message by UID - send: send email with attachments - reply/forward: reply to or forward messages
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
"""Mail subcommand for myclaude."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from myclaude.mail.client import IMAPClient, SMTPClient
|
||||
from myclaude.mail.config import (
|
||||
DEFAULT_IMAP_HOST,
|
||||
DEFAULT_IMAP_PORT,
|
||||
DEFAULT_SMTP_HOST,
|
||||
DEFAULT_SMTP_PORT,
|
||||
load_config,
|
||||
save_config,
|
||||
)
|
||||
from myclaude.mail.utils import truncate_text
|
||||
|
||||
console = Console()
|
||||
stderr_console = Console(stderr=True)
|
||||
|
||||
|
||||
@click.group("mail")
|
||||
def mail_cmd() -> None:
|
||||
"""Manage email via IMAP/SMTP (e.g. SYSU Coremail)."""
|
||||
|
||||
|
||||
@mail_cmd.command("config")
|
||||
def mail_config() -> None:
|
||||
"""Interactively configure mail account."""
|
||||
console.print("[bold cyan]Mail configuration[/bold cyan]")
|
||||
existing = load_config()
|
||||
|
||||
def _prompt(text: str, default: str | None = None) -> str:
|
||||
prompt = text
|
||||
if default:
|
||||
prompt = f"{text} [{default}]"
|
||||
value = click.prompt(prompt, default=default or "", show_default=False)
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
return str(value).strip()
|
||||
|
||||
imap_host = (
|
||||
_prompt(
|
||||
"IMAP host",
|
||||
existing.get("imap_host") if existing else DEFAULT_IMAP_HOST,
|
||||
)
|
||||
or DEFAULT_IMAP_HOST
|
||||
)
|
||||
imap_port_str = _prompt(
|
||||
"IMAP port",
|
||||
str(existing.get("imap_port")) if existing else str(DEFAULT_IMAP_PORT),
|
||||
) or str(DEFAULT_IMAP_PORT)
|
||||
smtp_host = (
|
||||
_prompt(
|
||||
"SMTP host",
|
||||
existing.get("smtp_host") if existing else DEFAULT_SMTP_HOST,
|
||||
)
|
||||
or DEFAULT_SMTP_HOST
|
||||
)
|
||||
smtp_port_str = _prompt(
|
||||
"SMTP port",
|
||||
str(existing.get("smtp_port")) if existing else str(DEFAULT_SMTP_PORT),
|
||||
) or str(DEFAULT_SMTP_PORT)
|
||||
email = _prompt("Email", existing.get("email") if existing else "") or ""
|
||||
password = (
|
||||
click.prompt("Password", hide_input=True)
|
||||
if not existing
|
||||
else click.prompt(
|
||||
"Password (leave blank to keep existing)",
|
||||
hide_input=True,
|
||||
default="",
|
||||
show_default=False,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
imap_port = int(imap_port_str)
|
||||
smtp_port = int(smtp_port_str)
|
||||
except ValueError:
|
||||
stderr_console.print("[red]Invalid port number.[/red]")
|
||||
raise SystemExit(1) from None
|
||||
|
||||
config = {
|
||||
"imap_host": imap_host,
|
||||
"imap_port": imap_port,
|
||||
"smtp_host": smtp_host,
|
||||
"smtp_port": smtp_port,
|
||||
"email": email,
|
||||
}
|
||||
if password:
|
||||
config["password"] = password
|
||||
elif existing and existing.get("password"):
|
||||
config["password"] = existing["password"]
|
||||
|
||||
save_config(config)
|
||||
console.print("[green]Configuration saved.[/green]")
|
||||
|
||||
|
||||
@mail_cmd.command("inbox")
|
||||
@click.option("--limit", default=20, help="Number of recent messages to show.")
|
||||
def mail_inbox(limit: int) -> None:
|
||||
"""Show recent inbox summaries."""
|
||||
cfg = load_config()
|
||||
if not cfg:
|
||||
stderr_console.print(
|
||||
'[red]No mail config found.[/red] Run "myclaude mail config" first.'
|
||||
)
|
||||
raise SystemExit(1) from None
|
||||
|
||||
try:
|
||||
with IMAPClient(
|
||||
cfg["imap_host"],
|
||||
cfg["imap_port"],
|
||||
cfg["email"],
|
||||
cfg["password"],
|
||||
) as client:
|
||||
summaries = client.inbox_summaries(limit=limit)
|
||||
except Exception as exc:
|
||||
stderr_console.print(f"[red]IMAP error:[/red] {exc}")
|
||||
raise SystemExit(1) from None
|
||||
|
||||
if not summaries:
|
||||
console.print("Inbox is empty.")
|
||||
return
|
||||
|
||||
table = Table(title="Inbox")
|
||||
table.add_column("UID", style="cyan", no_wrap=True)
|
||||
table.add_column("From", style="green")
|
||||
table.add_column("Subject")
|
||||
table.add_column("Date", style="dim")
|
||||
|
||||
for s in summaries:
|
||||
table.add_row(
|
||||
s["uid"],
|
||||
truncate_text(s.get("from", ""), 30),
|
||||
truncate_text(s.get("subject", ""), 50),
|
||||
s.get("date", ""),
|
||||
)
|
||||
console.print(table)
|
||||
|
||||
|
||||
@mail_cmd.command("read")
|
||||
@click.argument("uid")
|
||||
def mail_read(uid: str) -> None:
|
||||
"""Read a single message by UID."""
|
||||
cfg = load_config()
|
||||
if not cfg:
|
||||
stderr_console.print(
|
||||
'[red]No mail config found.[/red] Run "myclaude mail config" first.'
|
||||
)
|
||||
raise SystemExit(1) from None
|
||||
|
||||
try:
|
||||
with IMAPClient(
|
||||
cfg["imap_host"],
|
||||
cfg["imap_port"],
|
||||
cfg["email"],
|
||||
cfg["password"],
|
||||
) as client:
|
||||
msg = client.fetch_message(uid)
|
||||
except Exception as exc:
|
||||
stderr_console.print(f"[red]IMAP error:[/red] {exc}")
|
||||
raise SystemExit(1) from None
|
||||
|
||||
console.print(f"[bold]From:[/bold] {msg['from']}")
|
||||
console.print(f"[bold]To:[/bold] {msg['to']}")
|
||||
if msg.get("cc"):
|
||||
console.print(f"[bold]Cc:[/bold] {msg['cc']}")
|
||||
console.print(f"[bold]Subject:[/bold] {msg['subject']}")
|
||||
console.print(f"[bold]Date:[/bold] {msg['date']}")
|
||||
console.print("-" * 60)
|
||||
|
||||
body = msg["plain"] or msg["html"]
|
||||
console.print(body or "[dim](no body)[/dim]")
|
||||
|
||||
if msg.get("attachments"):
|
||||
console.print("\n[bold]Attachments:[/bold]")
|
||||
for att in msg["attachments"]:
|
||||
console.print(f" • {att['filename']} ({att['content_type']})")
|
||||
|
||||
|
||||
@mail_cmd.command("send")
|
||||
@click.option("--to", required=True, help="Recipient email address.")
|
||||
@click.option("--subject", required=True, help="Email subject.")
|
||||
@click.option("--body", required=True, help="Email body.")
|
||||
@click.option("--cc", default="", help="Cc recipients (comma-separated).")
|
||||
@click.option("--bcc", default="", help="Bcc recipients (comma-separated).")
|
||||
@click.option(
|
||||
"--attach",
|
||||
multiple=True,
|
||||
help="Attachment file path (can be used multiple times).",
|
||||
)
|
||||
@click.option("--html", is_flag=True, help="Treat body as HTML.")
|
||||
def mail_send(
|
||||
to: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
cc: str,
|
||||
bcc: str,
|
||||
attach: tuple[str, ...],
|
||||
html: bool,
|
||||
) -> None:
|
||||
"""Send an email."""
|
||||
cfg = load_config()
|
||||
if not cfg:
|
||||
stderr_console.print(
|
||||
'[red]No mail config found.[/red] Run "myclaude mail config" first.'
|
||||
)
|
||||
raise SystemExit(1) from None
|
||||
|
||||
body = body.replace("\\n", "\n").replace("\\t", "\t")
|
||||
|
||||
to_addrs = [addr.strip() for addr in to.split(",") if addr.strip()]
|
||||
cc_addrs = (
|
||||
[addr.strip() for addr in cc.split(",") if addr.strip()] if cc else None
|
||||
)
|
||||
bcc_addrs = (
|
||||
[addr.strip() for addr in bcc.split(",") if addr.strip()]
|
||||
if bcc
|
||||
else None
|
||||
)
|
||||
attachments = [Path(p) for p in attach]
|
||||
|
||||
for p in attachments:
|
||||
if not p.is_file():
|
||||
stderr_console.print(f"[red]Attachment not found:[/red] {p}")
|
||||
raise SystemExit(1) from None
|
||||
|
||||
try:
|
||||
client = SMTPClient(
|
||||
cfg["smtp_host"],
|
||||
cfg["smtp_port"],
|
||||
cfg["email"],
|
||||
cfg["password"],
|
||||
)
|
||||
client.send(
|
||||
to_addrs=to_addrs,
|
||||
subject=subject,
|
||||
body=body,
|
||||
cc_addrs=cc_addrs,
|
||||
bcc_addrs=bcc_addrs,
|
||||
attachments=[str(p) for p in attachments],
|
||||
is_html=html,
|
||||
)
|
||||
except Exception as exc:
|
||||
stderr_console.print(f"[red]SMTP error:[/red] {exc}")
|
||||
raise SystemExit(1) from None
|
||||
|
||||
console.print("[green]Email sent successfully.[/green]")
|
||||
|
||||
|
||||
@mail_cmd.command("reply")
|
||||
@click.argument("uid")
|
||||
@click.option("--body", required=True, help="Reply body.")
|
||||
@click.option("--html", is_flag=True, help="Treat body as HTML.")
|
||||
def mail_reply(uid: str, body: str, html: bool) -> None:
|
||||
"""Reply to a message by UID."""
|
||||
cfg = load_config()
|
||||
if not cfg:
|
||||
stderr_console.print(
|
||||
'[red]No mail config found.[/red] Run "myclaude mail config" first.'
|
||||
)
|
||||
raise SystemExit(1) from None
|
||||
|
||||
body = body.replace("\\n", "\n").replace("\\t", "\t")
|
||||
|
||||
try:
|
||||
with IMAPClient(
|
||||
cfg["imap_host"],
|
||||
cfg["imap_port"],
|
||||
cfg["email"],
|
||||
cfg["password"],
|
||||
) as client:
|
||||
original = client.fetch_message(uid)
|
||||
except Exception as exc:
|
||||
stderr_console.print(f"[red]IMAP error:[/red] {exc}")
|
||||
raise SystemExit(1) from None
|
||||
|
||||
try:
|
||||
smtp = SMTPClient(
|
||||
cfg["smtp_host"],
|
||||
cfg["smtp_port"],
|
||||
cfg["email"],
|
||||
cfg["password"],
|
||||
)
|
||||
smtp.send_reply(original, body=body, is_html=html)
|
||||
except Exception as exc:
|
||||
stderr_console.print(f"[red]SMTP error:[/red] {exc}")
|
||||
raise SystemExit(1) from None
|
||||
|
||||
console.print("[green]Reply sent successfully.[/green]")
|
||||
|
||||
|
||||
@mail_cmd.command("forward")
|
||||
@click.argument("uid")
|
||||
@click.option("--to", required=True, help="Recipient email address.")
|
||||
@click.option("--body", default="", help="Optional forwarding note.")
|
||||
@click.option("--html", is_flag=True, help="Treat body as HTML.")
|
||||
def mail_forward(uid: str, to: str, body: str, html: bool) -> None:
|
||||
"""Forward a message by UID."""
|
||||
cfg = load_config()
|
||||
if not cfg:
|
||||
stderr_console.print(
|
||||
'[red]No mail config found.[/red] Run "myclaude mail config" first.'
|
||||
)
|
||||
raise SystemExit(1) from None
|
||||
|
||||
body = body.replace("\\n", "\n").replace("\\t", "\t")
|
||||
to_addrs = [addr.strip() for addr in to.split(",") if addr.strip()]
|
||||
|
||||
try:
|
||||
with IMAPClient(
|
||||
cfg["imap_host"],
|
||||
cfg["imap_port"],
|
||||
cfg["email"],
|
||||
cfg["password"],
|
||||
) as client:
|
||||
original = client.fetch_message(uid)
|
||||
raw_original = client.fetch_raw(uid)
|
||||
except Exception as exc:
|
||||
stderr_console.print(f"[red]IMAP error:[/red] {exc}")
|
||||
raise SystemExit(1) from None
|
||||
|
||||
subject = original.get("subject", "")
|
||||
|
||||
try:
|
||||
smtp = SMTPClient(
|
||||
cfg["smtp_host"],
|
||||
cfg["smtp_port"],
|
||||
cfg["email"],
|
||||
cfg["password"],
|
||||
)
|
||||
smtp.send_forward(
|
||||
raw_original=raw_original,
|
||||
to_addrs=to_addrs,
|
||||
subject=subject,
|
||||
body=body or None,
|
||||
is_html=html,
|
||||
)
|
||||
except Exception as exc:
|
||||
stderr_console.print(f"[red]SMTP error:[/red] {exc}")
|
||||
raise SystemExit(1) from None
|
||||
|
||||
console.print("[green]Message forwarded successfully.[/green]")
|
||||
@@ -0,0 +1 @@
|
||||
"""Mail module for myclaude."""
|
||||
@@ -0,0 +1,279 @@
|
||||
"""IMAP and SMTP client wrappers."""
|
||||
|
||||
import imaplib
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
from typing import Any
|
||||
|
||||
from myclaude.mail.parser import extract_attachments, extract_text
|
||||
from myclaude.mail.utils import decode_header_value, format_date
|
||||
|
||||
|
||||
class IMAPClient:
|
||||
"""Simple IMAP client wrapper."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
email: str,
|
||||
password: str,
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.email = email
|
||||
self.password = password
|
||||
self._conn: imaplib.IMAP4_SSL | None = None
|
||||
|
||||
def connect(self) -> "IMAPClient":
|
||||
self._conn = imaplib.IMAP4_SSL(self.host, self.port)
|
||||
self._conn.login(self.email, self.password)
|
||||
return self
|
||||
|
||||
def disconnect(self) -> None:
|
||||
if self._conn:
|
||||
try:
|
||||
self._conn.close()
|
||||
self._conn.logout()
|
||||
except Exception:
|
||||
pass
|
||||
self._conn = None
|
||||
|
||||
def __enter__(self) -> "IMAPClient":
|
||||
return self.connect()
|
||||
|
||||
def __exit__(self, *_: Any) -> None:
|
||||
self.disconnect()
|
||||
|
||||
def inbox_summaries(self, limit: int = 20) -> list[dict[str, Any]]:
|
||||
"""Fetch recent inbox message summaries."""
|
||||
assert self._conn is not None
|
||||
status, _ = self._conn.select("INBOX")
|
||||
if status != "OK":
|
||||
raise RuntimeError("Failed to select INBOX")
|
||||
|
||||
_, data = self._conn.search(None, "ALL")
|
||||
msg_ids = data[0].split()
|
||||
recent_ids = msg_ids[-limit:]
|
||||
recent_ids.reverse()
|
||||
|
||||
summaries: list[dict[str, Any]] = []
|
||||
for msg_id in recent_ids:
|
||||
msg_id_str = msg_id.decode("ascii")
|
||||
_, fetched = self._conn.fetch(
|
||||
msg_id_str,
|
||||
"(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])",
|
||||
)
|
||||
raw_headers = b""
|
||||
for item in fetched:
|
||||
if isinstance(item, tuple):
|
||||
raw_headers += item[1]
|
||||
|
||||
from email import message_from_bytes
|
||||
|
||||
header_msg = message_from_bytes(raw_headers)
|
||||
summaries.append(
|
||||
{
|
||||
"uid": msg_id_str,
|
||||
"from": decode_header_value(header_msg.get("From")),
|
||||
"subject": decode_header_value(header_msg.get("Subject")),
|
||||
"date": format_date(header_msg.get("Date")),
|
||||
}
|
||||
)
|
||||
return summaries
|
||||
|
||||
def fetch_raw(self, uid: str) -> bytes:
|
||||
"""Fetch raw RFC822 bytes by UID."""
|
||||
assert self._conn is not None
|
||||
status, _ = self._conn.select("INBOX")
|
||||
if status != "OK":
|
||||
raise RuntimeError("Failed to select INBOX")
|
||||
_, data = self._conn.fetch(uid, "(RFC822)")
|
||||
raw = b""
|
||||
for item in data:
|
||||
if isinstance(item, tuple):
|
||||
raw += item[1]
|
||||
return raw
|
||||
|
||||
def fetch_message(self, uid: str) -> dict[str, Any]:
|
||||
"""Fetch full message by UID."""
|
||||
raw = self.fetch_raw(uid)
|
||||
|
||||
from email import message_from_bytes
|
||||
|
||||
msg = message_from_bytes(raw)
|
||||
plain, html = extract_text(msg)
|
||||
attachments = extract_attachments(msg)
|
||||
|
||||
return {
|
||||
"uid": uid,
|
||||
"from": decode_header_value(msg.get("From")),
|
||||
"to": decode_header_value(msg.get("To")),
|
||||
"cc": decode_header_value(msg.get("Cc")),
|
||||
"subject": decode_header_value(msg.get("Subject")),
|
||||
"date": format_date(msg.get("Date")),
|
||||
"message_id": decode_header_value(msg.get("Message-ID")),
|
||||
"reply_to": decode_header_value(msg.get("Reply-To")),
|
||||
"references": decode_header_value(msg.get("References")),
|
||||
"plain": plain,
|
||||
"html": html,
|
||||
"attachments": attachments,
|
||||
}
|
||||
|
||||
|
||||
class SMTPClient:
|
||||
"""Simple SMTP client wrapper."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
email: str,
|
||||
password: str,
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.email = email
|
||||
self.password = password
|
||||
|
||||
def send(
|
||||
self,
|
||||
to_addrs: list[str],
|
||||
subject: str,
|
||||
body: str,
|
||||
cc_addrs: list[str] | None = None,
|
||||
bcc_addrs: list[str] | None = None,
|
||||
attachments: list[str] | None = None,
|
||||
is_html: bool = False,
|
||||
) -> None:
|
||||
"""Send an email message."""
|
||||
msg = EmailMessage()
|
||||
msg["From"] = formataddr((self.email.split("@")[0], self.email))
|
||||
msg["To"] = ", ".join(to_addrs)
|
||||
if cc_addrs:
|
||||
msg["Cc"] = ", ".join(cc_addrs)
|
||||
msg["Subject"] = subject
|
||||
|
||||
content_type = "text/html" if is_html else "text/plain"
|
||||
msg.set_content(body, subtype=content_type.split("/")[1])
|
||||
|
||||
if attachments:
|
||||
for path in attachments:
|
||||
from pathlib import Path
|
||||
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
raise FileNotFoundError(f"Attachment not found: {path}")
|
||||
data = p.read_bytes()
|
||||
msg.add_attachment(
|
||||
data,
|
||||
maintype="application",
|
||||
subtype="octet-stream",
|
||||
filename=p.name,
|
||||
)
|
||||
|
||||
all_recipients = list(to_addrs)
|
||||
if cc_addrs:
|
||||
all_recipients.extend(cc_addrs)
|
||||
if bcc_addrs:
|
||||
all_recipients.extend(bcc_addrs)
|
||||
|
||||
with smtplib.SMTP_SSL(self.host, self.port) as server:
|
||||
server.login(self.email, self.password)
|
||||
server.sendmail(self.email, all_recipients, msg.as_string())
|
||||
|
||||
def send_reply(
|
||||
self,
|
||||
original: dict[str, Any],
|
||||
body: str,
|
||||
is_html: bool = False,
|
||||
) -> None:
|
||||
"""Send a reply to an existing message."""
|
||||
to_addr = original.get("reply_to") or original["from"]
|
||||
if not to_addr:
|
||||
raise ValueError("Original message has no From or Reply-To address")
|
||||
|
||||
subject = original.get("subject", "")
|
||||
if not subject.lower().startswith("re:"):
|
||||
subject = f"Re: {subject}"
|
||||
|
||||
quote_lines = [
|
||||
f"> From: {original.get('from', '')}",
|
||||
f"> To: {original.get('to', '')}",
|
||||
f"> Subject: {original.get('subject', '')}",
|
||||
f"> Date: {original.get('date', '')}",
|
||||
">",
|
||||
]
|
||||
for line in (original.get("plain") or original.get("html") or "").splitlines():
|
||||
quote_lines.append(f"> {line}")
|
||||
quoted = "\n".join(quote_lines)
|
||||
|
||||
full_body = f"{body}\n\n{quoted}" if quoted.strip("> ") else body
|
||||
|
||||
msg = EmailMessage()
|
||||
msg["From"] = formataddr((self.email.split("@")[0], self.email))
|
||||
msg["To"] = to_addr
|
||||
msg["Subject"] = subject
|
||||
|
||||
orig_msg_id = original.get("message_id", "").strip()
|
||||
if orig_msg_id:
|
||||
msg["In-Reply-To"] = orig_msg_id
|
||||
refs = original.get("references", "").strip()
|
||||
msg["References"] = f"{refs} {orig_msg_id}".strip() if refs else orig_msg_id
|
||||
|
||||
content_type = "text/html" if is_html else "text/plain"
|
||||
msg.set_content(full_body, subtype=content_type.split("/")[1])
|
||||
|
||||
with smtplib.SMTP_SSL(self.host, self.port) as server:
|
||||
server.login(self.email, self.password)
|
||||
server.sendmail(self.email, [to_addr], msg.as_string())
|
||||
|
||||
def send_forward(
|
||||
self,
|
||||
raw_original: bytes,
|
||||
to_addrs: list[str],
|
||||
subject: str | None = None,
|
||||
body: str | None = None,
|
||||
cc_addrs: list[str] | None = None,
|
||||
bcc_addrs: list[str] | None = None,
|
||||
is_html: bool = False,
|
||||
) -> None:
|
||||
"""Forward an existing message as a message/rfc822 attachment."""
|
||||
from email import message_from_bytes
|
||||
|
||||
orig_msg = message_from_bytes(raw_original)
|
||||
orig_subject = decode_header_value(orig_msg.get("Subject"))
|
||||
if subject is None:
|
||||
subject = orig_subject
|
||||
if not subject.lower().startswith("fwd:"):
|
||||
subject = f"Fwd: {subject}"
|
||||
|
||||
msg = EmailMessage()
|
||||
msg["From"] = formataddr((self.email.split("@")[0], self.email))
|
||||
msg["To"] = ", ".join(to_addrs)
|
||||
if cc_addrs:
|
||||
msg["Cc"] = ", ".join(cc_addrs)
|
||||
msg["Subject"] = subject
|
||||
|
||||
if body:
|
||||
content_type = "text/html" if is_html else "text/plain"
|
||||
msg.set_content(body, subtype=content_type.split("/")[1])
|
||||
else:
|
||||
msg.set_content("")
|
||||
|
||||
msg.add_attachment(
|
||||
orig_msg,
|
||||
maintype="message",
|
||||
subtype="rfc822",
|
||||
)
|
||||
|
||||
all_recipients = list(to_addrs)
|
||||
if cc_addrs:
|
||||
all_recipients.extend(cc_addrs)
|
||||
if bcc_addrs:
|
||||
all_recipients.extend(bcc_addrs)
|
||||
|
||||
with smtplib.SMTP_SSL(self.host, self.port) as server:
|
||||
server.login(self.email, self.password)
|
||||
server.sendmail(self.email, all_recipients, msg.as_string())
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Mail configuration management."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
DEFAULT_IMAP_HOST = "mail.sysu.edu.cn"
|
||||
DEFAULT_IMAP_PORT = 993
|
||||
DEFAULT_SMTP_HOST = "mail.sysu.edu.cn"
|
||||
DEFAULT_SMTP_PORT = 465
|
||||
|
||||
|
||||
def _config_path() -> Path:
|
||||
return Path.home() / ".myclaude" / "mail.json"
|
||||
|
||||
|
||||
def _ensure_dir(path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def load_config() -> dict[str, Any] | None:
|
||||
"""Load mail config from ~/.myclaude/mail.json."""
|
||||
path = _config_path()
|
||||
if not path.is_file():
|
||||
return None
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
# decode password
|
||||
if data.get("password"):
|
||||
data["password"] = base64.b64decode(data["password"]).decode("utf-8")
|
||||
# allow env overrides
|
||||
email = os.environ.get("MYCLAUDE_MAIL_EMAIL")
|
||||
password = os.environ.get("MYCLAUDE_MAIL_PASSWORD")
|
||||
if email:
|
||||
data["email"] = email
|
||||
if password:
|
||||
data["password"] = password
|
||||
return data
|
||||
|
||||
|
||||
def save_config(data: dict[str, Any]) -> None:
|
||||
"""Save mail config to ~/.myclaude/mail.json."""
|
||||
path = _config_path()
|
||||
_ensure_dir(path)
|
||||
to_save = dict(data)
|
||||
if to_save.get("password"):
|
||||
to_save["password"] = base64.b64encode(
|
||||
to_save["password"].encode("utf-8")
|
||||
).decode("ascii")
|
||||
with path.open("w", encoding="utf-8") as f:
|
||||
json.dump(to_save, f, indent=2, ensure_ascii=False)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Email message parsing utilities."""
|
||||
|
||||
from email.message import Message
|
||||
from typing import Any
|
||||
|
||||
from myclaude.mail.utils import decode_header_value, strip_html_tags
|
||||
|
||||
|
||||
def _extract_payload(part: Message) -> str:
|
||||
"""Extract text payload from a message part."""
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
payload = part.get_payload(decode=True)
|
||||
if isinstance(payload, bytes):
|
||||
return payload.decode(charset, errors="replace")
|
||||
return str(payload or "")
|
||||
|
||||
|
||||
def extract_text(msg: Message) -> tuple[str, str]:
|
||||
"""Extract plain text and html body from a message.
|
||||
|
||||
Returns (plain_text, html_text). Either may be empty.
|
||||
"""
|
||||
plain_parts: list[str] = []
|
||||
html_parts: list[str] = []
|
||||
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
ctype = part.get_content_type()
|
||||
cdisp = str(part.get("Content-Disposition", ""))
|
||||
if "attachment" in cdisp:
|
||||
continue
|
||||
if ctype == "text/plain":
|
||||
plain_parts.append(_extract_payload(part))
|
||||
elif ctype == "text/html":
|
||||
html_parts.append(_extract_payload(part))
|
||||
else:
|
||||
ctype = msg.get_content_type()
|
||||
payload = _extract_payload(msg)
|
||||
if ctype == "text/html":
|
||||
html_parts.append(payload)
|
||||
else:
|
||||
plain_parts.append(payload)
|
||||
|
||||
plain = "\n".join(plain_parts).strip()
|
||||
html = "\n".join(html_parts).strip()
|
||||
|
||||
if not plain and html:
|
||||
plain = strip_html_tags(html)
|
||||
|
||||
return plain, html
|
||||
|
||||
|
||||
def extract_attachments(msg: Message) -> list[dict[str, Any]]:
|
||||
"""Return list of attachment metadata dicts."""
|
||||
attachments: list[dict[str, Any]] = []
|
||||
if not msg.is_multipart():
|
||||
return attachments
|
||||
|
||||
for part in msg.walk():
|
||||
cdisp = str(part.get("Content-Disposition", ""))
|
||||
if "attachment" not in cdisp:
|
||||
continue
|
||||
filename = decode_header_value(part.get_filename())
|
||||
if filename:
|
||||
attachments.append(
|
||||
{
|
||||
"filename": filename,
|
||||
"content_type": part.get_content_type(),
|
||||
"size": len(part.get_payload(decode=True) or b""),
|
||||
}
|
||||
)
|
||||
return attachments
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Mail utility helpers."""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from email.header import decode_header
|
||||
from email.utils import mktime_tz, parsedate_tz
|
||||
from typing import Any
|
||||
|
||||
|
||||
def decode_header_value(value: Any) -> str:
|
||||
"""Decode email header value safely."""
|
||||
if value is None:
|
||||
return ""
|
||||
raw = value if isinstance(value, str) else str(value)
|
||||
parts = decode_header(raw)
|
||||
result: list[str] = []
|
||||
for part, charset in parts:
|
||||
if isinstance(part, bytes):
|
||||
result.append(part.decode(charset or "utf-8", errors="replace"))
|
||||
else:
|
||||
result.append(part)
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def format_date(date_str: str | None) -> str:
|
||||
"""Format email date string to local-friendly form."""
|
||||
if not date_str:
|
||||
return ""
|
||||
try:
|
||||
tt = parsedate_tz(date_str)
|
||||
if tt is None:
|
||||
return date_str
|
||||
ts = mktime_tz(tt)
|
||||
dt = datetime.fromtimestamp(ts)
|
||||
return dt.strftime("%Y-%m-%d %H:%M")
|
||||
except Exception:
|
||||
return date_str
|
||||
|
||||
|
||||
def strip_html_tags(html: str) -> str:
|
||||
"""Very basic HTML tag stripping for fallback text extraction."""
|
||||
text = re.sub(r"<script[^>]*>.*?</script>", "", html, flags=re.S)
|
||||
text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.S)
|
||||
# Replace block-level tags with newlines before stripping remaining tags
|
||||
text = re.sub(r"</(p|div|h[1-6]|li|tr|blockquote)>", "\n", text, flags=re.I)
|
||||
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
text = re.sub(r" ", " ", text)
|
||||
text = re.sub(r"<", "<", text)
|
||||
text = re.sub(r">", ">", text)
|
||||
text = re.sub(r"&", "&", text)
|
||||
text = re.sub(r"\n\s*\n+", "\n\n", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def truncate_text(text: str, max_len: int = 60) -> str:
|
||||
"""Truncate text with ellipsis."""
|
||||
if len(text) <= max_len:
|
||||
return text
|
||||
return text[: max_len - 1] + "…"
|
||||
Reference in New Issue
Block a user