diff --git a/myclaude/commands/mail.py b/myclaude/commands/mail.py
new file mode 100644
index 0000000..6b2a09f
--- /dev/null
+++ b/myclaude/commands/mail.py
@@ -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]")
diff --git a/myclaude/mail/__init__.py b/myclaude/mail/__init__.py
new file mode 100644
index 0000000..52d077e
--- /dev/null
+++ b/myclaude/mail/__init__.py
@@ -0,0 +1 @@
+"""Mail module for myclaude."""
diff --git a/myclaude/mail/client.py b/myclaude/mail/client.py
new file mode 100644
index 0000000..a7e2fc3
--- /dev/null
+++ b/myclaude/mail/client.py
@@ -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())
diff --git a/myclaude/mail/config.py b/myclaude/mail/config.py
new file mode 100644
index 0000000..649ee1d
--- /dev/null
+++ b/myclaude/mail/config.py
@@ -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)
diff --git a/myclaude/mail/parser.py b/myclaude/mail/parser.py
new file mode 100644
index 0000000..49b9e49
--- /dev/null
+++ b/myclaude/mail/parser.py
@@ -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
diff --git a/myclaude/mail/utils.py b/myclaude/mail/utils.py
new file mode 100644
index 0000000..ca1ae7c
--- /dev/null
+++ b/myclaude/mail/utils.py
@@ -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"", "", html, flags=re.S)
+ text = re.sub(r"", "", 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"
", "\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] + "…"