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

5
lib/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""shumengya-cron共用库日志/锁、多语言调度、飞书通知、SSH 辅助)。"""
__all__ = ["__version__"]
__version__ = "0.1.0"

128
lib/cron_platform.py Normal file
View File

@@ -0,0 +1,128 @@
"""跨平台辅助文件锁、Windows 任务计划程序同步。"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
def is_windows() -> bool:
return sys.platform == "win32"
def lock_ex_nb(fd: int) -> None:
"""非阻塞独占锁;无法获取时抛出 BlockingIOError。"""
if is_windows():
import msvcrt
try:
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
except OSError as exc:
raise BlockingIOError from exc
return
import fcntl
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
def unlock(fd: int) -> None:
if is_windows():
import msvcrt
try:
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
except OSError:
pass
return
import fcntl
try:
fcntl.flock(fd, fcntl.LOCK_UN)
except OSError:
pass
def windows_task_name(task_id: str) -> str:
return f"sproutclaw-{task_id}"
def _cron_to_daily_time(expression: str) -> str | None:
"""将 `分 时 * * *` 转为 schtasks 的 HH:MM不支持则返回 None。"""
parts = expression.split()
if len(parts) != 5:
return None
minute, hour, dom, month, dow = parts
if dom != "*" or month != "*" or dow != "*":
return None
if not minute.isdigit() or not hour.isdigit():
return None
return f"{int(hour):02d}:{int(minute):02d}"
def sync_windows_task(
task_id: str,
cron_root: Path,
schedule_expression: str | None,
*,
enabled: bool,
) -> str | None:
"""在 Windows 任务计划程序中注册/更新定时任务。"""
if schedule_expression is None:
return "未找到 cron 表达式,跳过任务计划注册"
daily_time = _cron_to_daily_time(schedule_expression)
if daily_time is None:
return (
f"暂不支持 cron 表达式「{schedule_expression}」自动转换,"
"请手动创建任务计划或使用 `分 时 * * *` 形式"
)
python_exe = Path(sys.executable).resolve()
cronctl = (cron_root / "cronctl.py").resolve()
task_name = windows_task_name(task_id)
run_cmd = f'"{python_exe}" "{cronctl}" run {task_id}'
query = subprocess.run(
["schtasks", "/Query", "/TN", task_name],
capture_output=True,
text=True,
)
exists = query.returncode == 0
if exists:
subprocess.run(
["schtasks", "/Delete", "/TN", task_name, "/F"],
capture_output=True,
text=True,
check=False,
)
create = subprocess.run(
[
"schtasks",
"/Create",
"/TN",
task_name,
"/TR",
run_cmd,
"/SC",
"DAILY",
"/ST",
daily_time,
"/F",
],
capture_output=True,
text=True,
check=False,
)
if create.returncode != 0:
err = (create.stderr or create.stdout or "").strip()
return f"任务计划注册失败: {err or '未知错误'}"
state = "开启" if enabled else "关闭(计划仍触发,任务自动跳过)"
action = "已更新" if exists else "已创建"
return f"{action} Windows 任务「{task_name}」,每天 {daily_time}{state}"

91
lib/external_runner.py Normal file
View File

