博客装修记:令人头疼的 Typecho 插件开发

warning: 这篇文章距离上次修改已过119天,其中的内容可能已经有所变动。

想了很久,最后还是决定把博客的评论回复邮件通知功能给补上。虽然Typecho有不少现成的,能实现这个功能的插件,不过找了一圈,似乎并没有发现能满足笔者特殊需求的插件,因此没办法,自己写吧。

至于这自己写插件的坑,只能说,真的一言难尽。

当然最后还是写出来了当然最后还是写出来了

1. 前言

熟悉本博客的朋友也许知道,在评论区上方,长期以来(两年半了快)都有一段文字,意思是说,因为没有邮件通知机制,请访客在评论后隔一段时间回来看看回复。出现这个提示的原因很简单:域名邮箱发件服务(或者说,MTA服务)大多数情况下都需要付费,对于目前暂无稳定收入,只能追求低成本建站的笔者来说,并不容易接受。便宜的甚至免费的方法也不是没有,例如AWS SES,一个月几块甚至几毛钱就能发信,但笔者没有外币卡,很多情况下注册这种服务就是件难事,更不谈去使用了。

除了买上述服务提供商的MTA服务之外,从技术上来说,想要发送邮件,其实也可以自行准备一台有公网IP的机器(比如博客服务器自身),搭建MTA服务,直接对目标邮箱地址的25端口进行发信。但实操当中,此类方法最大的问题有三个:

  • 商家可能会阻断25端口出站,需要特别申请
  • IP 信誉不一定高,很容易进对方垃圾箱,甚至直接拒收
  • SMTP 请求头会包含MTA的IP地址,会泄露源站IP

出现这种情况,要怪就怪SPAM。邮件这种半匿名的互联网通信方式,几乎就是从他诞生的第一天开始,就出现了『垃圾邮件』这个概念——作为参考,APRANET上的第一封电子邮件于1971年发出,而ARPANET上的第一封垃圾邮件也是在1971年发出(当然那时候都是单机邮件,范围仅限于同一个CTSS系统下的用户,还没有跨机器传播)。

图源维基媒体,有裁剪图源维基媒体,有裁剪

几十年来,互联网各界为了反垃圾邮件,采用了数不胜数的办法。从最早期的一份非标准规范RFC 706,到后来的关停开放邮件中继,检查各种SPF记录,DKIM记录,IP信誉库,在有效防止垃圾邮件的同时,也提高了发送正常邮件的技术难度。

最后,笔者还是找到了一家相对来说比较合用的发件服务商,Resend,来蹭蹭他的免费额度。因为本文不是推广文章,而且不同服务商的发件提交方法大同小异,无非就是SMTP地址不同,或者HTTP API的调用传参不同,因此这里就不过多展开了。

当然,这里必须提及一点:最后的成品插件不公开发布。不是不想开源,而是因为实在是不放心自己的代码质量,里面存在大量的workaround,也没有能力做后期维护,更没有对安全性做进一步的审计,甚至没有各种情况(例如站点多用户)的test case,放出来恐怕只能徒增麻烦。不过在下文里面会给一些关键代码片段(基于发稿时的最新稳定版1.3.0),以及谈谈设计思路,有需要的开发者可以参考。

2. 缺失的插件功能

如引言所述,现成的Typecho插件其实有不少,比如著名的 CommentToMail 。但问题是,此类插件更多的还是挂在finishComment钩子下,也就是当完成邮件发送/编辑之后,查询是否是属于回复评论,如果是,就自动向对应的SMTP服务器/HTTP API(下面统称为发信接口)发送邮件。

从工作原理上看当然没问题,但笔者其实更希望可以手动发信,也就是在后台完成站长回复之后手动触发,才发邮件通知,不是一有回复(包括读者回复)就触发通知。这样一方面可以减少开销,另一方面主要还是安全性考虑:因为站上收到过垃圾评论,因此存在的一种攻击方式是,垃圾评论发送者故意向评论区中的读者回复垃圾信息,然后就被塞入邮件里面发出去,不仅影响发信账号的信誉,还会给对方造成干扰。

此外,之前提到,部分读者不愿意留下真实邮箱,会使用[email protected]之类的伪地址。自动发信插件一般不会核对地址是否『看起来不正常』,照发不误,好一点的情况是对真实域名持有者(上例中是花旗银行)造成干扰,坏一点的情况是目标域名不存在,浪费额度不说,次数多了可能会被发信服务商判定为滥用发信服务,影响账号。

