PHPMailer教程:修复Gmail邮件相同主题不分组/不成串
2025-04-01 01:00:16
让 PHPMailer 发送的邮件在 Gmail 中正确分组:解决相同主题邮件不成串的问题
用 PHPMailer 发送通知邮件,比如支付成功、服务器状态更新之类的,挺方便。但有时会遇到个小麻烦:明明是关于同一个事件(比如同一笔支付)发出的多封邮件,收件箱里却没能自动把它们归拢到一起,变成一个邮件串(Conversation/Thread)。尤其是用 Gmail 的时候,这个问题更明显。就像下面这样:
期望的效果(比如同一个支付 ID 的两次更新):
+-----------------------------------------------------------------------------+
| Support (2) | [Payment] Player foo (123456789) - Payment ID 123456789 |
+-----------------------------------------------------------------------------+
实际收到的效果:
+-----------------------------------------------------------------------------+
| Support | [Payment] Player foo (123456789) - Payment ID 123456789 |
+-----------------------------------------------------------------------------+
| Support | [Payment] Player foo (123456789) - Payment ID 123456789 |
+-----------------------------------------------------------------------------+
即使邮件的发件人、收件人、主题都完全一样,它们还是各自为政,成了两条独立的邮件。这对于需要追踪处理过程的客服或者运维来说,查起来就费劲多了。
有人可能觉得,邮件串是靠主题(Subject)来识别的。直觉上好像是这样,但实际上,光靠主题相同,很多时候并不能保证邮件会被归类到同一个会话里。
邮件到底是怎么串起来的?
邮件客户端(比如 Gmail、Outlook 等)判断邮件是否属于同一个会话,主要依赖的是邮件头(Email Headers)里的几个特定字段,而不是简单地看主题是否一致。这几个关键的头信息是:
Message-ID
: 每封邮件都应该有一个全局唯一的标识符。就像人的身份证号,理论上不应该重复。References
: 当一封邮件是对之前邮件的回复或转发时,这个字段会包含它引用的所有父邮件的Message-ID
,形成一个引用链。In-Reply-To
: 这个字段通常包含这封邮件直接回复的那封邮件的Message-ID
。
邮件客户端会检查新邮件的 References
或 In-Reply-To
字段,看里面是不是包含了收件箱里已有邮件的 Message-ID
。如果找到了匹配,并且主题也相似(客户端通常会做一些标准化处理,比如去掉 "Re:", "回复:" 等前缀),那么这封新邮件就会被判定为属于同一个会话,然后被归拢进去。
那为啥我们的 PHPMailer 代码没起作用?
回头看看最开始那段 PHPMailer 代码:
<?php
use PHPMailer\PHPMailer\PHPMailer;
// ... 其他 require 和 use 语句 ...
$mail = new PHPMailer; // 创建新实例
$mail->isSMTP(); // 使用 SMTP
$mail->CharSet = 'UTF-8'; // 设置 UTF-8 编码
$mail->Host = 'smtp.gmail.com'; // 邮件服务器主机名
$mail->Port = 587; // SMTP 端口号 (TLS 通常用 587)
$mail->SMTPSecure = 'tls'; // 加密方式: ssl (已弃用) 或 tls
$mail->SMTPAuth = true; // 启用 SMTP 认证
$mail->Username = 'YOUR_EMAIL_LOGIN'; // SMTP 用户名
$mail->Password = 'YOUR_EMAIL_PASSWORD'; // SMTP 密码
$mail->wordWrap = 70; // 自动换行字符数
$mail->Subject = "[Payment] - Player {$payment->user->name} ({$payment->user->id}) - Payment ID {$payment->id}";
$mail->Body = $htmlBody; // HTML 邮件内容
$mail->AltBody = $plainBody; // 纯文本备用内容
$mail->setFrom( 'support@yourdomain.com', 'Support' ); // 发件人
$mail->addReplyTo( 'support@yourdomain.com', 'Support' ); // 回复地址
$mail->addAddress( 'support@yourdomain.com' ); // 收件人
$mail->isHTML( true ); // 设为 HTML 格式
if( !$mail->send() ) {
error_log( "邮件发送失败 Payment ID: {$payment->id} User ID: {$payment->user->id}" );
}
这段代码每次执行 $mail = new PHPMailer;
和 $mail->send();
,都是在创建一个全新的、独立的邮件。PHPMailer 默认会为每一封这样发送的邮件生成一个 独一无二 的 Message-ID
。关键在于,它并不知道这次发送的邮件跟之前关于同一个支付 ID 的邮件有任何关联。所以,它不会自动添加 References
或 In-Reply-To
头信息来指向之前的邮件。
结果就是,每一封邮件虽然主题相同,但因为有着不同的 Message-ID
并且缺少关联性的 References
或 In-Reply-To
头信息,邮件客户端就认为它们是独立的、不相关的邮件。自然就不会把它们串在一起了。
解决方案:手动添加邮件头信息
要让这些关于同一主题的邮件串起来,咱们需要手动给后续的邮件添加 References
和 In-Reply-To
头信息,告诉邮件客户端:“嘿,这封邮件是接着前面那封说的!”。
这需要做几件事:
- 识别主题 : 需要一个明确的方式来识别哪些邮件应该属于同一个会话。在例子中,
Payment ID {$payment->id}
就是一个很好的标识符。 - 记录首封邮件的 Message-ID : 当发送关于某个主题(比如某个 Payment ID)的 第一封 邮件时,需要获取并保存这封邮件的
Message-ID
。 - 为后续邮件添加头信息 : 当发送关于 同一个 主题的 后续 邮件时,取出之前保存的那个
Message-ID
,并将其添加到新邮件的In-Reply-To
和References
头信息里。
下面是具体的步骤和代码调整:
第一步:为邮件生成并设置 Message-ID
PHPMailer 会自动生成 Message-ID
,但如果我们需要在发送 之后 才能拿到它(这通常比较困难或不可靠),或者想更好地控制它,我们可以自己生成一个符合规范的 Message-ID
并在发送 之前 设置好。
Message-ID
的格式通常是 <unique-string@your-domain.com>
。我们可以用时间戳、随机字符串和你的域名来组合生成。
<?php
// Function to generate a unique Message-ID
function generateMessageID() {
// 确保使用你自己的域名
$domain = 'your-domain.com';
return sprintf(
"<%s.%s@%s>",
base_convert(microtime(true), 10, 36), // 基于时间的唯一部分
base_convert(bin2hex(random_bytes(8)), 16, 36), // 随机部分增加唯一性
$domain // 你的域名
);
}
// 在你的邮件发送逻辑中...
// 生成一个 Message-ID (无论是不是第一封,每次发送都需要一个新的)
$currentMessageID = generateMessageID();
// ----- 查找或存储与主题相关的首个 Message-ID 的逻辑 -----
$paymentId = $payment->id;
// 假设你有一个函数可以根据 Payment ID 找到存储的首个 Message-ID
$firstMessageID = get_first_message_id_for_payment($paymentId);
$mail = new PHPMailer;
// ... 其他 PHPMailer 设置 ...
// **关键:设置当前邮件的 Message-ID**
$mail->MessageID = $currentMessageID;
// **关键:如果是关于此 Payment ID 的后续邮件**
if ($firstMessageID) {
// 添加 In-Reply-To 头,指向第一封邮件
$mail->addCustomHeader('In-Reply-To', $firstMessageID);
// 添加 References 头,也指向第一封邮件 (对于简单串联足够了)
// 更复杂的场景可能需要累加 References
$mail->addCustomHeader('References', $firstMessageID);
} else {
// 这是关于此 Payment ID 的第一封邮件
// 发送成功后,需要将 $currentMessageID 存储起来,与 $paymentId 关联
// 伪代码: store_first_message_id_for_payment($paymentId, $currentMessageID);
}
// ... 设置 Subject, Body, From, To 等等,就像原来一样 ...
$mail->Subject = "[Payment] - Player {$payment->user->name} ({$payment->user->id}) - Payment ID {$payment->id}";
$mail->Body = $htmlBody;
$mail->AltBody = $plainBody;
$mail->setFrom( 'support@yourdomain.com', 'Support' );
$mail->addReplyTo( 'support@yourdomain.com', 'Support' );
$mail->addAddress( 'support@yourdomain.com' );
$mail->isHTML( true );
// 发送邮件
if( !$mail->send() ) {
error_log( "邮件发送失败 Payment ID: {$payment->id} User ID: {$payment->user->id}" );
} else {
// 如果是第一封邮件,并且发送成功了,现在存储 Message-ID
if (!$firstMessageID) {
// 实现这个存储函数,比如存到数据库或缓存
store_first_message_id_for_payment($paymentId, $currentMessageID);
}
}
// ----- 下面是辅助函数的示例实现(需要根据你的系统调整) -----
/**
* 尝试从存储中获取与 Payment ID 关联的第一封邮件的 Message-ID
* @param int $paymentId
* @return string|null 返回 Message-ID 或 null(如果找不到)
*/
function get_first_message_id_for_payment(int $paymentId): ?string {
// 示例:从数据库查询
// $db = new PDO(...);
// $stmt = $db->prepare("SELECT first_message_id FROM payment_email_threads WHERE payment_id = ?");
// $stmt->execute([$paymentId]);
// $result = $stmt->fetchColumn();
// return $result ?: null;
// 示例:从缓存(如 Redis)获取
// $redis = new Redis();
// $redis->connect('127.0.0.1', 6379);
// $key = "payment_thread_msgid:" . $paymentId;
// $messageId = $redis->get($key);
// return $messageId ?: null;
// 这是一个演示占位符, 你需要实现真实的存储逻辑
// 比如,用一个全局数组模拟 (不适用于生产环境!)
global $messageIdStore;
return $messageIdStore[$paymentId] ?? null;
}
/**
* 将 Payment ID 与其第一封邮件的 Message-ID 关联存储起来
* @param int $paymentId
* @param string $messageId
*/
function store_first_message_id_for_payment(int $paymentId, string $messageId): void {
// 示例:存入数据库
// $db = new PDO(...);
// $stmt = $db->prepare("INSERT INTO payment_email_threads (payment_id, first_message_id) VALUES (?, ?) ON DUPLICATE KEY UPDATE first_message_id = first_message_id"); // 防止重复插入
// $stmt->execute([$paymentId, $messageId]);
// 示例:存入缓存 (设置一个过期时间可能比较好)
// $redis = new Redis();
// $redis->connect('127.0.0.1', 6379);
// $key = "payment_thread_msgid:" . $paymentId;
// $redis->set($key, $messageId, ['ex' => 86400 * 7]); // 缓存 7 天
// 这是一个演示占位符, 你需要实现真实的存储逻辑
global $messageIdStore;
if (!isset($messageIdStore)) {
$messageIdStore = [];
}
// 只在尚未存储时才存储(保证是“第一封”的 ID)
if (!isset($messageIdStore[$paymentId])) {
$messageIdStore[$paymentId] = $messageId;
}
}
?>
第二步:理解和实现存储逻辑
上面代码中的 get_first_message_id_for_payment()
和 store_first_message_id_for_payment()
是核心。你需要根据你的应用架构选择合适的存储方式:
- 数据库 : 如果你的应用已经有数据库,可以创建一个简单的表(比如
email_threads
),包含topic_id
(这里是payment_id
) 和first_message_id
字段。用topic_id
作为主键或唯一索引。 - 缓存 (Redis, Memcached) : 对于需要快速查找并且可以接受一定程度数据丢失(比如缓存过期或清除)的场景,缓存是不错的选择。用
payment_id
作为 key,Message-ID
作为 value。设置一个合理的过期时间。 - 文件系统 : 如果邮件量不大,或者没有数据库/缓存,理论上也可以存到文件里,但不推荐,性能和管理上都比较麻烦。
重点:
- 唯一性保证 :
generateMessageID()
函数要尽可能保证生成的 ID 唯一。 - 原子性 : 获取和存储
firstMessageID
的过程最好能保证原子性,避免并发问题(虽然对于邮件发送来说,稍微有点延迟或冲突可能问题不大,但最好注意)。 - 域名 :
Message-ID
中@
后面的域名应该是你控制的、有效的域名。用localhost
或者无效的域名可能会导致邮件被标记为垃圾邮件。 - References 头累加 (进阶) : 严格来说,
References
头应该包含会话中所有先前邮件的Message-ID
,用空格分隔。比如,第二封邮件References
指向第一封,第三封邮件References
指向第一封和第二封。对于只想让邮件串起来的基本需求,只引用第一封通常也够用了。如果想更规范,可以在获取firstMessageID
的同时,也获取之前的References
内容,然后把firstMessageID
追加(如果不存在的话)到References
列表里。
跑起来试试!
按照上面的思路修改你的邮件发送代码,部署之后再触发几次关于同一个支付 ID 的邮件发送。这次,你应该能在 Gmail 或其他支持邮件串的客户端里看到,这些邮件被漂亮地组织在同一个会话下了。
搞定!一个小小的改动,就能让邮件通知清爽很多。