@@ -0,0 +1,91 @@
"""非 Python 任务:由 Python 编排 disable / 锁 / 日志subprocess 执行业务脚本。"""
from __future__ import annotations
import os
import shutil
import subprocess
from runner import (
TaskContext,
acquire_cron_lock,
append_raw_to_log,
task_is_disabled,
task_logging,
)
from task_manifest import TaskManifest
def _resolve_interpreter(runtime: str) -> list[str] | None:
if runtime == "javascript":
node = shutil.which("node")
return [node] if node else None
if runtime == "bash":
bash = shutil.which("bash")
return [bash] if bash else None
if runtime == "powershell":
pwsh = shutil.which("pwsh")
if pwsh:
return [pwsh, "-File"]
ps = shutil.which("powershell")
return [ps, "-File"] if ps else None
return None
def build_command(manifest: TaskManifest, ctx: TaskContext) -> list[str]:
interpreter = _resolve_interpreter(manifest.runtime)
if interpreter is None:
raise FileNotFoundError(f"未找到 {manifest.runtime} 解释器,请确认已安装并在 PATH 中")
entry = ctx.task_dir / manifest.entry
if manifest.runtime == "powershell":
return [*interpreter, str(entry)]
if manifest.runtime == "bash":
return [*interpreter, str(entry)]
return [*interpreter, str(entry)]
def _task_env(ctx: TaskContext) -> dict[str, str]:
env = os.environ.copy()
env["CRON_TASK_ID"] = ctx.task_id
env["CRON_TASK_DIR"] = str(ctx.task_dir)
env["CRON_ROOT"] = str(ctx.cron_root)
return env
def run_external_task(ctx: TaskContext, manifest: TaskManifest) -> int:
if task_is_disabled(ctx):
return 0
with task_logging(ctx) as log:
with acquire_cron_lock(ctx.lock_file, log=log) as locked:
if not locked:
return 0
log("start")
log(f"runtime={manifest.runtime} entry={manifest.entry}")
try:
cmd = build_command(manifest, ctx)
except FileNotFoundError as exc:
log(str(exc))
log("end")
return 1
proc = subprocess.run(
cmd,
cwd=str(ctx.task_dir),
env=_task_env(ctx),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
output = ((proc.stdout or "") + (proc.stderr or "")).strip()
if output:
append_raw_to_log(ctx.log_file, output)
if proc.returncode != 0:
log(f"exit={proc.returncode}")
log("end")
return proc.returncode

View File

@@ -2,30 +2,68 @@
from __future__ import annotations
import fcntl
import importlib.util
import os
import re
import shutil
from dataclasses import asdict, dataclass
from datetime import datetime
from pathlib import Path
from shumengya_cron.runner import (
from external_runner import run_external_task
from cron_platform import is_windows, lock_ex_nb, sync_windows_task, unlock
from runner import (
DISABLED_DIR_NAME,
LogMode,
TaskContext,
set_task_enabled,
task_is_enabled,
)
from task_manifest import (
effective_task_tags,
is_valid_task_dir,
load_task_manifest,
load_task_tags,
normalize_tags,
save_task_tags,
)
CRON_D = Path("/etc/cron.d")
_CRON_LINE = re.compile(
r"^(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+\S+\s+.+$",
)
_DESC_SKIP_PREFIX = ("开关",)
def _is_description_comment(text: str) -> bool:
if not text:
return False
if text.startswith(_DESC_SKIP_PREFIX):
return False
if "cronctl" in text:
return False
return True
def _validate_cron_schedule(schedule: str) -> str:
parts = schedule.strip().split()
if len(parts) != 5:
raise ValueError("cron 表达式须为 5 段:分 时 日 月 周")
return " ".join(parts)
def _replace_cron_expression(line: str, new_schedule: str) -> str:
stripped = line.strip()
if not _CRON_LINE.match(stripped):
return line
parts = stripped.split(None, 6)
if len(parts) < 7:
raise ValueError("schedule.cron 中的 cron 行格式无法识别")
return f"{new_schedule} {parts[5]} {parts[6]}"
def cron_root() -> Path:
return Path(__file__).resolve().parents[2]
return Path(__file__).resolve().parents[1]
def migrate_legacy_disabled_layout(root: Path | None = None) -> None:
@@ -55,13 +93,13 @@ def iter_task_ids(root: Path | None = None) -> list[str]:
if path.name.endswith(".disabled"):
task_ids.add(path.name.removesuffix(".disabled"))
continue
if (path / "run.py").is_file():
if is_valid_task_dir(path):
task_ids.add(path.name)
disabled_root = base / DISABLED_DIR_NAME
if disabled_root.is_dir():
for path in sorted(disabled_root.iterdir()):
if path.is_dir() and (path / "run.py").is_file():
if path.is_dir() and is_valid_task_dir(path):
task_ids.add(path.name)
return sorted(task_ids)
@@ -71,8 +109,8 @@ def get_context(task_id: str, root: Path | None = None) -> TaskContext:
ctx = TaskContext.from_task_id(task_id, cron_root=root)
if not ctx.task_dir.is_dir():
raise FileNotFoundError(f"任务不存在: {task_id}")
if not (ctx.task_dir / "run.py").is_file():
raise FileNotFoundError(f"任务目录缺少 run.py: {task_id}")
if not is_valid_task_dir(ctx.task_dir):
raise FileNotFoundError(f"任务目录缺少 schedule.cron 或入口脚本: {task_id}")
return ctx
@@ -89,7 +127,7 @@ def _parse_schedule(task_dir: Path) -> tuple[str | None, str | None]:
if line.startswith("#"):
if description is None:
text = line.lstrip("#").strip()
if text and not text.startswith("开关") and "cronctl" not in text:
if _is_description_comment(text):
description = text
continue
match = _CRON_LINE.match(line)
@@ -103,15 +141,16 @@ def task_is_running(ctx: TaskContext) -> bool:
lock_file = ctx.lock_file
if not lock_file.is_file():
return False
fd = lock_file.open("r")
fd = os.open(str(lock_file), os.O_RDWR | os.O_CREAT, 0o644)
try:
try:
fcntl.flock(fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
lock_ex_nb(fd)
return False
except BlockingIOError:
return True
finally:
fd.close()
unlock(fd)
os.close(fd)
@dataclass
@@ -124,6 +163,8 @@ class TaskInfo:
log_size: int
log_updated: str | None
task_dir: str
runtime: str
tags: list[str]
def to_dict(self) -> dict[str, object]:
return asdict(self)
@@ -138,6 +179,7 @@ def build_task_info(task_id: str, root: Path | None = None) -> TaskInfo:
stat = ctx.log_file.stat()
log_size = stat.st_size
log_updated = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S")
manifest = load_task_manifest(ctx.task_dir)
return TaskInfo(
task_id=task_id,
enabled=task_is_enabled(ctx),
@@ -147,6 +189,8 @@ def build_task_info(task_id: str, root: Path | None = None) -> TaskInfo:
log_size=log_size,
log_updated=log_updated,
task_dir=str(ctx.task_dir),
runtime=manifest.runtime,
tags=effective_task_tags(ctx.task_dir, manifest),
)
@@ -156,13 +200,24 @@ def list_tasks(root: Path | None = None) -> list[TaskInfo]:
def sync_cron_d(ctx: TaskContext, enabled: bool) -> str | None:
"""将 schedule.cron 安装到 /etc/cron.d/。返回提示信息或 None"""
cron_d_file = CRON_D / ctx.task_id
"""安装定时调度Linux 写入 /etc/cron.d/Windows 注册任务计划程序"""
schedule = ctx.task_dir / "schedule.cron"
if not schedule.is_file():
return "未找到 schedule.cron跳过安装"
expression, _ = _parse_schedule(ctx.task_dir)
if is_windows():
return sync_windows_task(
ctx.task_id,
ctx.cron_root,
expression,
enabled=enabled,
)
cron_d_file = CRON_D / ctx.task_id
shutil.copy2(schedule, cron_d_file)
state = "开启" if enabled else "关闭cron 仍触发,run.py 自动跳过)"
state = "开启" if enabled else "关闭cron 仍触发,任务自动跳过)"
return f"已同步到 {cron_d_file}{state}"
@@ -180,10 +235,14 @@ def toggle_task(task_id: str, root: Path | None = None) -> tuple[TaskInfo, str |
def run_task(task_id: str, root: Path | None = None) -> int:
"""动态加载任务 run.py调用其 run(ctx) 并返回退出码"""
"""执行任务Python 动态加载 run.py runtime 由 external_runner 启动"""
ctx = get_context(task_id, root)
run_py = ctx.task_dir / "run.py"
manifest = load_task_manifest(ctx.task_dir)
if manifest.runtime != "python":
return run_external_task(ctx, manifest)
run_py = ctx.task_dir / manifest.entry
spec = importlib.util.spec_from_file_location(f"cron_run_{task_id}", run_py)
mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type]
spec.loader.exec_module(mod) # type: ignore[union-attr]
@@ -202,3 +261,65 @@ def read_log_tail(task_id: str, lines: int = 200, root: Path | None = None) -> s
if lines <= 0 or len(parts) <= lines:
return content
return "\n".join(parts[-lines:]) + "\n"
def update_task_schedule(
task_id: str,
*,
description: str,
schedule: str,
tags: list[str] | None = None,
root: Path | None = None,
sync: bool = True,
) -> tuple[TaskInfo, str | None]:
"""更新 schedule.cron 中的描述注释、cron 表达式前五段,以及 task.json 标签。"""
ctx = get_context(task_id, root)
schedule_file = ctx.task_dir / "schedule.cron"
if not schedule_file.is_file():
raise FileNotFoundError(f"未找到 schedule.cron: {task_id}")
new_schedule = _validate_cron_schedule(schedule)
desc_text = description.strip()
raw_lines = schedule_file.read_text(encoding="utf-8").splitlines()
desc_updated = False
cron_updated = False
new_lines: list[str] = []
for line in raw_lines:
stripped = line.strip()
if not desc_updated and stripped.startswith("#"):
text = stripped.lstrip("#").strip()
if _is_description_comment(text):
new_lines.append(f"# {desc_text}" if desc_text else "#")
desc_updated = True
continue
if _CRON_LINE.match(stripped):
new_lines.append(_replace_cron_expression(line, new_schedule))
cron_updated = True
continue
new_lines.append(line)
if not cron_updated:
raise ValueError("schedule.cron 中未找到 cron 调度行")
if not desc_updated and desc_text:
inserted: list[str] = []
for line in new_lines:
if _CRON_LINE.match(line.strip()) and (not inserted or inserted[-1] != ""):
inserted.append(f"# {desc_text}")
inserted.append("")
inserted.append(line)
new_lines = inserted
schedule_file.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
if tags is not None:
save_task_tags(ctx.task_dir, normalize_tags(tags))
message = None
if sync:
ctx = get_context(task_id, root)
message = sync_cron_d(ctx, task_is_enabled(ctx))
return build_task_info(task_id, root), message

View File

@@ -1,23 +1,35 @@
"""
飞书 / Lark Markdown 通知 + 任务结果汇总
内置 lark-notice-api mengya-mail-api无需外部 subprocess
"""
from __future__ import annotations
import os
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Callable
if TYPE_CHECKING:
from shumengya_cron.runner import TaskContext
from runner import TaskContext
CRON_FEISHU_WEBHOOK_DEFAULT = (
"https://open.feishu.cn/open-apis/bot/v2/hook/1b13f977-f848-4a42-8e43-1b16ad592a34"
)
CRON_LARK_NOTICE_API_SRC_DEFAULT = "/shumengya/project/python/lark-notice-api/src"
_VENDOR_ROOT = Path(__file__).resolve().parent / "vendor"
_LARK_SRC = _VENDOR_ROOT / "lark-notice-api" / "src"
_MAIL_SRC = _VENDOR_ROOT / "mengya-mail-api" / "src"
_MAIL_ENV_DEFAULT = _VENDOR_ROOT / "mengya-mail-api" / ".env"
def _ensure_vendor_imports() -> None:
for src in (_LARK_SRC, _MAIL_SRC):
text = str(src)
if src.is_dir() and text not in sys.path:
sys.path.insert(0, text)
@dataclass
@@ -76,11 +88,8 @@ def send_task_summary(
log: Callable[[str], None],
extra_fields: list[tuple[str, object]] | None = None,
) -> None:
"""发送标准任务汇总通知(飞书 + 邮件降级)。
extra_fields 会插入在 结束时间 总数 之间适合放主机名目标路径等任务特有字段
"""
from shumengya_cron.runner import task_output_prefix
"""发送标准任务汇总通知(飞书 + 邮件降级)。"""
from runner import task_output_prefix
end_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
subject = f"{task_output_prefix(ctx.task_id)} {result.status_cn} {end_time}"
@@ -114,95 +123,61 @@ def send_feishu_markdown(
*,
log: Callable[[str], None],
) -> None:
"""发送飞书 Markdown失败时自动降级为邮件通知,邮件也失败则结束)。"""
"""发送飞书 Markdown失败时自动降级为邮件通知"""
if not title or not markdown:
log("飞书通知内容为空,跳过。")
return
webhook = os.environ.get("LARK_NOTICE_WEBHOOK", CRON_FEISHU_WEBHOOK_DEFAULT)
api_src = os.environ.get("LARK_NOTICE_API_SRC", CRON_LARK_NOTICE_API_SRC_DEFAULT)
if not webhook:
log("未配置飞书 webhook跳过飞书通知。")
return
env = os.environ.copy()
pp = api_src
if env.get("PYTHONPATH"):
pp = f"{api_src}:{env['PYTHONPATH']}"
env["PYTHONPATH"] = pp
_ensure_vendor_imports()
from lark_notice_api.client import LarkNoticeError, LarkWebhookClient
feishu_ok = False
try:
r = subprocess.run(
[
sys.executable,
"-m",
"lark_notice_api",
"--webhook",
webhook,
"send-markdown",
"--title",
title,
"--markdown",
markdown,
],
cwd=api_src if os.path.isdir(api_src) else None,
env=env,
check=False,
capture_output=True,
text=True,
)
if r.returncode == 0:
feishu_ok = True
else:
detail = (r.stderr or r.stdout or "").strip()
if detail:
log(f"发送飞书通知失败(退出码 {r.returncode}{detail}")
else:
log(f"发送飞书通知失败(退出码 {r.returncode})。")
except OSError:
log("发送飞书通知失败lark-notice-api 调用异常。")
if feishu_ok:
LarkWebhookClient(webhook).send_markdown(title, markdown)
return
except LarkNoticeError as exc:
log(f"发送飞书通知失败:{exc}")
except ImportError:
log("发送飞书通知失败:内置 lark-notice-api 不可用。")
log("飞书通知失败,尝试通过邮件发送…")
_send_mail_fallback(title, markdown, log)
# ── 邮件降级 ──────────────────────────────────────────────
_MAIL_SCRIPT_DEFAULT = "/shumengya/project/skills/mengya-mail-skills/scripts/mengya-mail-api.py"
_MAIL_ENV_FILE_DEFAULT = "/shumengya/project/python/mengya-mail-api/.env"
def _send_mail_fallback(title: str, markdown: str, log: Callable[[str], None]) -> None:
"""通过 mengya-mail-api 发送报告邮件(失败仅记日志,不再继续降级)。"""
"""通过内置 mengya-mail-api 发送邮件(失败仅记日志)。"""
enabled = os.environ.get("CRON_MAIL_ENABLED", "0")
if enabled != "1":
log("邮件通知未开启export CRON_MAIL_ENABLED=1 可启用),跳过。")
return
script = os.environ.get("MAIL_API_SCRIPT", _MAIL_SCRIPT_DEFAULT)
env_file = os.environ.get("MAIL_API_ENV_FILE", _MAIL_ENV_FILE_DEFAULT)
env_file = os.environ.get("MAIL_API_ENV_FILE", str(_MAIL_ENV_DEFAULT))
if env_file and os.path.isfile(env_file):
os.environ.setdefault("MENGYA_MAIL_ENV_FILE", env_file)
mail_to = os.environ.get("MAIL_TO", "mail@smyhub.com")
from_name = os.environ.get("MAIL_FROM_NAME", "cron")
if not os.path.isfile(script):
log(f"邮件脚本不存在:{script},跳过邮件通知。")
return
_ensure_vendor_imports()
from mengya_mail_api.config import ConfigError, MailConfig
from mengya_mail_api.email_client import MailClient, MailClientError
try:
r = subprocess.run(
[sys.executable, script, "--env-file", env_file, "--format", "json",
"send-email", "--to", mail_to, "--subject", title,
"--from-name", from_name, "--html-body", markdown],
capture_output=True, text=True, check=False,
config = MailConfig.from_env()
MailClient(config).send_email(
to=mail_to,
subject=title,
html_body=markdown,
from_name=from_name,
)
if r.returncode == 0:
log("邮件通知发送成功。")
else:
detail = (r.stderr or r.stdout or "").strip()
log(f"邮件通知发送失败(退出码 {r.returncode}{detail or '无详细信息'}")
except OSError as exc:
log(f"邮件通知发送异常:{exc}")
log("邮件通知发送成功。")
except ConfigError as exc:
log(f"邮件配置错误:{exc}")
except MailClientError as exc:
log(f"邮件通知发送失败{exc}")
except ImportError:
log("邮件通知发送失败:内置 mengya-mail-api 不可用。")

View File

@@ -7,7 +7,6 @@
from __future__ import annotations
import fcntl
import os
import sys
from contextlib import contextmanager
@@ -17,6 +16,8 @@ from enum import Enum
from pathlib import Path
from typing import Callable, Iterator, TextIO
from cron_platform import lock_ex_nb, unlock
class LogMode(str, Enum):
"""日志模式:整段重定向到文件(多数任务)或直接写文件(如 AI CLI 更新)。"""
@@ -26,8 +27,8 @@ class LogMode(str, Enum):
def _cron_root() -> Path:
# .../lib/shumengya_cron/runner.py -> cron 根目录
return Path(__file__).resolve().parents[2]
# .../lib/runner.py -> cron 根目录
return Path(__file__).resolve().parents[1]
DISABLED_DIR_NAME = ".disabled"
@@ -184,7 +185,7 @@ def acquire_cron_lock(
) -> Iterator[bool]:
"""
获取互斥锁若已被占用则记录日志并 yield False调用方应 sys.exit(0)
使用 flock bash 版一致
Linux 使用 flockWindows 使用 msvcrt 文件锁
"""
msg = busy_message or os.environ.get(
"CRON_LOCK_BUSY_MSG", "已有同名任务在运行,跳过本次。"
@@ -193,17 +194,14 @@ def acquire_cron_lock(
fd = os.open(str(lock_file), os.O_RDWR | os.O_CREAT, 0o644)
try:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
lock_ex_nb(fd)
except BlockingIOError:
log(msg)
yield False
return
yield True
finally:
try:
fcntl.flock(fd, fcntl.LOCK_UN)
except OSError:
pass
unlock(fd)
os.close(fd)

114
lib/runtime_probe.py Normal file
View File

@@ -0,0 +1,114 @@
"""检测本机 Python / Node.js / Bash / PowerShell 是否可用及版本。"""
from __future__ import annotations
import re
import shutil
import subprocess
import sys
from dataclasses import asdict, dataclass
@dataclass
class RuntimeProbe:
id: str
name: str
available: bool
version: str | None = None
path: str | None = None
def to_dict(self) -> dict[str, object]:
return asdict(self)
def _first_line(text: str) -> str:
line = (text or "").strip().splitlines()[0].strip() if text else ""
return line
def _run(cmd: list[str], *, timeout: float = 8.0) -> tuple[int, str]:
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
)
out = _first_line((proc.stdout or "") + "\n" + (proc.stderr or ""))
return proc.returncode, out
except (OSError, subprocess.TimeoutExpired):
return 1, ""
def _probe_python() -> RuntimeProbe:
exe = sys.executable
code, out = _run([exe, "--version"])
version: str | None = None
if code == 0 and out:
version = out.removeprefix("Python ").strip() if out.lower().startswith("python") else out
elif sys.version:
version = sys.version.split()[0]
return RuntimeProbe(
id="python",
name="Python",
available=True,
version=version,
path=exe,
)
def _probe_nodejs() -> RuntimeProbe:
path = shutil.which("node")
if not path:
return RuntimeProbe(id="nodejs", name="JavaScript", available=False)
code, out = _run([path, "--version"])
if code != 0 or not out:
return RuntimeProbe(id="nodejs", name="JavaScript", available=False, path=path)
version = out.lstrip("vV").strip() or out
return RuntimeProbe(id="nodejs", name="JavaScript", available=True, version=version, path=path)
def _parse_bash_version(text: str) -> str | None:
match = re.search(r"version\s+([^\s(]+)", text, re.I)
return match.group(1) if match else (text or None)
def _probe_bash() -> RuntimeProbe:
path = shutil.which("bash")
if not path:
return RuntimeProbe(id="bash", name="Bash", available=False)
code, out = _run([path, "--version"])
if code != 0 or not out:
return RuntimeProbe(id="bash", name="Bash", available=False, path=path)
version = _parse_bash_version(out)
return RuntimeProbe(id="bash", name="Bash", available=True, version=version, path=path)
def _probe_powershell() -> RuntimeProbe:
for cmd_name in ("pwsh", "powershell"):
path = shutil.which(cmd_name)
if not path:
continue
code, out = _run(
[path, "-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"]
)
if code == 0 and out:
return RuntimeProbe(
id="powershell",
name="PowerShell",
available=True,
version=out.strip(),
path=path,
)
return RuntimeProbe(id="powershell", name="PowerShell", available=False)
def probe_all_runtimes() -> list[dict[str, object]]:
return [
_probe_python().to_dict(),
_probe_nodejs().to_dict(),
_probe_bash().to_dict(),
_probe_powershell().to_dict(),
]

View File

@@ -1,5 +0,0 @@
"""shumengya-cron共用库日志/锁、飞书通知、SSH 辅助)。各任务逻辑在任务目录 run.py。"""
__all__ = ["__version__"]
__version__ = "0.1.0"

165
lib/task_manifest.py Normal file
View File

@@ -0,0 +1,165 @@
"""任务清单 task.json 解析与 runtime 推断。"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
Runtime = Literal["python", "javascript", "bash", "powershell"]
RUNTIME_DEFAULT_ENTRY: dict[str, str] = {
"python": "run.py",
"javascript": "run.js",
"bash": "run.sh",
"powershell": "run.ps1",
}
RUNTIME_ALIASES: dict[str, Runtime] = {
"python": "python",
"py": "python",
"javascript": "javascript",
"js": "javascript",
"node": "javascript",
"bash": "bash",
"sh": "bash",
"shell": "bash",
"powershell": "powershell",
"ps1": "powershell",
"pwsh": "powershell",
}
ENTRY_TO_RUNTIME: dict[str, Runtime] = {
"run.py": "python",
"run.js": "javascript",
"run.sh": "bash",
"run.ps1": "powershell",
}
RUNTIME_LABEL: dict[str, str] = {
"python": "Python",
"javascript": "JavaScript",
"bash": "Bash",
"powershell": "PowerShell",
}
MAX_TASK_TAGS = 4
MAX_TAG_LENGTH = 32
@dataclass(frozen=True)
class TaskManifest:
runtime: Runtime
entry: str
@property
def entry_path(self) -> str:
return self.entry
@property
def label(self) -> str:
return RUNTIME_LABEL.get(self.runtime, self.runtime)
def _normalize_runtime(raw: str) -> Runtime | None:
key = raw.strip().lower()
return RUNTIME_ALIASES.get(key) # type: ignore[return-value]
def _infer_from_entry_files(task_dir: Path) -> TaskManifest | None:
for entry, runtime in ENTRY_TO_RUNTIME.items():
if (task_dir / entry).is_file():
return TaskManifest(runtime=runtime, entry=entry)
return None
def load_task_manifest(task_dir: Path) -> TaskManifest:
"""读取 task.json缺失时按入口文件推断默认 python + run.py。"""
manifest_file = task_dir / "task.json"
if manifest_file.is_file():
data = json.loads(manifest_file.read_text(encoding="utf-8"))
runtime_raw = str(data.get("runtime", "python"))
runtime = _normalize_runtime(runtime_raw)
if runtime is None:
raise ValueError(f"不支持的 runtime: {runtime_raw}")
entry = str(data.get("entry", RUNTIME_DEFAULT_ENTRY[runtime])).strip()
if not entry:
entry = RUNTIME_DEFAULT_ENTRY[runtime]
entry_path = task_dir / entry
if not entry_path.is_file():
raise FileNotFoundError(f"task.json 指定的入口不存在: {entry}")
return TaskManifest(runtime=runtime, entry=entry)
inferred = _infer_from_entry_files(task_dir)
if inferred is not None:
return inferred
if (task_dir / "run.py").is_file():
return TaskManifest(runtime="python", entry="run.py")
raise FileNotFoundError("未找到 task.json 或可识别的入口脚本")
def normalize_tags(raw: object) -> list[str]:
"""去重、去空白,最多 MAX_TASK_TAGS 个。"""
if not isinstance(raw, (list, tuple)):
return []
tags: list[str] = []
for item in raw:
s = str(item).strip()
if not s or len(s) > MAX_TAG_LENGTH:
continue
if s not in tags:
tags.append(s)
if len(tags) >= MAX_TASK_TAGS:
break
return tags
def load_task_tags(task_dir: Path) -> list[str]:
manifest_file = task_dir / "task.json"
if not manifest_file.is_file():
return []
try:
data = json.loads(manifest_file.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return []
return normalize_tags(data.get("tags", []))
def effective_task_tags(task_dir: Path, manifest: TaskManifest) -> list[str]:
"""已存标签优先;否则默认用运行时名称作为唯一标签。"""
stored = load_task_tags(task_dir)
if stored:
return stored
return [RUNTIME_LABEL.get(manifest.runtime, manifest.runtime)]
def save_task_tags(task_dir: Path, tags: list[str]) -> None:
"""写入 task.json 的 tags 字段;无文件时按当前 manifest 创建。"""
manifest = load_task_manifest(task_dir)
normalized = normalize_tags(tags)
manifest_file = task_dir / "task.json"
data: dict[str, object] = {}
if manifest_file.is_file():
data = json.loads(manifest_file.read_text(encoding="utf-8"))
data["runtime"] = manifest.runtime
data["entry"] = manifest.entry
data["tags"] = normalized
manifest_file.write_text(
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def is_valid_task_dir(task_dir: Path) -> bool:
try:
if not task_dir.is_dir():
return False
if not (task_dir / "schedule.cron").is_file():
return False
load_task_manifest(task_dir)
return True
except (OSError, ValueError, FileNotFoundError, json.JSONDecodeError):
return False

View File

@@ -0,0 +1,5 @@
from __future__ import annotations
__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,115 @@
from __future__ import annotations
import argparse
import json
import os
import sys
from typing import Any
from .client import LarkNoticeError, LarkWebhookClient
def _split_global_args(argv: list[str]) -> tuple[list[str], str | None, float | None]:
cleaned: list[str] = []
webhook: str | None = None
timeout: float | None = None
i = 0
while i < len(argv):
arg = argv[i]
if arg == "--webhook" and i + 1 < len(argv):
webhook = argv[i + 1]
i += 2
continue
if arg == "--timeout" and i + 1 < len(argv):
timeout = float(argv[i + 1])
i += 2
continue
cleaned.append(arg)
i += 1
return cleaned, webhook, timeout
def _client_from_args(args: argparse.Namespace) -> LarkWebhookClient:
webhook = args.webhook or os.getenv("LARK_NOTICE_WEBHOOK", "")
if not webhook:
raise LarkNoticeError("缺少 webhook请传 --webhook 或设置 LARK_NOTICE_WEBHOOK")
return LarkWebhookClient(webhook=webhook, timeout=args.timeout)
def _normalize_cli_markdown(markdown: str) -> str:
if "\n" in markdown or "\r" in markdown:
return markdown
return markdown.replace(r"\n", "\n").replace(r"\r", "\r")
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="lark-notice-api")
parser.add_argument("--webhook", help="Feishu webhook URL")
parser.add_argument("--timeout", type=float, default=10.0, help="HTTP timeout seconds")
sub = parser.add_subparsers(dest="command", required=True)
text = sub.add_parser("send-text", help="Send plain text message")
text.add_argument("--text", required=True)
post = sub.add_parser("send-post", help="Send Feishu post message")
post.add_argument("--title", required=True)
post.add_argument("--line", action="append", dest="lines", required=True)
md = sub.add_parser("send-markdown", help="Send interactive markdown card")
md.add_argument("--title", required=True)
md.add_argument("--markdown", required=True)
demo = sub.add_parser("demo", help="Send a markdown demo card")
demo.add_argument("--title", default="飞书消息示例")
demo.add_argument(
"--markdown",
default=(
"# 飞书 Markdown 通知测试\n"
"\n"
"这是一条 **JSON 2.0** 富文本消息。\n"
"\n"
"- 支持 *斜体*、**粗体**、~~删除线~~\n"
"- [打开示例网站](https://feishu.cn)\n"
"- 代码块示例:\n"
"\n"
"```json\n"
"{\"hello\": \"world\"}\n"
"```"
),
)
return parser
def _emit(result: dict[str, Any]) -> None:
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, webhook, timeout = _split_global_args(raw_argv)
args = _parser().parse_args(cleaned_argv)
if webhook is not None:
args.webhook = webhook
if timeout is not None:
args.timeout = timeout
try:
client = _client_from_args(args)
if args.command == "send-text":
result = client.send_text(args.text)
elif args.command == "send-post":
result = client.send_post(args.title, args.lines)
elif args.command == "send-markdown":
result = client.send_markdown(args.title, _normalize_cli_markdown(args.markdown))
elif args.command == "demo":
result = client.send_markdown(args.title, args.markdown)
else:
raise LarkNoticeError(f"未知命令: {args.command}")
except LarkNoticeError as exc:
sys.stderr.write(f"ERROR: {exc}\n")
return 2
_emit(result)
return 0

View File

@@ -0,0 +1,87 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
from urllib import error, request
class LarkNoticeError(RuntimeError):
pass
@dataclass(slots=True)
class LarkWebhookClient:
webhook: str
timeout: float = 10.0
def _post(self, payload: dict[str, Any]) -> dict[str, Any]:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = request.Request(
self.webhook,
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with request.urlopen(req, timeout=self.timeout) as resp:
body = resp.read().decode("utf-8", errors="replace")
except error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
raise LarkNoticeError(f"HTTP {exc.code}: {body or exc.reason}") from exc
except error.URLError as exc:
raise LarkNoticeError(f"网络错误: {exc.reason}") from exc
try:
result = json.loads(body)
except json.JSONDecodeError:
raise LarkNoticeError(f"响应不是 JSON: {body}") from None
if result.get("code") not in (0, None):
raise LarkNoticeError(
f"飞书返回错误: code={result.get('code')} msg={result.get('msg') or result.get('message')}"
)
return result
def send_text(self, text: str) -> dict[str, Any]:
return self._post({"msg_type": "text", "content": {"text": text}})
def send_post(self, title: str, lines: list[str]) -> dict[str, Any]:
content = [[{"tag": "text", "text": line}] for line in lines]
return self._post(
{
"msg_type": "post",
"content": {
"post": {
"zh-CN": {
"title": title,
"content": content,
}
}
},
}
)
@staticmethod
def _normalize_markdown(markdown: str) -> str:
return markdown.replace("\r\n", "\n").replace("\r", "\n")
def send_markdown(self, title: str, markdown: str) -> dict[str, Any]:
return self._post(
{
"msg_type": "interactive",
"card": {
"schema": "2.0",
"config": {"wide_screen_mode": True, "enable_forward": True},
"header": {"title": {"tag": "plain_text", "content": title}},
"body": {
"elements": [
{
"tag": "markdown",
"content": self._normalize_markdown(markdown),
}
]
},
},
}
)

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()
]