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:
3
lib/vendor/mengya-mail-api/src/mengya_mail_api/__init__.py
vendored
Normal file
3
lib/vendor/mengya-mail-api/src/mengya_mail_api/__init__.py
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
7
lib/vendor/mengya-mail-api/src/mengya_mail_api/__main__.py
vendored
Normal file
7
lib/vendor/mengya-mail-api/src/mengya_mail_api/__main__.py
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
262
lib/vendor/mengya-mail-api/src/mengya_mail_api/cli.py
vendored
Normal file
262
lib/vendor/mengya-mail-api/src/mengya_mail_api/cli.py
vendored
Normal 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())
|
||||
95
lib/vendor/mengya-mail-api/src/mengya_mail_api/config.py
vendored
Normal file
95
lib/vendor/mengya-mail-api/src/mengya_mail_api/config.py
vendored
Normal 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")),
|
||||
)
|
||||
425
lib/vendor/mengya-mail-api/src/mengya_mail_api/email_client.py
vendored
Normal file
425
lib/vendor/mengya-mail-api/src/mengya_mail_api/email_client.py
vendored
Normal 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)
|
||||
170
lib/vendor/mengya-mail-api/src/mengya_mail_api/http_server.py
vendored
Normal file
170
lib/vendor/mengya-mail-api/src/mengya_mail_api/http_server.py
vendored
Normal 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()
|
||||
77
lib/vendor/mengya-mail-api/src/mengya_mail_api/templates.py
vendored
Normal file
77
lib/vendor/mengya-mail-api/src/mengya_mail_api/templates.py
vendored
Normal 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()
|
||||
]
|
||||
Reference in New Issue
Block a user