另一个缺失的是邮件退订功能,也就是允许收件人选择不再接收此类邮件。笔者一直觉得,一份合格的通知邮件应该具备可用的退订功能,不仅是给用户一个选择的权利,也能提高进入对方收件箱的概率(过滤器认为你的邮件是正规的)。如果有做过出海业务的朋友,应该知道,类似GDPR之类的条例对于邮件退订链接有严格的要求,比如说退订步骤不得过于复杂,云云。再比如,一些著名的邮件推送平台,比如阿里云,也会明确加入对于退订链接的要求:

不满足者不得发送不满足者不得发送

满足了以上两大点需求,剩下的都是些小问题了,比如说专门针对Resend这个服务商的API写调用规则,这个根据文档来处理就好。

3. 插件开发

....但其实,问题才刚刚开始。

笔者给这个插件命名为ManualMailSender,简称mms,因此下面你会反复看到这两个关键词的出现。

3.1 谁在调用我?

工欲善其事,必先利其器。一个插件想要和主站交互,需要两个条件:有Hook点来触发代码,以及存在API来与系统交互。

之前给站点适配JSON-LD的时候,笔者提到过,给Typecho开发插件是一件很麻烦的事情,毕竟当时的插件接口文档是缺失的,因此只能手动分析。后来,Typecho开发组修复了文档页面,给出了详细的Hook点定义,表面上看起来很美好,但实际上细细一品就会发现不对劲。比如,我们现在有这个接口:

Widget\Feedback的finishCommentWidget\Feedback的finishComment

看到这,你会很自然地想到用来触发一些事件,但是,$comment的内容是什么?

有可能开发组比较忙,来不及写文档,谅解是可以谅解,但最终结果就是,光看文档是看不出个所以然的。想要开发插件,必须得在本地(最好是本机)部署一份Typecho,并给PHP安装Xdebug。前者用来看源代码,后者用来动态分析上下文,一边看代码,一边搞明白接口传参和内部实现,顺便还能总结出一些小窍门(当然,更像是『小撬门』)。

这里给一个小撬门:Typecho的取值机制其实比较特殊,比如说,某方法里调用$this->content可以拿到某内容,如果你用Xdebug调试,会发现你找不到content这个属性。实际上,秘诀藏在/var/Typecho/Widget.php(此处斜杠表示网站根目录,下同)里,这个文件定义了Widget抽象类,而Typecho中几乎所有的类,都会继承他。类里面有一个__get()方法,熟悉PHP的朋友都知道,这是一个魔术方法(magic methods),当读取不可访问(private)或不存在的属性时,就会触发这个方法,运行里面的代码,来给用户提供值。

Typecho,可以说是把这个魔术方法玩到出神入化,一方面可以直接返回所需要的值,另一方面还可以把属性名转成方法名,执行方法后返回结果。此外,返回值的来源是一个名为row的属性,这其实是Typecho内置的堆栈系统,就在同一个文件内有定义,这样可以实现统一的取数据流程:把要用的数据都push进去,然后调用next(),把下一行数据就推入row,魔术方法就可以取到值了。所以如果想知道可以有哪些属性,直接用Xdebug看row的内容就好,基本上都在里面了。

3.2 我来调用谁?

假设,你现在想在插件里判断当前用户是不是管理员,可以怎么办?

Typecho的Widget是个好东西,通过看源代码(尤其是/var/Widget/)的方式,可以找到仅存在于注释中的接口,能解燃眉之急。因为Typecho并没有文档来记录可用接口,所以笔者无法判断这些接口到底算不算是『仅限内部使用』,但似乎除了这些,也没别的可用了。

要想使用他们,光看插件分类下的文档,是不够的,还需要位于页面最底端的开发文档页面,虽然里面的内容似乎也有很多时候未更新,但其实也基本上指明了一条方向。

比如上面提出来的问题,笔者在一番查询之后,在/var/Widget/User.php里面找到了注释表明为『判断用户权限』的pass()方法,还在上面一点的地方找到了$groups属性,已经预先定义了一些值,作为用户权限标识符。不过,虽然这个方法是Public的,但其不是静态方法,一般来说,需要先把类实例化才能使用。幸好,Typecho已经考虑到了代码其他地方调用的需求,因此为我们准备好了很方便的加载器,直接就能拿到一个实例化好的类。因此在插件代码里面,只需要调用:

# 具体方法位于 /var/Widget/User.php 
# 因此传参就是 Widget_User
# 其他的同理
$user = Typecho_Widget::widget('Widget_User');
if($user -> pass('administrator', true)) {
    echo "是管理员,邮箱是:".$user -> mail;
}

就可以实现我们的目的了。

3.3 添加手动按钮

有了上面这两个基础知识,就可以开始编写基本的代码了。

