返回

PHPMailer教程:修复Gmail邮件相同主题不分组/不成串

php

让 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)里的几个特定字段,而不是简单地看主题是否一致。这几个关键的头信息是:

  1. Message-ID : 每封邮件都应该有一个全局唯一的标识符。就像人的身份证号,理论上不应该重复。
  2. References : 当一封邮件是对之前邮件的回复或转发时,这个字段会包含它引用的所有父邮件的 Message-ID,形成一个引用链。
  3. In-Reply-To : 这个字段通常包含这封邮件直接回复的那封邮件的 Message-ID

邮件客户端会检查新邮件的 ReferencesIn-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 的邮件有任何关联。所以,它不会自动添加 ReferencesIn-Reply-To 头信息来指向之前的邮件。

结果就是,每一封邮件虽然主题相同,但因为有着不同的 Message-ID 并且缺少关联性的 ReferencesIn-Reply-To 头信息,邮件客户端就认为它们是独立的、不相关的邮件。自然就不会把它们串在一起了。

解决方案:手动添加邮件头信息

要让这些关于同一主题的邮件串起来,咱们需要手动给后续的邮件添加 ReferencesIn-Reply-To 头信息,告诉邮件客户端:“嘿,这封邮件是接着前面那封说的!”。

这需要做几件事:

  1. 识别主题 : 需要一个明确的方式来识别哪些邮件应该属于同一个会话。在例子中,Payment ID {$payment->id} 就是一个很好的标识符。
  2. 记录首封邮件的 Message-ID : 当发送关于某个主题(比如某个 Payment ID)的 第一封 邮件时,需要获取并保存这封邮件的 Message-ID
  3. 为后续邮件添加头信息 : 当发送关于 同一个 主题的 后续 邮件时,取出之前保存的那个 Message-ID,并将其添加到新邮件的 In-Reply-ToReferences 头信息里。

下面是具体的步骤和代码调整:

第一步:为邮件生成并设置 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 或其他支持邮件串的客户端里看到,这些邮件被漂亮地组织在同一个会话下了。

搞定!一个小小的改动,就能让邮件通知清爽很多。