Skip to main content
Glama
achenglike

email-notify

by achenglike
README.md
# email-notify

> 基于 Docker 的轻量邮件发送微服务。**同时对外提供两种调用方式**:
> 1. **REST HTTP 接口**(给传统应用)
> 2. **MCP(Model Context Protocol)服务**(给 AI 智能体,外部 AI 远端连 Streamable HTTP / 本地 AI 用 stdio)
>
> 底层 SMTP 凭据通过环境变量注入,两种接口共用同一份发送逻辑。

---

## 特性

- **双接口单进程**:一个 Starlette + uvicorn 进程同时承载 REST 与 MCP,复用 `mailer.py`
- **低内存**:MCP 用 `stateless_http=True`,会话内存随连接数零增长;`mem_limit: 96m` 兜底
- **轻镜像**:基于 `python:3.12-alpine`,非 root 用户运行、文件系统 `read_only`
- **双传输**:MCP 同时支持 Streamable HTTP(远端 AI)和 stdio(本地 AI,如 Claude Desktop)
- **统一鉴权**:单一 `API_KEY` 同时保护 `/api/send` 与 `/mcp`,`/healthz` 放行
- **生产可用**:`restart: unless-stopped`、日志轮转、内置 healthcheck

---

## 目录结构

```
email-notify/
├── sendmail.py              # 原始脚本(保留,向后兼容,可独立运行)
├── app/
│   ├── __init__.py
│   ├── server.py            # Starlette: REST 路由 + MCP 挂载 + Bearer 中间件
│   ├── mcp_tools.py         # FastMCP 服务 + send_email 工具定义
│   ├── mcp_stdio.py         # stdio 传输入口(本地 AI 用)
│   ├── mailer.py            # SMTP 发送逻辑(REST 与 MCP 共用,env 驱动)
│   └── auth.py              # Bearer Token 校验
├── requirements.txt         # mcp[cli] + uvicorn(版本钉死)
├── Dockerfile               # python:3.12-alpine,uvicorn 启动
├── docker-compose.yml       # 资源限制 + 健康检查 + 日志轮转
├── .env.example
└── README.md
```

---

## 快速开始

### 1. 准备配置

```bash
cp .env.example .env
# 生成一个 32 字节随机 API_KEY
openssl rand -hex 32
```

编辑 `.env`,填入真实 SMTP 配置和生成的 `API_KEY`:

```dotenv
SMTP_SERVER=smtp.163.com
SMTP_PORT=25
SENDER_MAIL=xxx@163.com
SENDER_PW=xxx
API_KEY=<上面 openssl 生成的串>
```

### 2. 构建并启动

```bash
docker compose up -d --build
```

### 3. 验证

```bash
# 健康检查(无需鉴权)
curl http://localhost:8000/healthz
# {"status":"ok"}
```

---

## 接口 1:REST HTTP

### `POST /api/send` — 发送邮件

**请求头**

| 名称 | 必填 | 说明 |
|---|---|---|
| `Authorization` | 是 | `Bearer <API_KEY>` |
| `Content-Type` | 是 | `application/json` |

**请求体**

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `recipients` | `string[]` | 是 | 收件人数组,元素需含 `@`,非空 |
| `subject` | `string` | 是 | 主题,非空 |
| `message_body` | `string` | 是 | HTML 正文,非空 |

**响应**

| 状态码 | 响应体 | 含义 |
|---|---|---|
| `200` | `{"status":"sent","recipients":[...]}` | 发送成功 |
| `400` | `{"error":"<原因>"}` | 参数校验失败 / 非 JSON 体 |
| `401` | `{"error":"unauthorized"}` | Token 缺失或错误 |
| `500` | `{"error":"server_misconfigured",...}` | SMTP 环境变量未配置 |
| `502` | `{"error":"smtp_failed",...}` | SMTP 连接 / 认证 / 投递失败 |

**示例**

```bash
curl -X POST http://localhost:8000/api/send \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "recipients": ["dev@example.com"],
    "subject": "测试邮件",
    "message_body": "<h1>Hello</h1><p>from email-notify</p>"
  }'
```

### `GET /healthz` — 健康检查

无需鉴权,返回 `{"status":"ok"}`,**不**发起 SMTP 连接。

---

## 接口 2:MCP(给 AI 智能体)

服务对外暴露一个 MCP 工具:

| 工具 | 入参 | 返回 |
|---|---|---|
| `send_email` | `recipients: string[]`, `subject: string`, `message_body: string` | `{"status":"sent","recipients":[...]}` |

AI 客户端有两种连法。

### 方式 A:Streamable HTTP(远端 AI 推荐)

MCP 端点:`http://<host>:8000/mcp`

调用时在 HTTP 头里带上 `Authorization: Bearer <API_KEY>`(与 REST 接口共用同一个 token)。