插件需要一个基本框架,这个在上次的博文的2.3和2.4节已经有所提及,因此不再赘述。整个插件唯一的一个Hook点是admin/footer.phpbegin,他的作用只有一个:往后台页面注入一段js代码,用来渲染我们所需要的手动按钮。

// 插件框架必备的函数
public static function activate() {
    Typecho_Plugin::factory('admin/footer.php')
      -> begin = ['ManualMailSender_Plugin', 'insertJS'];
}
// 自己定义的函数
// 在后台页面注入js,判断是否为评论页面,并执行对应操作
public static function insertJS() {
    $adminEmail = Typecho_Widget::widget('Widget_User') -> mail;
    echo('<script name="x-manualmailsender-js">');
    echo strtr(file_get_contents(__DIR__.'/mms_send_button.js'), [
        '{{admin_mail}}' => $adminEmail
    ]);
    echo("</script>\n");
}

mms_send_button.js的内容比较长,限于篇幅不好直接放上来,但基本思路就是:

  • 判断当前页面是不是/admin/manage-comments.php
  • 是的话,根据预先设定好的邮箱,在管理员的评论下添加通知按钮
  • 给按钮的dataset添加coid(当前评论ID),绑定click事件
  • click事件中,把coid发给插件的接口,并在回调事件中显示接口输出信息

最终表现出来就是一个小按钮:

其实用的是 span其实用的是 span

3.4 设计发信接口

由上可知,我们的目标是,给定一个管理员回复的评论的coid,需要:

  • 倒查出本评论信息(正文),所回复的评论信息(发送者,正文,邮箱),对应的文章信息(文章ID,标题)
  • 查询数据库,检查收件人是否退订
  • 查询数据库,计算评论的永久链接
  • 构建发信用 HTML
  • 调用发信接口,发信

3.4.1 神秘的 addRoute 方法

不过,我们先抛开上面的问题不谈,先来聊聊Typecho在插件中如何正确添加路由。

根据官方文档,我们知道,Helper类下面有添加路由的接口,大概长这个样子:

use Utils\Helper;
Helper::addRoute($name, $url, $widget, $action, $after);

// 这个是官方给出来的示例代码
Helper::addRoute(
  'plugin-api', '/api/plugin/[action:alpha]',
  'MyPlugin_Action', 'api'
);

但问题在于,官方的文档也语焉不详,并没有提到:

  • $widget 怎么设置才生效
  • $url 支持的『参数占位符』是个啥

正是在动手写过代码之后,笔者才得出来上面提到的『本地搭建Typecho,并挂上Xdebug』作为必要前提的结论,因为如果没有这两玩意,挠破头可能都想不到这里究竟是啥——不过,不幸中的万幸,Typecho给文件夹起名字还是挺规范的,因此可以直接蒙到可能出现的位置。

3.4.1.1 类名疑云

先说第一个,毕竟这是必需品。

对于$widget参数,官方给的注释是『处理该路由的 Widget 类名』,字面上当然不难理解,得有个类来处理。我们的插件类名是ManualMailSender_Plugin,但如果你真的往里面写这个,你会发现系统报错,说是execute()未定义:

当然,也不是不能理解,毕竟确实没有。假设,现在我们往插件方法里面手动补一个空方法,重试,会发现系统报另一个错:

Typecho\Widget::widget(): 
  Return value must be of type Typecho\Widget,
  ManualMailSender_Plugin returned

显然,这里的类名,要求类型是Typecho\Widget,也就是说,需要开一个新的类去继承他。要做到这一点,需要在插件目录下新建一个Action.php,放入代码:

class ManualMailSender_Action extends Typecho_Widget {
  // 添加需要用到的方法
  // 就可以被路由进去,进行相关操作
}

那么,为啥类名是ManualMailSender_Action

这个问题的答案很简单,但分析起来有点复杂。这里假设你用的是VSCode,先导航到/var/Typecho/Widget.php的154行,下一个条件断点:

$className == "ManualMailSender_Action"

重新执行,在触发断点后,按F11进入单步执行,跳转到Common.php,进入了spl_autoload_register()。这是PHP一个很好用的函数,只要传入一个函数作为参数,那么,当程序中调用到不存在的类时,就会执行这个函数,让他来负责加载包含目标类的文件。

Typecho中的这个函数逻辑很复杂,但其中很多都是分支而已。我们重点关注加载插件路由所指向的的类的逻辑:

