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.json172.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

第 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

第 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

第 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

第 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

🔍 验证方法

# 从 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


🕳️ 踩过的坑 & 被否决的方案


⚠️ 已知限制 / 副作用

  • 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/plaintext/html,带附件的邮件需补充 base64 编码逻辑
  • DMARC 策略当前是 p=none(只报告不拦截),后续可收紧为 p=quarantinep=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=nonep=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 事故

⚠️ 第 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