Frappe 事务性邮件 — CF Email Service 桥接
TL;DR
Frappe 系统邮件(密码重置/通知/任意发信)通过 Cloudflare Email Service REST API 发出,全程不经过 SendGrid/Mailgun 等第三方。宿主机 systemd 托管 SMTP-HTTP 桥接,所有 bench 容器共用,站点发信零人工。
现状:✅ 已跑通(gottenergy + demo2 均验证
200 OK,收件箱确认送达)
🎯 背景:为什么需要这个
- 需求:Frappe 系统邮件(密码重置、通知、任意业务邮件)能发给任意收件人
- 卡点:DigitalOcean 新账户默认封锁所有出站 SMTP 端口(25/465/587)。已提交工单,被拒。官方回复原文:
"Due to our current policy we are unable to unblock SMTP ports on new accounts" - 硬性约束:只用 Cloudflare 生态,不引入 SendGrid/Mailgun/MailChannels 等第三方邮件商。Frappe 只能走 SMTP 协议发信,CF Email Service 只提供 HTTP REST API
✅ 前置条件
跑这篇笔记之前,你手上应该已经有:
- 域名已在 Cloudflare 托管(
getgrowth.app) - Cloudflare Workers Paid($5/月)— 解锁”发信给任意收件人”,Free 计划只能发给账户内验证地址
- 域名已通过 Email Service → Email Sending → Onboard Domain(自动生成 MX/SPF/DKIM/DMARC DNS 记录)
- CF API Token:Dashboard → 个人资料 → API Tokens → 创建 Token → 权限
Account → Email Sending: Edit - 目标服务器的 SSH 访问权限(
root@45.55.93.224) - 服务器已有 Python 3.10+(Ubuntu 22.04 自带)
🏗️ 最终架构
Frappe 站点 (bench 容器内)
→ frappe.sendmail() → Email Queue
→ frappe.email.queue.flush()
→ SMTP 连接 172.17.0.1:2525 (Docker 网关 → 宿主机)
→ /opt/cf-smtp-bridge.py (aiosmtpd, systemd 托管)
→ HTTPS POST 443 → api.cloudflare.com/.../email/sending/send
→ 收件人邮箱
- 桥接跑在宿主机而非容器内 → bench 重建不丢失
common_site_config.json填172.17.0.1:2525→ 所有站点共用- 防火墙限制 2525 仅 Docker 子网可访问,公网不可达
- 比最初方案省了一步:不经过 CF Worker 中转,直连 CF REST API
🛠️ 部署步骤
关于这部分的阅读方式
每一步先给”抄了就能跑”的命令,命令下面折叠的 Callout 是详细解释。
第 1 步:宿主机环境准备
apt install -y python3.10-venv
useradd -r -s /usr/sbin/nologin cf-bridge
python3 -m venv /opt/cf-bridge-venv
/opt/cf-bridge-venv/bin/pip install aiosmtpd httpx这条命令在干嘛
python3.10-venv:Ubuntu 22.04 默认不带 venv 包,需手动安装cf-bridge用户:-r系统账号,-s /usr/sbin/nologin禁止 SSH 登录。桥接进程以这个用户跑,即使被攻破也无权限- 独立 venv 而非系统 pip:避免依赖冲突,
systemd通过ExecStart直接指向 venv 里的 Python
第 2 步:写入桥接脚本
在 Press VPS (104.236.24.124) 上写文件,再 scp 到目标:
cat > /tmp/cf-smtp-bridge.py << 'PYEOF'
import asyncio, os, logging, sys
from email import message_from_bytes
from aiosmtpd.controller import Controller
import httpx
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(message)s',
handlers=[logging.StreamHandler(sys.stdout)],
)
TOKEN = os.environ.get('CF_API_TOKEN', '')
ACCOUNT_ID = 'b92bfb0cd6516b40a98a95af77ccc6db'
API_URL = f'https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/email/sending/send'
if not TOKEN:
logging.error('CF_API_TOKEN 为空,请检查 EnvironmentFile')
sys.exit(1)
class Handler:
async def handle_DATA(self, server, session, envelope):
msg = message_from_bytes(envelope.content)
html, text = None, None
if msg.is_multipart():
for part in msg.walk():
ct = part.get_content_type()
pl = part.get_payload(decode=True)
if ct == 'text/html' and pl:
html = pl.decode(errors='ignore')
elif ct == 'text/plain' and pl and not html:
text = pl.decode(errors='ignore')
else:
body = msg.get_payload(decode=True)
text = body.decode(errors='ignore') if body else ''
payload = {
'from': envelope.mail_from,
'to': list(envelope.rcpt_tos),
'subject': msg.get('Subject', ''),
'html': html or '',
'text': text or '',
}
try:
async with httpx.AsyncClient() as client:
resp = await client.post(
API_URL,
headers={'Authorization': f'Bearer {TOKEN}',
'Content-Type': 'application/json'},
json=payload, timeout=15,
)
except Exception as e:
logging.error(f'请求异常: {e}')
return '451 Temporary failure, please retry'
if resp.status_code >= 300:
logging.error(f'CF 发信失败 {resp.status_code}: {resp.text[:300]}')
return '550 Failed to relay via Cloudflare'
logging.info(f'OK -> {envelope.rcpt_tos}: {msg.get("Subject", "")}')
return '250 OK'
controller = Controller(Handler(), hostname='0.0.0.0', port=2525)
controller.start()
logging.info('Bridge ready on 0.0.0.0:2525')
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_forever()
except KeyboardInterrupt:
pass
finally:
controller.stop()
loop.close()
PYEOF
# 传到目标 VPS
docker exec -i $(docker ps -qf "name=press-backend") ssh root@45.55.93.224 \
"cat > /opt/cf-smtp-bridge.py" < /tmp/cf-smtp-bridge.py这条命令在干嘛
- heredoc 内容太长不能直接嵌套在
docker exec → ssh → docker exec链中,终端会截断。先写到本地/tmp再传过去hostname='0.0.0.0':不能监听127.0.0.1,因为 bench 容器通过 Docker 网关连过来,来源 IP 是172.17.0.xasyncio.new_event_loop()+set_event_loop():Python 3.14 移除了get_event_loop()自动创建。宿主机 Python 3.10 不需要,但保留兼容- CF API payload 是扁平结构
{to, from, subject, html, text},不是嵌套的{from: {address}, content: [{type, value}]}
第 3 步:Token 文件 + systemd Service
# Token 文件
echo 'CF_API_TOKEN=<真实Token>' > /opt/cf-bridge.env
chown cf-bridge:cf-bridge /opt/cf-bridge.env
chmod 600 /opt/cf-bridge.env
# systemd service
cat > /etc/systemd/system/cf-smtp-bridge.service << 'SVCEOF'
[Unit]
Description=CF Email REST API Bridge
After=network.target
[Service]
Type=simple
User=cf-bridge
EnvironmentFile=/opt/cf-bridge.env
ExecStart=/opt/cf-bridge-venv/bin/python /opt/cf-smtp-bridge.py
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
SVCEOF
systemctl daemon-reload
systemctl enable --now cf-smtp-bridge这条命令在干嘛
EnvironmentFile=读取/opt/cf-bridge.env→ Token 从不在命令行/脚本文件里出现,是 systemd 官方推荐做法Restart=always+RestartSec=5:桥接 crash 后 5 秒自动重启- systemd 默认
StartLimitBurst=5(5 秒内最多 5 次重启),超出后停止重试,防止无限重启循环
第 4 步:防火墙
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow from 172.17.0.0/16 to any port 2525 proto tcp
ufw deny 2525/tcp
ufw --force enable
ufw status verbose⚠️ 这条命令有危险
必须先
ufw allow OpenSSH,再ufw enable! 如果先 enable 而没有 SSH 规则,你会被锁在服务器外面,只能靠 DO Web Console 救。必须同时放行 80/tcp 和 443/tcp! UFW 默认策略是
deny incoming,不加这两条会导致 nginx 对外完全不可达(所有站点返回 Cloudflare 522)。详见下方踩坑记录”坑 6”。跑完后:(1) 开新终端连一下 SSH 确认能登录 (2) 从另一台 VPS 测 443 端口连通性。
第 5 步:Bench 配置
每个 bench 容器的 common_site_config.json:
{
"mail_server": "172.17.0.1",
"mail_port": 2525,
"use_ssl": 0,
"use_tls": 0,
"mail_login": "",
"mail_password": "",
"auto_email_id": "noreply@getgrowth.app"
}每个站点创建/更新 Email Account(名字为 “Noreply”):
smtp_server: 172.17.0.1
smtp_port: 2525
no_smtp_authentication: 1
为什么 Email Account 必须存在
Frappe v16 的
frappe.email.queue.flush()不会从common_site_config.json回退 SMTP 地址。Email Account 不存在 → flush 静默不发。迁移站点的默认 Email Account 名字是 “Noreply” 不是 “Frappe”。
🔍 验证方法
# 从 bench 容器内发测试邮件
docker exec -i bench-0003-000007-f1 bench --site demo2.getgrowth.app console << 'EOF'
frappe.sendmail(recipients=["miao.qiang@getgrowth.consulting"],
subject="[Test]", message="test")
frappe.db.commit()
frappe.email.queue.flush()
EOF
# 看桥接日志
journalctl -u cf-smtp-bridge -n 20预期输出:HTTP/1.1 200 OK + OK -> ['miao.qiang@...']: [Test],且收件箱收到邮件。
如果没有:journalctl --since '1 minute ago' 看最新日志。空日志 = flush 没连过来 → 检查 Email Account 的 smtp_server 是否指向 172.17.0.1。
🕳️ 踩过的坑 & 被否决的方案
方案 A:CF Email Service 原生 SMTP(已否决)
问题:
smtp.mx.cloudflare.net:465需要出站 465 端口,DO 封锁了 教训:先确认端口可达性,再选方案
方案 B:MailChannels + CF Worker(已否决)
问题:MailChannels 与 CF 免费合作已于 2024-08-31 终止,
api.mailchannels.net/tx/v1/send拒绝匿名调用 教训:其他 AI 给的方案不一定还在有效期内,先查官方公告
方案 C:桥接 + CF Worker 中转(已否决)
问题:多一跳网络请求,无必要复杂度 教训:CF Email Service REST API 可以直接从 VPS 调,不需要 Worker
坑 1:
400 email.sending.error.invalid_request_schema现象:CF API 返回 400 根因:payload 用了嵌套结构
{from: {address}, to: [{address}], content: [{type, value}]}解决:CF Email Service 实际是扁平结构{to, from, subject, html, text}
坑 2:
Password not found for Email Account现象:
frappe.sendmail(now=True)报错 根因:Email Account 不存在或未设no_smtp_authentication: 1解决:创建 Email Account “Noreply”,设no_smtp_authentication: 1
坑 3:
frappe.email.queue.flush()静默不发现象:flush 执行无报错,但桥接日志无新连接,队列状态 “Not Sent” 根因:Email Account “Noreply” 的
smtp_server指向127.0.0.1(旧容器内桥接),容器内桥接已停 解决:更新 Email Account 的smtp_server为172.17.0.1(宿主机桥接)
坑 4:
frappe.sendmail(now=True)邮件静默丢失现象:
now=True发了但收不到,桥接日志没有记录 根因:now=True内部有 after_commit hook,和手动flush()可能冲突 解决:不用now=True。改用sendmail()→frappe.db.commit()→frappe.email.queue.flush()三步
坑 5:heredoc 在嵌套命令中被截断
现象:终端粘贴的 heredoc 内容只有一半 根因:
docker exec → ssh → docker exec → heredoc链中终端缓冲区截断 解决:先写文件到本地 VPS,再cat < file传过去;或拆成单条bench execute逐条执行
⚠️ 已知限制 / 副作用
- Frappe v16 每个站点必须有 Email Account 记录,仅
common_site_config.json不够。新站点默认创建 “Noreply” 但指向127.0.0.1,需手动/脚本改为172.17.0.1 - Frappe 的邮件队列依赖 scheduler,如果 scheduler 没正常运行,邮件会堆积在 Email Queue 表里(状态 “Not Sent”)
- 桥接不处理附件:当前只处理
text/plain和text/html,带附件的邮件需补充 base64 编码逻辑 - DMARC 策略当前是
p=none(只报告不拦截),后续可收紧为p=quarantine或p=reject - Press 自身在另一台 VPS(104.236.24.124),不能连
172.17.0.1:2525,需单独处理
🔒 安全 & 成本速查
- CF API Token 存放位置:宿主机
/opt/cf-bridge.env,权限 600,属主cf-bridge:cf-bridge - systemd 读取方式:
EnvironmentFile=/opt/cf-bridge.env(Token 不出现在脚本/命令行/supervisor 配置中) - 谁能访问 2525 端口:仅 Docker 网桥子网
172.17.0.0/16,公网 UFW deny - 桥接进程运行用户:
cf-bridge(/usr/sbin/nologin,无 Shell,最小权限) - 月度成本:Workers Paid $5/月(必需)+ Email Sending 免费额度内
- 如果要下线:
systemctl disable --now cf-smtp-bridge,删除/opt/cf-smtp-bridge.py和/opt/cf-bridge-venv/,Revoke CF API Token
📌 待办 / 未来优化方向
- 桥接补充附件处理能力(base64
attachments字段) - DMARC 观察后收紧策略(
p=none→p=quarantine) - Press 自身邮件方案(另台 VPS,需直接调 CF REST API 或装第二个桥接)
- 新站点 Email Account 自动创建/更新脚本(当前需手动改 smtp_server)
- 容器内旧桥接 supervisor.conf 段已清理(bench-0003-000007-f1 ✅)
📋 速查命令合集
# 桥接状态
systemctl status cf-smtp-bridge
# 桥接日志(实时)
journalctl -u cf-smtp-bridge -f
# 桥接日志(最近)
journalctl -u cf-smtp-bridge -n 20
# 从容器内测试发信
docker exec -i bench-0003-000007-f1 bench --site demo2.getgrowth.app console
>>> frappe.sendmail(recipients=["自己的邮箱"], subject="Test", message="test")
>>> frappe.db.commit()
>>> frappe.email.queue.flush()
# 查队列状态
docker exec bench-0003-000007-f1 bench --site demo2.getgrowth.app execute \
"[(e.name, e.status) for e in frappe.db.get_all('Email Queue', fields=['name','status'], order_by='creation desc', limit=5)]"
# 查 Email Account 配置
docker exec bench-0003-000007-f1 bench --site demo2.getgrowth.app execute \
"frappe.db.get_all('Email Account', fields=['name','smtp_server','smtp_port'])"🔧 2026-07-08 补充:UFW 封锁 80/443 事故
坑 6:UFW enable 后站点全部 522 Connection Timeout
现象:demo2.getgrowth.app 等所有站点返回 Cloudflare 522,但服务器 ping 通、nginx 运行正常 排查:
timeout 3 bash -c 'echo > /dev/tcp/45.55.93.224/443'→ BLOCKED。ufw status verbose只显示 22/tcp + 2525/tcp,没有 80 和 443 根因:第 4 步部署防火墙时,UFW 默认策略是deny incoming,但只加了 SSH 和 2525 规则,漏了 80/tcp 和 443/tcp。ufw --force enable后 nginx 对外完全不可达 解决:ufw allow 80/tcp && ufw allow 443/tcp,立即恢复
⚠️ 第 4 步防火墙命令已修正
原命令(危险!):
ufw allow OpenSSH
ufw allow from 172.17.0.0/16 to any port 2525 proto tcp
ufw deny 2525/tcp
ufw --force enable修正后(加 80/443):
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow from 172.17.0.0/16 to any port 2525 proto tcp
ufw deny 2525/tcp
ufw --force enable
ufw status verbose部署完防火墙后必须验证
每次
ufw --force enable后,从另一台 VPS 测 80/443 端口连通性:timeout 3 bash -c 'echo > /dev/tcp/45.55.93.224/443' && echo "OK" || echo "BLOCKED" timeout 3 bash -c 'echo > /dev/tcp/45.55.93.224/80' && echo "OK" || echo "BLOCKED"
📝 更新日志
| 日期 | 改动 | 原因 |
|---|---|---|
| 2026-07-06 | 首次创建,完成全部部署 + 验证 | DO 封锁 SMTP,CF 桥接方案验证通过 |
| 2026-07-08 | 补充 UFW 80/443 事故:修正防火墙步骤 + 加验证命令 | 部署防火墙漏加 80/443 导致全站 522 |