if ($isPlugin) {
    // ...
    $path = str_replace('_', '/', $className);
}
// 中间省略一批代码.....
$path .= '.php';
$defaultFile = __TYPECHO_ROOT_DIR__ . '/var/' . $path;
if (file_exists($defaultFile) && !$isPlugin) {
    // 无关逻辑,跳过
} else {
    $pluginFile = __TYPECHO_ROOT_DIR__ . __TYPECHO_PLUGIN_DIR__ . '/' . $path;
    if (file_exists($pluginFile)) {
        include_once $pluginFile; // 在此处实际加载
    }
}

也就是说,系统把所需类名转换为了实际加载的文件名,对于我们的案例来说,系统就会去寻找/usr/plugins/ManualMailSender/Action.php,并尝试加载其中的同名类。因此,Action 也不是不能换成别的,但一改就要改三处(这里的类名,文件名,文件内类名),比如你想改成『VeryGoodClassName』,就需要:

  • 新文件名改成VeryGoodClassName.php
  • addroute和文件内的类名改成 ManualMailSender_VeryGoodClassName

所以没啥必要的情况下,不改也行。

3.4.1.2 不知所踪的占位符

接着我们来聊第二个,关于占位符的事情。

从直觉上看,这里的意思是,会把URL的一部分解析为参数,传给设置好的处理方法——实际上也确实如此。不过上面示例中,估计肯定会有读者想到,里面的alpha如果说是占位符的类型的话,他可能的取值是啥?

导航到/var/Typecho/Router/Parser.php,就能找到可用的占位符类型:

// __construct 内部定义的常量
// 注释为笔者自行添加
$this->defaultRegex = [
  // 里面的 %s 是给后面的 sprintf 用的
  // 一般会被替换为 + 
  'string' => '(.%s)', // 字符串
  'char' => '([^/]%s)', // 不是斜杠的字符串
  'digital' => '([0-9]%s)', // 数字
  'alpha' => '([_0-9a-zA-Z-]%s)', // 数字大小写,以及下划线,连接符
  'alphaslash' => '([_0-9a-zA-Z-/]%s)', // 再加上斜杠
  'split' => '((?:[^/]+/)%s[^/]+)', // 可能是按斜杠分隔
];

继续往下翻,还可以看到parse()方法。具体源代码就不贴了,但他的作用是,根据$url里面的参数名和占位符,在后续处理流程里提取对应的参数。比如示例中的[action:alpha],意思是可以在后续拿到一个键为action的值,其值是根据alpha类型的正则表达式所提取出来的。比如说对于URL/api/plugin/test-123,最后拿到的值就是test-123

好了,我们的值已经提取出来了,要怎么获取呢?

Typecho 官方同样是没有说明这一点,需要自己去摸索。因为摸索过程实在是太复杂,笔者也懒得多说了,反正结论就是,需要在构造函数中取值,并保存好:

class ManualMailSender_Action extends Typecho_Widget {
    private $action; // 名字任意取
    public function __construct($request, $response, $params) {
        $this -> action = $request->get('action');
    }
    // 其他方法里面就可以使用这个$action了
}

开发MMS插件的时候,笔者并没有研究出来上面那一点,因此并未使用这个方法,而是用的老传统,$_GET

3.4.2 基本信息收集

这一段其实没什么好说的,主要就是先鉴权,确定调用接口的人是管理员之后,对输入参数进行过滤清洗判空,然后慢慢查询数据库,把所需要的参数都收集齐。Typecho 提供了操作数据库的方式,因此我们可以直接使用他来进行各类查询操作。

此外,还有一些东西不必亲自查询,例如要计算文章链接,需要知道站点的URL,就可以通过Typecho内置接口来获取:

$option = Helper::options();
$baseURL = $option -> siteURL;

3.4.3 计算评论永久链接

实际发信HTML里面,有一个指向回复评论的链接,其核心就是评论分页页码。

如何在仅仅知道coid的情况下计算这个链接,卡了笔者很多天的一件事情。一开始的思路很简单很暴力,生成后台按钮的时候,把系统已经生成好的永久链接一起提交到发信接口,能用倒是能用,但显得很不美观。后来想了想,既然系统可以做到,那么还不如直接抄系统的逻辑过来用算了。

几经周折,找到了/var/Widget/Base/Comments.php,里面有一个___commentPage()函数,是系统的计算逻辑。阅读源代码之后,发现他的计算方法也很简单很暴力,直呼妙哉,这里直接把当时写的分析注释抄过来:

1. 给定一个coid,递归查询到主评论($parent == 0)或最顶层(对应的评论不存在)
2. 根据$option -> commentsOrder的值,查询出比他还要早(或者晚)的所有已批准评论
3. 开一个空数组,开一个$total = 0
3.1 进入for循环,逐行插入数组,coid为键,parent为值
3.2 接着,如果parent为0(主评论)
    或用parent为键时查不到值(原主评论消失,子评论被顶上来)
    那么,$total += 1
