不太优雅地解决Twikoo-Cloudflare评论邮件提醒问题

不太优雅地解决Twikoo-Cloudflare评论邮件提醒问题

博客评论系统用的是GitHub - twikoojs/twikoo-cloudflare,但评论得到回复后的邮件提醒一直没有去设置。本以为很容易就可以解决的事,没想到踩了很多坑。最终采取了一个不太优雅的本地方案。

由于 nodemailer 包的兼容性问题,通过 SMTP 发送通知的邮件集成将无法直接使用。因此,在Worker中,我们支持通过 SendGrid 的 HTTPS API 发送邮件通知。

API邮件服务商 #

事情并没有那么顺利,注册SendGrid失败了,查了相关资料,可能SendGrid对注册邮箱、IP地址有严格的要求。

After a thorough review, we regret to inform you that we are unable to proceed with activating your account at this time. Ensuring the security and integrity of our platform is our top priority, and our vetting process is designed to detect potential risks. While we understand the importance of transparency, we are not able to provide the specifics of our vetting process.

于是,想着转战其它支持API调用发送邮件的服务,更换服务商只需修改twikoo-cloudflareindex.js中的POST请求部分就可以了。

// twikoo-cloudflare/src/index.js
// 注入Cloudflare特定的依赖(原依赖于Cloudflare不兼容)
setCustomLibs({
  DOMPurify: {
    sanitize (input) {
      return input
    }
  },
  nodemailer: {
    createTransport () {
      return {
        verify () {
          return true
        },
        sendMail ({ from, to, subject, html }) {
          if (!config.SMTP_PASS) return "未配置SMTP_PASS,跳过邮件通知。"
          return fetch('https://api.sendgrid.com/v3/mail/send', {
            method: 'POST',
            headers: {
              'Authorization': `Bearer ${config.SMTP_PASS}`,
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({
              personalizations: [{ to: [{ email: to }] }],
              from: { email: from },
              subject,
              content: [{ type: 'text/html', value: html }],
            })
          })
        }
      }
    }
  }
})

流行的API邮件服务商有Mailgun、Mailjet、Mailersend、AokSend。但试一圈下来,几乎都得到模板式的回应。其中,Mailgun的注册要手机验证,因收不到验证码,还开了个工单请客服帮忙手动激活了,但一激活账户就锁定了:

Your account is temporarily disabled. (Account disabled) Please contact support to resolve.

可能的原因仍然是注册IP和注册邮箱的问题:

If you’re setting up a new server it’s possible it’s been given an IP address that’s on a spam blacklist due to spamming in the past. They will block you because they suspect that spamming will continue, despite the address being owned by a different person. Or there could be a bunch of other reasons why they might not like you or your site.1

附上当时申请手动激活Mailgun的工单模板:

Hello Mailgun Support, I’m having trouble verifying my account. After clicking the verification link, I was asked to enter my mobile phone number. However, when I attempted to send the verification code, I received error messages such as “too many attempts” and similar reminders, and the code never arrived. My mobile phone number is +86 XXX XXXX XXXX. My email is XXX@XXX. Could you please help me complete the verification process? Thank you for your assistance. Best regards,

同样,在辗转了两个工作人员的工单后,还填写了使用场景的调查表单后,Mailjet也失败了:

We regret to inform you that your account activation request has been declined by our Compliance team after a thorough review of your account information. This decision has been made in accordance with our Acceptable Use Policy and/or Terms of Service, which are in place to ensure the safety and security of our users. For security reasons, we are unable to provide specific details regarding the decision-making process. However, please be assured that we have a rigorous system in place to review account information and identify potential risks.

Mailersend注册、绑定域名倒是挺方便的,但仍然需要填写表单进行人工审核,还得绑定信用卡使用,鉴于之前的失败,没有继续申请了,正式激活前,API也没法试用。

总结下来,这类API邮件服务商可能对注册邮箱和IP有着严格要求。联想到自己收到的大量垃圾邮件,也可以理解吧,为了避免滥用。

Gmail API #

Gmail也可以通过API调用来发送邮件,在Google Cloud控制台构建应用,TEST测试情况下,Token和Refresh Token的最大有效期也只有7天,而要正式发布解除限制的话,则需要经过审查,遂作罢。

转发至本地 #

看起来用API调用发送邮件这条路暂时走不通了,我想着把请求发送到自己服务器的n8n Webhook上,然后在本地进行邮件的处理。但由于我的n8n使用的是非标准端口,Cloudflare Worker似乎不支持非标准端口,请求总是发不出来,遂作罢。

不太优雅的解决方案 #

Twikoo Worker将数据连接到Cloudflare的D1数据库上,于是采用这样一个方法:用Cloudflare API读取D1数据库2,抓取数据库内的评论信息,在本地用smtp协议对评论者发送提醒邮件。方法很笨拙,但有效,疲于折腾这些邮件服务了。以下是代码:

import time
import datetime
import requests
import smtplib
import configparser
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText

class CommentNotifier:
    def __init__(self, CLOUDFLARE_ACCOUNT_ID, CF_ACCOUNT_MAIL, CLOUDFLARE_DB_ID, CLOUDFLARE_API_TOKEN,
                 SMTP_SERVER, SMTP_PORT, SMTP_USER, SMTP_PASS):
        # Cloudflare D1
        self.CF_ACCOUNT_ID = CLOUDFLARE_ACCOUNT_ID
        self.CF_ACCOUNT_MAIL = CF_ACCOUNT_MAIL
        self.CF_DB_ID = CLOUDFLARE_DB_ID
        self.CF_TOKEN = CLOUDFLARE_API_TOKEN
        self.CF_ENDPOINT = f"https://api.cloudflare.com/client/v4/accounts/{self.CF_ACCOUNT_ID}/d1/database/{self.CF_DB_ID}/query"

        # SMTP settings
        self.SMTP_SERVER = SMTP_SERVER
        self.SMTP_PORT = SMTP_PORT
        self.SMTP_USER = SMTP_USER
        self.SMTP_PASS = SMTP_PASS

    def query_d1(self, sql, params=None):
        headers = {
            "X-Auth-Key": f"{self.CF_TOKEN}",
            "X-Auth-Email": f"{self.CF_ACCOUNT_MAIL}",
            "Content-Type": "application/json"
        }
        payload = {"sql": sql}
        if params:
            payload["params"] = params

        resp = requests.post(self.CF_ENDPOINT, headers=headers, json=payload)
        resp.raise_for_status()
        data = resp.json()
        return data["result"][0]["results"]

    def get_latest_comments(self):
        sql = "SELECT * FROM comment ORDER BY created DESC LIMIT 5;"
        return self.query_d1(sql)

    def filter_recent_replies(self, comments):
        now = int(time.time() * 1000)
        cutoff = now - 30 * 60 * 1000

        return [
            c for c in comments
            if c.get("pid") and c.get("created", 0) >= cutoff
        ]

    def get_parent_comment(self, pid):
        sql = "SELECT * FROM comment WHERE _id = ?;"
        res = self.query_d1(sql, [pid])
        return res[0] if res else None

    def send_mail(self, to_email, subject, body_html):
        msg = MIMEMultipart('alternative')
        name = self.SMTP_USER.split('@')[0]
        msg['From'] = f"{name} <{self.SMTP_USER}>"
        msg['To'] = to_email
        msg['Subject'] = subject

        msg.attach(MIMEText(body_html, 'html'))

        try:
            with smtplib.SMTP_SSL(self.SMTP_SERVER, self.SMTP_PORT) as server:
                server.login(self.SMTP_USER, self.SMTP_PASS)
                server.sendmail(self.SMTP_USER, to_email, msg.as_string())
                print(f"[Success] Mail has been sent to {to_email}")
        except Exception as e:
            print(f"[Error] Mail sending failed: {e}")

    def run(self):
        comments = self.get_latest_comments()
        recent_replies = self.filter_recent_replies(comments)

        if not recent_replies:
            print("[Info] No recent replies found.")
            return

        for reply in recent_replies:
            parent = self.get_parent_comment(reply["pid"])
            if not parent:
                print(f"[Warning] Parent comment not found: {reply['pid']}")
                continue

            to_email = parent.get("mail").strip()
            to_nick = parent.get("nick")
            to_comment = parent.get("comment")
            to_href = parent.get("href")

            if reply.get("mail") == to_email:
                continue

            if not to_email:
                print(f"[Warning] No email found for parent comment: {parent}")
                continue

            subject = f"凉糕的博客通知"
            body_html = f"""
                <table style=\"width:100%;background-color:#f5f7fa;padding:40px 0;font-family:Arial, sans-serif;\">
                <tr><td align=\"center\">
                <table style=\"width:600px;background-color:#0F4D81;color:#ffffff;text-align:center;border-radius:8px 8px 0 0;\">
                    <tr><td style=\"padding:20px 0;font-size:22px;font-weight:bold;\">
                        凉糕的博客通知
                    </td></tr>
                </table>
                <table style=\"width:600px;background-color:#ffffff;border-radius:0 0 8px 8px;box-shadow:0 2px 8px rgba(0,0,0,0.05);\">
                    <tr><td style=\"padding:30px 40px;font-size:14px;line-height:1.8;color:#333;\">
                        <p><strong>{to_nick}</strong>,您曾在
                        <a href=\"https://osnsyc.top\" target=\"_blank\" style=\"color:#12addb;text-decoration:none;\">『凉糕的博客』</a>发表评论:</p>
                        <div style=\"background-color:#f5f5f5;padding:12px 15px;margin:15px 0;border-radius:4px;word-wrap:break-word;\">
                            {to_comment}
                        </div>
                        <p><strong>{reply['nick']}</strong> 回复说:</p>
                        <div style=\"background-color:#f5f5f5;padding:12px 15px;margin:15px 0;border-radius:4px;word-wrap:break-word;\">
                            {reply['comment']}
                        </div>
                        <div style=\"text-align:center;margin:30px 0;\">
                            <a href=\"{to_href}\" target=\"_blank\" style=\"background-color:#0F4D81;color:#ffffff;padding:12px 24px;text-decoration:none;border-radius:4px;font-weight:bold;display:inline-block;\">
                                查看完整回复
                            </a>
                        </div>
                        <p style=\"font-size:12px;color:#888;margin:10px 0 20px 0;text-align:center;line-height:1.6;\">
                            如果按钮无法点击,请点击链接前往:<br>
                            <a href=\"{to_href}\" target=\"_blank\" style=\"color:#12addb;text-decoration:none;\">{to_href}</a>
                        </p>
                        <p style=\"font-size:12px;color:#888;margin-top:20px;text-align:center;\">
                            欢迎再次访问 
                            <a href=\"https://osnsyc.top\" target=\"_blank\" style=\"color:#12addb;text-decoration:none;\">『凉糕的博客』</a>
                        </p>
                    </td></tr>
                </table>
                </td></tr></table>
            """
            self.send_mail(to_email, subject, body_html)

if __name__ == '__main__':
    print("=== Script started at", datetime.datetime.now(), "===")
    config = configparser.ConfigParser()
    config.read('config.ini')

    CLOUDFLARE_ACCOUNT_ID = config.get('Cloudflare', 'ACCOUNT_ID')
    CF_ACCOUNT_MAIL = config.get('Cloudflare', 'ACCOUNT_MAIL')
    CLOUDFLARE_DB_ID = config.get('Cloudflare', 'DB_ID')
    CLOUDFLARE_API_TOKEN = config.get('Cloudflare', 'API_TOKEN')

    SMTP_SERVER = config.get('SMTP', 'SERVER')
    SMTP_PORT = config.getint('SMTP', 'PORT')
    SMTP_USER = config.get('SMTP', 'USER')
    SMTP_PASS = config.get('SMTP', 'PASS')

    notifier = CommentNotifier(
        CLOUDFLARE_ACCOUNT_ID, CF_ACCOUNT_MAIL, CLOUDFLARE_DB_ID, CLOUDFLARE_API_TOKEN,
        SMTP_SERVER, SMTP_PORT, SMTP_USER, SMTP_PASS
    )
    notifier.run()

准备config.ini

[SMTP]
SERVER = smtp.163.com
PORT = 465
USER = [email protected]
PASS = [授权码]

[Cloudflare]
ACCOUNT_ID = 
ACCOUNT_MAIL = 
DB_ID = 
API_TOKEN = 

每半小时执行一次,处理近半小时的评论:

# crontab -e
0,30 * * * * python main.py >> mailer.log 2>&1

评论的回复、回复的回复 评论的回复、回复的回复

收到回复者的邮件提醒 收到回复者的邮件提醒

总结 #

硬着头皮使用Twikoo-Cloudflare,艰难地解决了邮件提醒的问题,现在网站的流量不大,因此在功能性方面,先解决“ 0 -> 1 ”的问题。不纠结All in Cloudflare的话,还是推荐其它部署方式,云函数部署 | Twikoo 文档

前一篇 Markdown ×... 随机阅读