feat: 多语言任务、WebUI 增强与 Agent MCP 集成

重构 lib 为扁平模块并支持 Windows schtasks;新增 JS/Bash/PowerShell 模板、WebUI 调度编辑,以及 Cursor Skill 与 MCP 工具供 Agent 管理定时任务。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 21:45:25 +08:00
parent 6c2db2dfa3
commit c10cacd5c6
59 changed files with 3989 additions and 800 deletions

View File

@@ -0,0 +1,3 @@
__all__ = ["__version__"]
__version__ = "0.1.0"

View File

@@ -0,0 +1,7 @@
from __future__ import annotations
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,262 @@
from __future__ import annotations
import argparse
import json
import os
import sys
from typing import Any
from .config import ConfigError, MailConfig
from .email_client import MailClient, MailClientError
from .templates import TemplateError, list_templates, render_template
def _load_env_file(path: str | None) -> None:
if path:
os.environ["MENGYA_MAIL_ENV_FILE"] = path
def _split_global_args(argv: list[str]) -> tuple[list[str], str | None, str | None]:
cleaned: list[str] = []
env_file: str | None = None
output_format: str | None = None
i = 0
while i < len(argv):
arg = argv[i]
if arg == "--env-file" and i + 1 < len(argv):
env_file = argv[i + 1]
i += 2
continue
if arg == "--format" and i + 1 < len(argv):
output_format = argv[i + 1]
i += 2
continue
cleaned.append(arg)
i += 1
return cleaned, env_file, output_format
def _client() -> MailClient:
return MailClient(MailConfig.from_env())
def _parse_var_pairs(values: list[str] | None) -> dict[str, str]:
result: dict[str, str] = {}
for item in values or []:
if "=" not in item:
raise ValueError(f"变量格式应为 key=value: {item}")
key, value = item.split("=", 1)
key = key.strip()
if not key:
raise ValueError(f"变量键不能为空: {item}")
result[key] = value
return result
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="mengya-mail-api")
parser.add_argument("--env-file", help="Load env vars from a specific .env file")
parser.add_argument(
"--format",
choices=("json", "md"),
default="json",
help="Output format",
)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("test-connection", help="Check SMTP/IMAP connectivity")
send = sub.add_parser("send-email", help="Send a mail")
send.add_argument("--to", nargs="+", required=True, help="Primary recipients")
send.add_argument("--subject", required=True, help="Subject")
send.add_argument("--text-body", help="Plain text body")
send.add_argument("--html-body", help="HTML body")
send.add_argument("--cc", nargs="+", help="CC recipients")
send.add_argument("--bcc", nargs="+", help="BCC recipients")
send.add_argument("--from-name", help="Display name of the sender")
listed = sub.add_parser("list-emails", help="List messages")
listed.add_argument("--folder", help="IMAP folder")
listed.add_argument("--criteria", nargs="+", help="IMAP search criteria")
listed.add_argument("--subject", help="Filter by subject")
listed.add_argument("--from-address", dest="from_address", help="Filter by sender")
listed.add_argument("--to-address", dest="to_address", help="Filter by recipient")
listed.add_argument("--limit", type=int, default=10, help="Max messages")
listed.add_argument("--mark-seen", action="store_true", help="Mark as seen")
read = sub.add_parser("read-email", help="Read one message by UID")
read.add_argument("--uid", required=True, help="Message UID")
read.add_argument("--folder", help="IMAP folder")
read.add_argument("--mark-seen", action="store_true", help="Mark as seen")
tmpl = sub.add_parser("send-template", help="Send a template mail")
tmpl.add_argument("--template", required=True, choices=sorted(list(item["key"] for item in list_templates())), help="Template key")
tmpl.add_argument("--to", nargs="+", required=True, help="Primary recipients")
tmpl.add_argument("--var", action="append", help="Template variable key=value")
tmpl.add_argument("--cc", nargs="+", help="CC recipients")
tmpl.add_argument("--bcc", nargs="+", help="BCC recipients")
tmpl.add_argument("--from-name", help="Display name of the sender")
sub.add_parser("templates", help="List template keys")
return parser
def _to_markdown(command: str, result: dict[str, Any]) -> str:
if command == "test-connection":
lines = [
"# Mail Connection",
"",
f"- Address: {result.get('address', '')}",
f"- SMTP: {result.get('smtp', '')}",
f"- IMAP: {result.get('imap', '')}",
f"- Ready: {result.get('ready', False)}",
]
return "\n".join(lines) + "\n"
if command == "templates":
templates = result.get("templates") or []
lines = ["# Templates", "", f"- Count: {result.get('count', 0)}"]
for item in templates:
lines.append(f"- {item.get('key', '')}: {item.get('title', '')}")
return "\n".join(lines) + "\n"
if command in {"send-email", "send-template"}:
lines = [
"# Mail Sent",
"",
f"- Status: {result.get('status', '')}",
f"- From: {result.get('from', '')}",
f"- To: {', '.join(result.get('to') or [])}",
f"- Subject: {result.get('subject', '')}",
]
if result.get("template"):
lines.append(f"- Template: {result.get('template')}")
if result.get("date"):
lines.append(f"- Date: {result.get('date')}")
if result.get("message_id"):
lines.append(f"- Message-ID: {result.get('message_id')}")
return "\n".join(lines) + "\n"
if command == "list-emails":
lines = [
"# Mail List",
"",
f"- Folder: {result.get('folder', '')}",
f"- Count: {result.get('count', 0)}",
]
for item in result.get("messages") or []:
lines.extend(
[
"",
f"## {item.get('subject', '(no subject)')}",
"",
f"- UID: {item.get('uid', '')}",
f"- From: {item.get('from', '')}",
f"- To: {item.get('to', '')}",
f"- Date: {item.get('date', '') or ''}",
f"- Seen: {item.get('seen', False)}",
f"- Attachments: {item.get('has_attachments', False)}",
f"- Snippet: {item.get('snippet', '')}",
]
)
return "\n".join(lines) + "\n"
if command == "read-email":
lines = [
"# Mail Detail",
"",
f"- UID: {result.get('uid', '')}",
f"- Subject: {result.get('subject', '')}",
f"- From: {result.get('from', '')}",
f"- To: {result.get('to', '')}",
f"- CC: {result.get('cc', '')}",
f"- Date: {result.get('date', '') or ''}",
f"- Message-ID: {result.get('message_id', '')}",
f"- Seen: {result.get('seen', False)}",
f"- Attachments: {result.get('has_attachments', False)}",
]
attachments = result.get("attachments") or []
if attachments:
lines.extend(["", "## Attachments", ""] + [f"- {item}" for item in attachments])
text_body = result.get("text_body") or ""
html_body = result.get("html_body") or ""
if text_body:
lines.extend(["", "## Text Body", "", text_body])
if html_body:
lines.extend(["", "## HTML Body", "", html_body])
return "\n".join(lines) + "\n"
return json.dumps(result, ensure_ascii=False, indent=2) + "\n"
def _emit(command: str, result: dict[str, Any], fmt: str) -> None:
if fmt == "md":
sys.stdout.write(_to_markdown(command, result))
else:
sys.stdout.write(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
def main(argv: list[str] | None = None) -> int:
raw_argv = sys.argv[1:] if argv is None else argv
cleaned_argv, env_file, output_format = _split_global_args(raw_argv)
args = _build_parser().parse_args(cleaned_argv)
_load_env_file(env_file or args.env_file)
try:
client = _client()
if args.command == "test-connection":
result = client.test_connection()
elif args.command == "send-email":
result = client.send_email(
to=args.to,
subject=args.subject,
text_body=args.text_body,
html_body=args.html_body,
cc=args.cc,
bcc=args.bcc,
from_name=args.from_name,
)
elif args.command == "list-emails":
result = client.list_emails(
folder=args.folder,
criteria=args.criteria,
limit=args.limit,
mark_seen=args.mark_seen,
subject=args.subject,
from_address=args.from_address,
to_address=args.to_address,
)
elif args.command == "read-email":
result = client.read_email(uid=args.uid, folder=args.folder, mark_seen=args.mark_seen)
elif args.command == "send-template":
variables = _parse_var_pairs(args.var)
subject, text_body, html_body = render_template(args.template, variables)
result = client.send_email(
to=args.to,
subject=subject,
text_body=text_body,
html_body=html_body,
cc=args.cc,
bcc=args.bcc,
from_name=args.from_name,
)
result["template"] = args.template
elif args.command == "templates":
template_items = list_templates()
result = {"count": len(template_items), "templates": template_items}
else:
sys.stderr.write(f"ERROR: unsupported command: {args.command}\n")
return 2
except (ConfigError, MailClientError, TemplateError, ValueError) as exc:
sys.stderr.write(f"ERROR: {exc}\n")
return 2
final_format = output_format or args.format
_emit(args.command, result, final_format)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
class ConfigError(ValueError):
pass
def _strip_quotes(value: str) -> str:
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
return value[1:-1]
return value
def _candidate_env_files() -> list[Path]:
candidates: list[Path] = []
explicit = os.getenv("MENGYA_MAIL_ENV_FILE")
if explicit:
candidates.append(Path(explicit).expanduser())
cwd_env = Path.cwd() / ".env"
package_env = Path(__file__).resolve().parents[2] / ".env"
candidates.extend([cwd_env, package_env])
unique: list[Path] = []
seen: set[Path] = set()
for candidate in candidates:
resolved = candidate.resolve(strict=False)
if resolved not in seen:
seen.add(resolved)
unique.append(candidate)
return unique
def _load_env_file(path: Path) -> None:
if not path.is_file():
return
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("export "):
line = line[7:].strip()
if "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = _strip_quotes(value.strip())
if key and key not in os.environ:
os.environ[key] = value
def load_env_if_present() -> None:
for candidate in _candidate_env_files():
_load_env_file(candidate)
@dataclass(frozen=True)
class MailConfig:
address: str
password: str
smtp_host: str = "smtp.qiye.aliyun.com"
smtp_port: int = 465
imap_host: str = "imap.qiye.aliyun.com"
imap_port: int = 993
default_folder: str = "INBOX"
timeout_seconds: float = 30.0
@classmethod
def from_env(cls) -> "MailConfig":
load_env_if_present()
address = os.getenv("MENGYA_MAIL_ADDRESS") or os.getenv("ALIYUN_MAIL_ADDRESS")
password = os.getenv("MENGYA_MAIL_PASSWORD") or os.getenv("ALIYUN_MAIL_PASSWORD")
if not address:
raise ConfigError("缺少环境变量 MENGYA_MAIL_ADDRESS")
if not password:
raise ConfigError("缺少环境变量 MENGYA_MAIL_PASSWORD")
return cls(
address=address,
password=password,
smtp_host=os.getenv("MENGYA_MAIL_SMTP_HOST", "smtp.qiye.aliyun.com"),
smtp_port=int(os.getenv("MENGYA_MAIL_SMTP_PORT", "465")),
imap_host=os.getenv("MENGYA_MAIL_IMAP_HOST", "imap.qiye.aliyun.com"),
imap_port=int(os.getenv("MENGYA_MAIL_IMAP_PORT", "993")),
default_folder=os.getenv("MENGYA_MAIL_DEFAULT_FOLDER", "INBOX"),
timeout_seconds=float(os.getenv("MENGYA_MAIL_TIMEOUT_SECONDS", "30")),
)

View File

@@ -0,0 +1,425 @@
from __future__ import annotations
import email
import imaplib
import json
import re
import shlex
import smtplib
from dataclasses import dataclass
from datetime import datetime
from email import policy
from email.header import decode_header, make_header
from email.message import EmailMessage, Message
from email.utils import formataddr, formatdate, make_msgid, parsedate_to_datetime
from html.parser import HTMLParser
from typing import Any
from .config import MailConfig
class MailClientError(RuntimeError):
pass
@dataclass
class MailSummary:
uid: str
subject: str
from_address: str
to: str
date: str | None
seen: bool
has_attachments: bool
snippet: str
def to_dict(self) -> dict[str, Any]:
return {
"uid": self.uid,
"subject": self.subject,
"from": self.from_address,
"to": self.to,
"date": self.date,
"seen": self.seen,
"has_attachments": self.has_attachments,
"snippet": self.snippet,
}
class _HTMLTextExtractor(HTMLParser):
def __init__(self) -> None:
super().__init__()
self._chunks: list[str] = []
def handle_data(self, data: str) -> None:
if data:
self._chunks.append(data)
def text(self) -> str:
return " ".join(chunk.strip() for chunk in self._chunks if chunk.strip())
def _decode_header_value(value: str | None) -> str:
if not value:
return ""
try:
return str(make_header(decode_header(value)))
except Exception:
return value
def _normalize_recipients(value: str | list[str] | tuple[str, ...] | None) -> list[str]:
if value is None:
return []
if isinstance(value, str):
items = [part.strip() for part in value.split(",")]
return [item for item in items if item]
return [item.strip() for item in value if item and item.strip()]
def _strip_html(html: str) -> str:
parser = _HTMLTextExtractor()
parser.feed(html)
parser.close()
return parser.text()
def _clean_text(text: str, limit: int | None = None) -> str:
normalized = re.sub(r"\s+", " ", text).strip()
if limit is not None and len(normalized) > limit:
return normalized[: limit - 1] + ""
return normalized
def _pick_text_part(message: Message) -> tuple[str, str]:
text_body = ""
html_body = ""
if message.is_multipart():
for part in message.walk():
disposition = part.get_content_disposition()
if disposition == "attachment":
continue
content_type = part.get_content_type()
payload = part.get_payload(decode=True)
if payload is None:
continue
charset = part.get_content_charset() or "utf-8"
try:
body = payload.decode(charset, errors="replace")
except LookupError:
body = payload.decode("utf-8", errors="replace")
if content_type == "text/plain" and not text_body:
text_body = body
elif content_type == "text/html" and not html_body:
html_body = body
else:
payload = message.get_payload(decode=True)
if payload is not None:
charset = message.get_content_charset() or "utf-8"
try:
text_body = payload.decode(charset, errors="replace")
except LookupError:
text_body = payload.decode("utf-8", errors="replace")
if not text_body and html_body:
text_body = _strip_html(html_body)
return text_body.strip(), html_body.strip()
def _has_attachments(message: Message) -> bool:
for part in message.walk():
if part.get_content_disposition() == "attachment":
return True
return False
def _attachment_names(message: Message) -> list[str]:
names: list[str] = []
for part in message.walk():
if part.get_content_disposition() == "attachment":
filename = part.get_filename()
if filename:
names.append(_decode_header_value(filename))
return names
def _parse_date(value: str | None) -> str | None:
if not value:
return None
try:
dt = parsedate_to_datetime(value)
if isinstance(dt, datetime):
return dt.isoformat()
except Exception:
return value
return value
def _contains(haystack: str, needle: str | None) -> bool:
if not needle:
return True
return needle.casefold() in haystack.casefold()
class MailClient:
def __init__(self, config: MailConfig) -> None:
self.config = config
def send_email(
self,
*,
to: str | list[str],
subject: str,
text_body: str | None = None,
html_body: str | None = None,
cc: str | list[str] | None = None,
bcc: str | list[str] | None = None,
from_name: str | None = None,
) -> dict[str, Any]:
to_list = _normalize_recipients(to)
cc_list = _normalize_recipients(cc)
bcc_list = _normalize_recipients(bcc)
all_recipients = to_list + cc_list + bcc_list
if not to_list:
raise MailClientError("至少需要一个主收件人")
if not all_recipients:
raise MailClientError("至少需要一个收件人")
if not text_body and not html_body:
raise MailClientError("text_body 与 html_body 至少提供一个")
message = EmailMessage()
message["From"] = formataddr((from_name, self.config.address)) if from_name else self.config.address
message["To"] = ", ".join(to_list)
if cc_list:
message["Cc"] = ", ".join(cc_list)
message["Subject"] = subject
message["Date"] = formatdate(localtime=True)
message["Message-ID"] = make_msgid(domain=self.config.address.split("@", 1)[-1])
if text_body:
message.set_content(text_body)
elif html_body:
message.set_content(_strip_html(html_body) or "请查看 HTML 正文")
if html_body:
message.add_alternative(html_body, subtype="html")
try:
with smtplib.SMTP_SSL(
self.config.smtp_host,
self.config.smtp_port,
timeout=self.config.timeout_seconds,
) as smtp:
smtp.login(self.config.address, self.config.password)
smtp.send_message(message, from_addr=self.config.address, to_addrs=all_recipients)
except Exception as exc:
raise MailClientError(f"SMTP 发送失败: {exc}") from exc
return {
"status": "sent",
"from": message["From"],
"to": to_list,
"cc": cc_list,
"bcc": bcc_list,
"subject": subject,
"date": _parse_date(message["Date"]),
"message_id": message["Message-ID"],
}
def test_connection(self) -> dict[str, Any]:
smtp_status = "ok"
imap_status = "ok"
try:
with smtplib.SMTP_SSL(
self.config.smtp_host,
self.config.smtp_port,
timeout=self.config.timeout_seconds,
) as smtp:
smtp.login(self.config.address, self.config.password)
except Exception as exc:
smtp_status = f"failed: {exc}"
try:
with imaplib.IMAP4_SSL(
self.config.imap_host,
self.config.imap_port,
timeout=self.config.timeout_seconds,
) as imap:
imap.login(self.config.address, self.config.password)
except Exception as exc:
imap_status = f"failed: {exc}"
return {
"address": self.config.address,
"smtp": smtp_status,
"imap": imap_status,
"ready": smtp_status == "ok" and imap_status == "ok",
}
def list_emails(
self,
*,
folder: str | None = None,
criteria: str | list[str] | None = None,
limit: int = 10,
mark_seen: bool = False,
subject: str | None = None,
from_address: str | None = None,
to_address: str | None = None,
) -> dict[str, Any]:
selected_folder = folder or self.config.default_folder
if limit < 1:
raise MailClientError("limit 必须大于 0")
use_client_side_filters = any([subject, from_address, to_address])
base_search_terms = self._build_base_search_terms(criteria=criteria, use_all=use_client_side_filters)
try:
with imaplib.IMAP4_SSL(
self.config.imap_host,
self.config.imap_port,
timeout=self.config.timeout_seconds,
) as imap:
imap.login(self.config.address, self.config.password)
status, _ = imap.select(selected_folder, readonly=not mark_seen)
if status != "OK":
raise MailClientError(f"无法选择文件夹 {selected_folder}")
status, data = imap.uid("search", None, *base_search_terms)
if status != "OK":
raise MailClientError(f"IMAP 搜索失败: {' '.join(base_search_terms)}")
uids = [uid.decode() for uid in data[0].split()] if data and data[0] else []
candidate_limit = max(limit * 10, 50) if use_client_side_filters else limit
selected_uids = list(reversed(uids[-candidate_limit:]))
messages: list[dict[str, Any]] = []
for uid in selected_uids:
summary = self._fetch_summary(imap, uid, mark_seen=mark_seen)
if use_client_side_filters and not self._matches_summary_filters(
summary,
subject=subject,
from_address=from_address,
to_address=to_address,
):
continue
messages.append(summary.to_dict())
if len(messages) >= limit:
break
except MailClientError:
raise
except Exception as exc:
raise MailClientError(f"IMAP 列取邮件失败: {exc}") from exc
return {
"folder": selected_folder,
"criteria": base_search_terms,
"count": len(messages),
"messages": messages,
}
def read_email(
self,
*,
uid: str,
folder: str | None = None,
mark_seen: bool = False,
) -> dict[str, Any]:
selected_folder = folder or self.config.default_folder
fetch_query = "(RFC822 FLAGS)" if mark_seen else "(BODY.PEEK[] FLAGS)"
try:
with imaplib.IMAP4_SSL(
self.config.imap_host,
self.config.imap_port,
timeout=self.config.timeout_seconds,
) as imap:
imap.login(self.config.address, self.config.password)
status, _ = imap.select(selected_folder, readonly=not mark_seen)
if status != "OK":
raise MailClientError(f"无法选择文件夹 {selected_folder}")
status, data = imap.uid("fetch", uid, fetch_query)
if status != "OK" or not data or data[0] is None:
raise MailClientError(f"找不到 UID={uid} 的邮件")
metadata = data[0][0].decode(errors="replace") if isinstance(data[0], tuple) else str(data[0])
raw_message = data[0][1] if isinstance(data[0], tuple) else b""
message = email.message_from_bytes(raw_message, policy=policy.default)
except MailClientError:
raise
except Exception as exc:
raise MailClientError(f"读取邮件失败: {exc}") from exc
text_body, html_body = _pick_text_part(message)
return {
"uid": uid,
"subject": _decode_header_value(message.get("Subject")),
"from": _decode_header_value(message.get("From")),
"to": _decode_header_value(message.get("To")),
"cc": _decode_header_value(message.get("Cc")),
"date": _parse_date(message.get("Date")),
"message_id": _decode_header_value(message.get("Message-ID")),
"seen": "\\Seen" in metadata,
"has_attachments": _has_attachments(message),
"attachments": _attachment_names(message),
"text_body": text_body[:20000],
"html_body": html_body[:20000],
}
def _fetch_summary(self, imap: imaplib.IMAP4_SSL, uid: str, *, mark_seen: bool) -> MailSummary:
fetch_query = "(RFC822 FLAGS)" if mark_seen else "(BODY.PEEK[] FLAGS)"
status, data = imap.uid("fetch", uid, fetch_query)
if status != "OK" or not data or data[0] is None:
raise MailClientError(f"无法读取 UID={uid} 的邮件")
metadata = data[0][0].decode(errors="replace") if isinstance(data[0], tuple) else str(data[0])
raw_message = data[0][1] if isinstance(data[0], tuple) else b""
message = email.message_from_bytes(raw_message, policy=policy.default)
text_body, html_body = _pick_text_part(message)
snippet_source = text_body or _strip_html(html_body)
return MailSummary(
uid=uid,
subject=_decode_header_value(message.get("Subject")),
from_address=_decode_header_value(message.get("From")),
to=_decode_header_value(message.get("To")),
date=_parse_date(message.get("Date")),
seen="\\Seen" in metadata,
has_attachments=_has_attachments(message),
snippet=_clean_text(snippet_source, limit=160),
)
@staticmethod
def _build_base_search_terms(*, criteria: str | list[str] | None, use_all: bool) -> list[str]:
if criteria is None:
return ["ALL"] if use_all else ["UNSEEN"]
return MailClient._normalize_search_terms(criteria)
@staticmethod
def _matches_summary_filters(
summary: MailSummary,
*,
subject: str | None,
from_address: str | None,
to_address: str | None,
) -> bool:
return (
_contains(summary.subject, subject)
and _contains(summary.from_address, from_address)
and _contains(summary.to, to_address)
)
@staticmethod
def _normalize_search_terms(criteria: str | list[str] | None) -> list[str]:
if criteria is None:
return ["UNSEEN"]
if isinstance(criteria, str):
parsed = shlex.split(criteria)
return parsed or ["UNSEEN"]
if not criteria:
return ["UNSEEN"]
return [item for item in criteria if item]
def format_result(data: dict[str, Any]) -> str:
return json.dumps(data, ensure_ascii=False, indent=2)

View File

@@ -0,0 +1,170 @@
from __future__ import annotations
import os
from typing import Any
import uvicorn
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from . import __version__
from .config import ConfigError, MailConfig
from .email_client import MailClient, MailClientError
from .templates import TemplateError, list_templates as get_template_list, render_template
API_TOKEN_ENV = "MENGYA_MAIL_API_TOKEN"
DEFAULT_API_TOKEN = "shumengya520"
API_HOST_ENV = "MENGYA_MAIL_API_HOST"
API_PORT_ENV = "MENGYA_MAIL_API_PORT"
class SendEmailRequest(BaseModel):
to: str | list[str]
subject: str
text_body: str | None = None
html_body: str | None = None
cc: str | list[str] | None = None
bcc: str | list[str] | None = None
from_name: str | None = None
class ListEmailsRequest(BaseModel):
folder: str | None = None
criteria: str | list[str] | None = None
subject: str | None = None
from_address: str | None = None
to_address: str | None = None
limit: int | None = None
mark_seen: bool = False
class ReadEmailRequest(BaseModel):
uid: str
folder: str | None = None
mark_seen: bool = False
class SendTemplateRequest(BaseModel):
template: str
to: str | list[str]
variables: dict[str, str] | None = None
cc: str | list[str] | None = None
bcc: str | list[str] | None = None
from_name: str | None = None
def _get_api_token() -> str:
return os.getenv(API_TOKEN_ENV, DEFAULT_API_TOKEN)
def _extract_token(request: Request) -> str | None:
auth_header = request.headers.get("authorization", "")
if auth_header.lower().startswith("bearer "):
return auth_header[7:].strip() or None
return request.headers.get("x-auth-token")
def require_token(request: Request) -> None:
expected = _get_api_token()
provided = _extract_token(request)
if not provided or provided != expected:
raise HTTPException(status_code=401, detail="Unauthorized")
def create_app(mail_client: MailClient | None = None) -> FastAPI:
app = FastAPI(
title="萌芽邮箱 API",
version=__version__,
docs_url=None,
redoc_url=None,
)
client = mail_client or MailClient(MailConfig.from_env())
@app.exception_handler(ConfigError)
async def handle_config_error(_: Request, exc: ConfigError) -> JSONResponse:
return JSONResponse({"error": str(exc)}, status_code=400)
@app.exception_handler(MailClientError)
async def handle_mail_error(_: Request, exc: MailClientError) -> JSONResponse:
return JSONResponse({"error": str(exc)}, status_code=400)
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/api/send-email")
async def send_email(
payload: SendEmailRequest,
_: None = Depends(require_token),
) -> dict[str, Any]:
result = client.send_email(
**payload.model_dump(exclude_none=True)
)
return result
@app.post("/api/list-emails")
async def list_emails(
payload: ListEmailsRequest,
_: None = Depends(require_token),
) -> dict[str, Any]:
result = client.list_emails(
**payload.model_dump(exclude_none=True)
)
return result
@app.post("/api/read-email")
async def read_email(
payload: ReadEmailRequest,
_: None = Depends(require_token),
) -> dict[str, Any]:
result = client.read_email(
**payload.model_dump(exclude_none=True)
)
return result
@app.get("/api/test-connection")
async def test_connection(_: None = Depends(require_token)) -> dict[str, Any]:
return client.test_connection()
@app.get("/api/templates")
async def list_templates(_: None = Depends(require_token)) -> dict[str, Any]:
templates = get_template_list()
return {"count": len(templates), "templates": templates}
@app.post("/api/send-template")
async def send_template(
payload: SendTemplateRequest,
_: None = Depends(require_token),
) -> dict[str, Any]:
try:
subject, text_body, html_body = render_template(payload.template, payload.variables)
except TemplateError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
result = client.send_email(
to=payload.to,
subject=subject,
text_body=text_body,
html_body=html_body,
cc=payload.cc,
bcc=payload.bcc,
from_name=payload.from_name,
)
result["template"] = payload.template
return result
return app
app = create_app()
def main() -> None:
host = os.getenv(API_HOST_ENV, "0.0.0.0")
port = int(os.getenv(API_PORT_ENV, "8080"))
uvicorn.run("mengya_mail_api.http_server:app", host=host, port=port)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,77 @@
from __future__ import annotations
import os
from pathlib import Path
DEFAULT_TEMPLATE_VARS = {
"name": "朋友",
"sender": "树萌芽",
}
TEMPLATES: dict[str, dict[str, str]] = {
"birthday": {
"title": "生日祝福",
"subject": "生日快乐,{name}",
"text": (
"亲爱的{name}\n\n"
"祝你生日快乐,愿新的一岁平安顺遂,心想事成!\n\n"
"{sender}"
),
"html_file": "birthday.html",
},
"new_year": {
"title": "元旦祝福",
"subject": "元旦快乐,{name}",
"text": (
"亲爱的{name}\n\n"
"新年伊始,愿你元旦快乐,万事顺遂,心想事成!\n\n"
"{sender}"
),
"html_file": "new_year.html",
},
}
class TemplateError(ValueError):
pass
def template_dir() -> Path:
env_dir = os.getenv("MENGYA_MAIL_TEMPLATE_DIR")
if env_dir:
return Path(env_dir).expanduser()
return Path(__file__).resolve().parents[2] / "template"
def read_template_file(filename: str) -> str:
path = template_dir() / filename
if not path.is_file():
raise TemplateError(f"Template file not found: {path}")
return path.read_text(encoding="utf-8")
def render_template(template_key: str, variables: dict[str, str] | None = None) -> tuple[str, str, str | None]:
template = TEMPLATES.get(template_key)
if not template:
raise TemplateError(f"Unknown template: {template_key}")
merged = {**DEFAULT_TEMPLATE_VARS, **(variables or {})}
try:
subject = template["subject"].format(**merged)
text_body = template["text"].format(**merged)
html_body = None
html_file = template.get("html_file")
if html_file:
html_body = read_template_file(html_file).format(**merged)
except KeyError as exc:
missing = exc.args[0] if exc.args else "unknown"
raise TemplateError(f"Missing template variable: {missing}") from exc
return subject, text_body, html_body
def list_templates() -> list[dict[str, str]]:
return [
{"key": key, "title": value.get("title", key)}
for key, value in TEMPLATES.items()
]