4. 计算 ceil($total / $option -> $commentPageSize),作为分页页码

只要自己实现一次这个逻辑,就能得出和系统一模一样的计算结果。

3.4.4 构造HTML,实际发信

很多邮件客户端,不一定支持很多现代的CSS和HTML特性,因此在制作通知邮件用的HTML页面时需要特别注意。笔者随便找了个LLM,让他帮我生成了一段兼容性比较好,样式也还算过得去的代码,略做改动之后就投入使用了。

关于发信吗,为了以后更换服务商方便,笔者采用了分离设计,也就是把具体的API调用,新开一个类,抽象为一个通用接口。反正执行这段代码的次数不多,性能差一点也不会有太大的问题,上层代码只要统一调用这个发信接口就可以发信,反而减轻了开发负担。

3.5 邮件退订接口

接着来谈谈退订链接的事情。

根据惯例,一个比较好的退订链接,最好是每封邮件都不同,而且附带了收信人地址的,这样需要退订的收件人只要点开链接,并确认退订操作,就可以成功退订邮件。

最后实现的退订页面最后实现的退订页面

当然,退订链接的幕后,同样是比较繁忙的。

3.5.1 数据库设计

需要有个地方来存储具体的退订状态,因此在插件的激活方法里,需要准备建表语句:

// 获取数据库对象,以及系统表前缀
$db = Db::get();
$prefix = $db->getPrefix();

$db->query(
    "CREATE TABLE IF NOT EXISTS `{$prefix}mss_email_status` (
    `id` int unsigned NOT NULL AUTO_INCREMENT,
    `mail` varchar(150) NOT NULL,
    `status` tinyint NOT NULL DEFAULT 1 COMMENT '0为正常接收,大于0(比如1)为已退订',
    PRIMARY KEY (`id`)
    ) COMMENT='由 ManualMailSender 添加,用于记录退订情况' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"
);

按常理来说,需要在取消激活的方法里,设置对应的表删除语句。但考虑到有可能不小心禁用插件导致数据全丢,反正这个插件也只是自己用,因此笔者并没有这么添加。

3.5.2 页面显示与退订处理

为了简便起见,这里采用同一个路径处理两个请求,也就是收到GET时显示页面,收到POST且有对应的参数时处理退订逻辑。本质上来说,所谓退订,就是在数据库里面调整记录(上例中,就是把status设置为1),显著标明此地址已退订,即可,就是一个很简单的CRUD类操作,没有什么太高深的技巧。

当然,这里还有个提高用户舒适度的小撬门:在显示退订页面的之前,先查一次数据库,获取用户当前退订状态,并预先设置好页面上的选择框,清晰表明当前的状态,这样可以让用户放心——『刷新之后还是没勾上,应该是退订成功了』。

3.5.3 退订状态查询

回到之前的发信接口代码里,同样加上查询逻辑,如果用户已退订,就直接把接口拦下来,不再进行发信操作:

测试案例测试案例

4. 写在最后

道理确实是很简单,只靠上面的逻辑,再补一些安全处理(例如有效邮箱地址校验,长度校验)等等,就可以成为一个很不错的发信插件了。

目前这个插件还在测试中,暂时没有发现问题,如有疑问,欢迎来信询问。

(完)


木头箱子脆脆,但是这样正好

如无特殊声明,本站内容遵循 CC BY-NC-SA 4.0 协议

转载请注明出处并保留作者信息,谢谢!

本站由 搬瓦工VPS 强力驱动

none
最后修改于:2026年03月14日 21:59

已有 4 条评论

  1. 1 1

    邮箱服务能不自建就不自建,用免费的,国内也一堆,自己用是足够的,关于垃圾回复,可以考虑上cf或者验证码,typecho的很多垃圾评论都是低端程序发出来的,随便一拦就没了,我博客之前天天好几条垃圾评论,上了cap.js后瞬间清净了。

    1. 我站点有拦截垃圾评论的插件,简单调教了一下,拦截率还是可以的(也有可能和我目前碰到的垃圾评论都包含关键词或者网址有关

  2. 发条评论测试一下,这个理念确实不错,手动发件

    1. 更多的还是一种可控的感觉吧,全自动的话很容易出乱子(尤其是不确定系统有没有提权漏洞的情况下,因为Typecho曾经有过不少漏洞,当然都及时修了)

添加新评论

提醒:『评论回复邮件提醒』功能正在测试中
评论后,如果站长有回复,会有邮件通知