> ⚠️ **远程访问必须配置 Host 白名单**:SDK 默认只允许本机(`127.0.0.1`/`localhost`)连接。外部 AI 远程连入时,需在 `.env` 里设置 `MCP_ALLOWED_HOSTS`,否则会得到 `421 Invalid Host header`。两种取值:
> - **指定主机**:`MCP_ALLOWED_HOSTS=notify.example.com:*,10.0.0.5:*`(推荐,支持 `host:*` 通配端口)
> - **全放开**:`MCP_ALLOWED_HOSTS=*`(关闭 Host 校验,仅靠 Bearer Token 鉴权,适合 Docker / 反代 / 内网)
>
> 详见下方[环境变量](#环境变量);遇到 421 可参考"常见问题"一节。

在支持自定义 HTTP Header 的 MCP 客户端里,把 `authentication` 配成 Bearer Token 即可。例如用官方 SDK 写客户端:

```python
import asyncio
from mcp.client.session import ClientSession
from mcp.client.streamable_http import streamablehttp_client

async def main():
    async with streamablehttp_client(
        "http://localhost:8000/mcp",
        headers={"Authorization": "Bearer <API_KEY>"},
    ) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()
            result = await session.call_tool("send_email", {
                "recipients": ["dev@example.com"],
                "subject": "线上告警",
                "message_body": "<h1>CPU 超过 90%</h1>",
            })
            print(result.structuredContent)

asyncio.run(main())
```

> 用 MCP Inspector 快速调试:`npx -y @modelcontextprotocol/inspector`,填入 URL `http://localhost:8000/mcp` 与 Bearer Token。

### 方式 B:stdio(本地 AI,如 Claude Desktop)

stdio 模式把服务作为子进程拉起,**无需鉴权**(靠进程隔离),所有 SMTP 配置从环境变量传入。

入口:`python -m app.mcp_stdio`

Claude Desktop 配置示例(`claude_desktop_config.json`):

```json
{
  "mcpServers": {
    "email-notify": {
      "command": "python",
      "args": ["-m", "app.mcp_stdio"],
      "env": {
        "SMTP_SERVER": "smtp.163.com",
        "SMTP_PORT": "25",
        "SENDER_MAIL": "xxx@163.com",
        "SENDER_PW": "xxx"
      }
    }
  }
}
```

> 也可直接用镜像跑:`command` 换成 `docker`,`args` 换成 `["run","-i","--rm","-e","SMTP_SERVER=...","<image>","python","-m","app.mcp_stdio"]`。

---

## 环境变量

| 变量 | 说明 | 示例 |
|---|---|---|
| `SMTP_SERVER` | SMTP 服务器地址 | `smtp.163.com` |
| `SMTP_PORT` | SMTP 端口 | `25`(STARTTLS)/ `587` / `465` |
| `SENDER_MAIL` | 发件人邮箱 | `foo@163.com` |
| `SENDER_PW` | 发件人密码 / 授权码(163/QQ 等需用授权码) | `ABCDXXXXXXXXXXXX` |
| `API_KEY` | REST 与 MCP 共用的 Bearer Token,建议 ≥ 32 字节 | `openssl rand -hex 32` |
| `MCP_ALLOWED_HOSTS` | MCP 允许的 Host 头白名单(逗号分隔,支持 `host:*`)。默认仅本机;外部访问需设置。设为 `*` 表示全放开(关闭 Host 校验,仅靠 Bearer Token 鉴权) | `notify.example.com:*,10.0.0.5:*` 或 `*` |

> **注意**:加密模式按 `SMTP_PORT` 自动选择——`465`/`994` 走隐式 SSL(`SMTP_SSL`),`25`/`587` 走 STARTTLS。云服务器默认封出站 25 端口,建议用 `465`。

---

## 运维

### 常用命令

```bash
docker compose up -d --build      # 构建并后台启动
docker compose logs -f            # 跟踪日志
docker compose restart            # 重启
docker compose down               # 停止并删除容器
docker compose ps                 # 查看健康状态
```

### 资源占用

| 指标 | 典型值 | 上限 |
|---|---|---|
| 运行时内存 (RSS) | 50-70 MB | `mem_limit: 96m` |
| 镜像大小 | ~80 MB | - |
| 启动时间 | < 2s | - |
| MCP 会话内存增长 | 无 | 无(`stateless_http=True`) |

### 日志

- `json-file` 驱动,轮转 `10m × 3 份`(最多 30MB 落盘)
- uvicorn access log + 应用业务日志全部打到 stdout,由 docker 收集

---

## 调参与扩展

### 提升 REST 并发

uvicorn 默认单 worker。SMTP 是阻塞 I/O,已在 Starlette 里通过 `anyio.to_thread.run_sync` 丢到线程池,单进程即可并发处理多个发送。如需更高吞吐,把 Dockerfile CMD 的 `--workers` 改为 2(需同步调大 `mem_limit` 到 `160m`)。

### 新增 MCP 工具

在 `app/mcp_tools.py` 加一个 `@mcp.tool()` 函数即可,MCP 的 HTTP 与 stdio 两条链路自动获得新工具,无需改动 `server.py`。

---

## 安全说明

1. **`.env` 已在 `.gitignore` 中**,切勿提交真实凭据
2. **单一 Bearer Token**:`/api/send` 和 `/mcp` 都要校验;stdio 模式免鉴权(本地子进程)
3. **非 root 容器** + **只读文件系统**(`read_only: true` + `tmpfs: /tmp`)
4. **建议**:生产环境在前面加反向代理(nginx / traefik)做 TLS 终结和限流
5. **MCP HTTP 安全**:建议绑定内网或经反代暴露,不要直接公网无防护开放

---

## 常见问题

<details>
<summary><b>返回 <code>502 smtp_failed: [Errno 101] Network unreachable</code> 或发不出信</b></summary>

通常是 **IPv6 优先但无 IPv6 路由** + **云厂商封出站 25 端口**(腾讯云/阿里云/AWS 默认都封 25)叠加导致。解决:把 `SMTP_PORT` 改成 **465**(SSL,云厂商不封):

```dotenv
SMTP_PORT=465
```

代码已按端口自动选择模式:`465`/`994` 走隐式 SSL(`SMTP_SSL`),`25`/`587` 走 STARTTLS。无需改代码。

> 若 465 也连不通,可能是服务器缺 SSL 证书校验链或防火墙问题;可临时改 `.env` 用 587(STARTTLS)尝试。
</details>

<details>
<summary><b>返回 <code>502 smtp_failed: [SSL] WRONG_VERSION_NUMBER</code></b></summary>

端口和加密模式不匹配。`SMTP_PORT=465` 必须配合隐式 SSL(代码已自动处理);若你强行用 25 走 SSL 或 465 走 STARTTLS 会报此错。保持端口默认映射即可:465/994 → SSL,25/587 → STARTTLS。
</details>

<details>
<summary><b>返回 <code>502 smtp_failed: ... 535</code> 或 <code>Authentication required</code></b></summary>

`SENDER_PW` 应填邮箱服务商的**授权码**,不是登录密码。163/126/QQ/Gmail 等均需在邮箱后台单独生成。
</details>

<details>
<summary><b>MCP 客户端连 <code>/mcp</code> 报 401</b></summary>

MCP 端点与 REST 共用 `API_KEY`。确认客户端的 `Authorization: Bearer <API_KEY>` 头与 `.env` 里的值完全一致。
</details>

<details>
<summary><b>MCP 客户端连 <code>/mcp</code> 报 <code>421 Invalid Host header</code></b></summary>

SDK 默认开启 DNS rebinding 防护,只允许本机(`127.0.0.1`/`localhost`)连接。两种解决方式,在 `.env` 里设置 `MCP_ALLOWED_HOSTS`:

```dotenv
# 方式1:指定主机白名单(推荐,精确控制,支持 host:* 通配端口)
MCP_ALLOWED_HOSTS=notify.example.com:*
MCP_ALLOWED_HOSTS=notify.example.com:*,10.0.0.5:*

# 方式2:全放开(关闭 Host 校验,仅靠 Bearer Token 鉴权,适合 Docker / 反代 / 内网)
MCP_ALLOWED_HOSTS=*
```

改完 `docker compose up -d` 重启即可。注意方式1里的 Host 是客户端发来的 `Host:` 头值(通常是 `域名:端口`),不是 URL 路径。
</details>

<details>
<summary><b>容器启动后 OOM Killed</b></summary>

`mem_limit: 96m` 过低。检查 `docker compose logs`,临时调到 `128m` 排查。
</details>

<details>
<summary><b>原始脚本 <code>sendmail.py</code> 还能用吗?</b></summary>

可以。`sendmail.py` 保留未动,可独立 `python sendmail.py` 运行。新服务能力在 `app/` 包内,互不干扰。
</details>

---

## 设计取舍

| 决策 | 选择 | 理由 |
|---|---|---|
| Web 框架 | Starlette + uvicorn | 单一 ASGI 进程,能同时挂 MCP(async)与 REST;轻量 |
| MCP 传输 | Streamable HTTP + stdio | 远端 AI 用 HTTP,本地 AI 用 stdio;共用同一份 tool 定义 |
| MCP 模式 | `stateless_http=True` | 会话内存零增长,契合低内存诉求 |
| Worker 数 | 1 | 阻塞 SMTP 用线程池并发,单 worker 把内存压到最低 |
| 鉴权 | 单一 `API_KEY` | REST 与 MCP 共用,少一个 env 变量 |
| 健康检查 | 不连 SMTP | 防止探针触发外部连接 |
| 邮件连接 | 每次新建 | 低内存优先,避免常驻连接池 |

---

## License

MIT