从评论通知学电子邮件的原理

WordPress 自带了一个当有人评论时发邮件通知管理员的功能,但是没有一个游客的评论被管理回复时通知游客的功能,正好好奇 WordPress 的邮件发送功能是怎么实现的,于是昨天下午就在折腾这个东西,中间由于一些插曲,于是现在才弄好,记录一下。

首先尝试的思路是去找插件,找到一个很久以前的插件,WordPress 官网都已经提示两年没有更新了,估计跟这个版本很难兼容,于是开始自己动手丰衣足食,这样才能学到东西嘛。

找了一下找到这样的一段代码,文章说放到 functions.php 可以生效

/* 邮件通知 by Qiqiboy */
 function comment_mail_notify($comment_id) {
     $comment = get_comment($comment_id);//根据id获取这条评论相关数据
     $content=$comment->comment_content;
     //对评论内容进行匹配
     $match_count=preg_match_all('/<a href="#comment-([0-9]+)?" rel="nofollow">/si',$content,$matchs);
     if($match_count>0){//如果匹配到了
         foreach($matchs[1] as $parent_id){//对每个子匹配都进行邮件发送操作
             SimPaled_send_email($parent_id,$comment);
         }
     }elseif($comment->comment_parent!='0'){//以防万一,有人故意删了@回复,还可以通过查找父级评论id来确定邮件发送对象
         $parent_id=$comment->comment_parent;
         SimPaled_send_email($parent_id,$comment);
     }else return;
 }
 add_action('comment_post', 'comment_mail_notify');
 function SimPaled_send_email($parent_id,$comment){//发送邮件的函数 by Qiqiboy.com
     $admin_email = get_bloginfo ('admin_email');//管理员邮箱
     $parent_comment=get_comment($parent_id);//获取被回复人(或叫父级评论)相关信息
     $author_email=$comment->comment_author_email;//评论人邮箱
     $to = trim($parent_comment->comment_author_email);//被回复人邮箱
     $spam_confirmed = $comment->comment_approved;
     if ($spam_confirmed != 'spam' && $to != $admin_email && $to != $author_email) {
         $wp_email = 'wp@' . preg_replace('#^www.#', '', strtolower($_SERVER['SERVER_NAME'])); // e-mail 發出點, no-reply 可改為可用的 e-mail.
         $subject = '您在 [' . get_option("blogname") . '] 的留言有了回应';
         $message = '<div style="background-color:#eef2fa;border:1px solid #d8e3e8;color:#111;padding:0 15px;-moz-border-radius:5px;-webkit-border-radius:5px;-khtml-border-radius:5px;">
             <p>' . trim(get_comment($parent_id)->comment_author) . ', 您好!</p>
             <p>您曾在《' . get_the_title($comment->comment_post_ID) . '》的留言:<br />'
             . trim(get_comment($parent_id)->comment_content) . '</p>
             <p>' . trim($comment->comment_author) . ' 给你的回复:<br />'
             . trim($comment->comment_content) . '<br /></p>
             <p>您可以点击 <a href="' . htmlspecialchars(get_comment_link($parent_id,array("type" => "all"))) . '">查看回复的完整內容</a></p>
             <p>欢迎再度光临 <a href="' . get_option('home') . '">' . get_option('blogname') . '</a></p>
             <p>(此邮件由系统自动发出, 请勿回复.)</p></div>';
         $from = "From: "" . get_option('blogname') . "" <$wp_email>";
         $headers = "$fromnContent-Type: text/html; charset=" . get_option('blog_charset') . "n";

         // 从全局域获得phpmailer对象
	     global $phpmailer;

		// Make sure the PHPMailer class has been instantiated
		// (copied verbatim from wp-includes/pluggable.php)
		// (Re)create it, if it's gone missing
		if ( !is_object( $phpmailer ) || !is_a( $phpmailer, 'PHPMailer' ) ) {
			require_once ABSPATH . WPINC . '/class-phpmailer.php';
			require_once ABSPATH . WPINC . '/class-smtp.php';
			$phpmailer = new PHPMailer();
		}
		// Set SMTPDebug to level 2
		$phpmailer->SMTPDebug = 2;

		// Start output buffering to grab smtp debugging output
		ob_start();

        $send_result = wp_mail( $to, $subject, $message, $headers );

        ob_get_clean();
         //var_dump($send_result);
         //var_dump($phpmailer);
     }
 }

于是就找到 functions.php 放进去,但是立刻就报错了,重新排查了一下,发现有两个 functions.php 文件,而正确的做法是要放到主题的文件下的 functions.php 文件,于是移过去,再试,还错,于是开始纠结,回去看代码。

这个函数的思路很简单,就是找评论的父节点,然后取得其 email 地址,拼接字符串,调用 wp_mail 函数来发送,最后再使用全局的钩子,把这个函数 hiook 到评论的时候触发。

看来重点在 wp_mail 函数,跟进去看看

function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() ) {
	// Compact the input, apply the filters, and extract them back out
	extract( apply_filters( 'wp_mail', compact( 'to', 'subject', 'message', 'headers', 'attachments' ) ) );

	if ( !is_array($attachments) )
		$attachments = explode( "n", str_replace( "rn", "n", $attachments ) );

	global $phpmailer;

	// (Re)create it, if it's gone missing
	if ( !is_object( $phpmailer ) || !is_a( $phpmailer, 'PHPMailer' ) ) {
		require_once ABSPATH . WPINC . '/class-phpmailer.php';
		require_once ABSPATH . WPINC . '/class-smtp.php';
		$phpmailer = new PHPMailer( true );
	}

	// Headers
	if ( empty( $headers ) ) {
		$headers = array();
	} else {
		if ( !is_array( $headers ) ) {
			// Explode the headers out, so this function can take both
			// string headers and an array of headers.
			$tempheaders = explode( "n", str_replace( "rn", "n", $headers ) );
		} else {
			$tempheaders = $headers;
		}
		$headers = array();
		$cc = array();
		$bcc = array();

		// If it's actually got contents
		if ( !empty( $tempheaders ) ) {
			// Iterate through the raw headers
			foreach ( (array) $tempheaders as $header ) {
				if ( strpos($header, ':') === false ) {
					if ( false !== stripos( $header, 'boundary=' ) ) {
						$parts = preg_split('/boundary=/i', trim( $header ) );
						$boundary = trim( str_replace( array( "'", '"' ), '', $parts[1] ) );
					}
					continue;
				}
				// Explode them out
				list( $name, $content ) = explode( ':', trim( $header ), 2 );

				// Cleanup crew
				$name    = trim( $name    );
				$content = trim( $content );

				switch ( strtolower( $name ) ) {
					// Mainly for legacy -- process a From: header if it's there
					case 'from':
						if ( strpos($content, '<' ) !== false ) {
							// So... making my life hard again?
							$from_name = substr( $content, 0, strpos( $content, '<' ) - 1 );
							$from_name = str_replace( '"', '', $from_name );
							$from_name = trim( $from_name );

							$from_email = substr( $content, strpos( $content, '<' ) + 1 );
							$from_email = str_replace( '>', '', $from_email );
							$from_email = trim( $from_email );
						} else {
							$from_email = trim( $content );
						}
						break;
					case 'content-type':
						if ( strpos( $content, ';' ) !== false ) {
							list( $type, $charset ) = explode( ';', $content );
							$content_type = trim( $type );
							if ( false !== stripos( $charset, 'charset=' ) ) {
								$charset = trim( str_replace( array( 'charset=', '"' ), '', $charset ) );
							} elseif ( false !== stripos( $charset, 'boundary=' ) ) {
								$boundary = trim( str_replace( array( 'BOUNDARY=', 'boundary=', '"' ), '', $charset ) );
								$charset = '';
							}
						} else {
							$content_type = trim( $content );
						}
						break;
					case 'cc':
						$cc = array_merge( (array) $cc, explode( ',', $content ) );
						break;
					case 'bcc':
						$bcc = array_merge( (array) $bcc, explode( ',', $content ) );
						break;
					default:
						// Add it to our grand headers array
						$headers[trim( $name )] = trim( $content );
						break;
				}
			}
		}
	}

	// Empty out the values that may be set
	$phpmailer->ClearAddresses();
	$phpmailer->ClearAllRecipients();
	$phpmailer->ClearAttachments();
	$phpmailer->ClearBCCs();
	$phpmailer->ClearCCs();
	$phpmailer->ClearCustomHeaders();
	$phpmailer->ClearReplyTos();

	// From email and name
	// If we don't have a name from the input headers
	if ( !isset( $from_name ) )
		$from_name = 'WordPress';

	/* If we don't have an email from the input headers default to wordpress@$sitename
	 * Some hosts will block outgoing mail from this address if it doesn't exist but
	 * there's no easy alternative. Defaulting to admin_email might appear to be another
	 * option but some hosts may refuse to relay mail from an unknown domain. See
	 * http://trac.wordpress.org/ticket/5007.
	 */

	if ( !isset( $from_email ) ) {
		// Get the site domain and get rid of www.
		$sitename = strtolower( $_SERVER['SERVER_NAME'] );
		if ( substr( $sitename, 0, 4 ) == 'www.' ) {
			$sitename = substr( $sitename, 4 );
		}

		$from_email = 'wordpress@' . $sitename;
	}

	// Plugin authors can override the potentially troublesome default
	$phpmailer->From     = apply_filters( 'wp_mail_from'     , $from_email );
	$phpmailer->FromName = apply_filters( 'wp_mail_from_name', $from_name  );

	// Set destination addresses
	if ( !is_array( $to ) )
		$to = explode( ',', $to );

	foreach ( (array) $to as $recipient ) {
		try {
			// Break $recipient into name and address parts if in the format "Foo <bar@baz.com>"
			$recipient_name = '';
			if( preg_match( '/(.+)s?<(.+)>/', $recipient, $matches ) ) {
				if ( count( $matches ) == 3 ) {
					$recipient_name = $matches[1];
					$recipient = $matches[2];
				}
			}
			$phpmailer->AddAddress( trim( $recipient ), $recipient_name);
		} catch ( phpmailerException $e ) {
			continue;
		}
	}

	// Set mail's subject and body
	$phpmailer->Subject = $subject;
	$phpmailer->Body    = $message;

	// Add any CC and BCC recipients
	if ( !empty( $cc ) ) {
		foreach ( (array) $cc as $recipient ) {
			try {
				// Break $recipient into name and address parts if in the format "Foo <bar@baz.com>"
				$recipient_name = '';
				if( preg_match( '/(.+)s?<(.+)>/', $recipient, $matches ) ) {
					if ( count( $matches ) == 3 ) {
						$recipient_name = $matches[1];
						$recipient = $matches[2];
					}
				}
				$phpmailer->AddCc( trim($recipient), $recipient_name );
			} catch ( phpmailerException $e ) {
				continue;
			}
		}
	}

	if ( !empty( $bcc ) ) {
		foreach ( (array) $bcc as $recipient) {
			try {
				// Break $recipient into name and address parts if in the format "Foo <bar@baz.com>"
				$recipient_name = '';
				if( preg_match( '/(.+)s?<(.+)>/', $recipient, $matches ) ) {
					if ( count( $matches ) == 3 ) {
						$recipient_name = $matches[1];
						$recipient = $matches[2];
					}
				}
				$phpmailer->AddBcc( trim($recipient), $recipient_name );
			} catch ( phpmailerException $e ) {
				continue;
			}
		}
	}

	// Set to use PHP's mail()
	$phpmailer->IsMail();

	// Set Content-Type and charset
	// If we don't have a content-type from the input headers
	if ( !isset( $content_type ) )
		$content_type = 'text/plain';

	$content_type = apply_filters( 'wp_mail_content_type', $content_type );

	$phpmailer->ContentType = $content_type;

	// Set whether it's plaintext, depending on $content_type
	if ( 'text/html' == $content_type )
		$phpmailer->IsHTML( true );

	// If we don't have a charset from the input headers
	if ( !isset( $charset ) )
		$charset = get_bloginfo( 'charset' );

	// Set the content-type and charset
	$phpmailer->CharSet = apply_filters( 'wp_mail_charset', $charset );

	// Set custom headers
	if ( !empty( $headers ) ) {
		foreach( (array) $headers as $name => $content ) {
			$phpmailer->AddCustomHeader( sprintf( '%1$s: %2$s', $name, $content ) );
		}

		if ( false !== stripos( $content_type, 'multipart' ) && ! empty($boundary) )
			$phpmailer->AddCustomHeader( sprintf( "Content-Type: %s;nt boundary="%s"", $content_type, $boundary ) );
	}

	if ( !empty( $attachments ) ) {
		foreach ( $attachments as $attachment ) {
			try {
				$phpmailer->AddAttachment($attachment);
			} catch ( phpmailerException $e ) {
				continue;
			}
		}
	}

	do_action_ref_array( 'phpmailer_init', array( &$phpmailer ) );

	// Send!
	try {
		$phpmailer->Send();
	} catch ( phpmailerException $e ) {
		return false;
	}

	return true;
}

这个函数做的事情其实比较基本,就是根据 SMTP 协议来准备字符串,处理附件之类的事情。

这里需要温习一下 SMTP 协议,之前安装 WordPress 的时候也说过,想不通邮件的发送机理,于是最近就好好的读了一下 TCP/IP 协议族的东西,现在理论上基本理解了,其实 SMTP 无非就是两个邮箱服务器之间基于 TCP 流上的字符串交互,从 TCP 的层面看,跟 HTTP 没有本质区别,而这些 TCP 也好, UDP 也好的数据包,从 IP 层面也是没有区别的,本质都是一个要送达互联网另一端主机的数据包。

SMTP 协议的交互是类似 FTP , HTTP 那样的,发起方发送一条开始通信的字符串,接收方开始回应,指示下一步动作,例如要求发送者邮箱,要求回送邮箱,要求正文等等,发送方就依次按照要求,把这些数据一一交付,接收方把这些数据投放到对应的用户的邮箱,然后回应成功或者用户没找到之类的,就这么结束。

根据维基百科,一次典型的交互类似这样

S: 220 www.example.com ESMTP Postfix
C: HELO mydomain.com
S: 250 Hello mydomain.com
C: MAIL FROM: <sender@mydomain.com>
S: 250 Ok
C: RCPT TO: <friend@example.com>
S: 250 Ok
C: DATA
S: 354 End data with <CR><LF>.<CR><LF>
C: Subject: test message
C: From:""< sender@mydomain.com>
C: To:""< friend@example.com>
C:
C: Hello,
C: This is a test.
C: Goodbye.
C: .
S: 250 Ok: queued as 12345
C: quit
S: 221 Bye

可以看到也是类似 HTTP 那样用 200 , 250 ,之类的数码来表示状态的。

至于附件,这个 HTTP 也比较类似,一开始的时候是没有考虑附件的,后来有这个需求的时候,面临的问题就是怎么兼容,让字符形式可以传递二进制的文件,解决的方案是使用 Base64 编码,顾名思义,Base64 就是一种使用 a-z A-z 0-9 共 26 + 26 + 10 = 62 加上另外两个其他的可打印字符来编码二进制文件的方法,这样的目的就是可以用字符来承载二进制,进行传输,进一步的了解可以看维基百科,http://zh.wikipedia.org/wiki/B… ,有意思的是,SMTP 的文本附件混杂也是像 HTTP 那样用 boundary 来分割的。这点在上面的函数中也有体现。

现在再来回头看前面的函数就比较好理解了,无非就是准备一些交流的 SMTP 内容,跟对方服务器交互,最后看到调用了 phpmailer 对象的方法把东西发出去,这个的内容比较长,就不贴出来了,进去是一个封装的很好的发送类,基本上只要关心接口就可以。

另外,由于 SAE 不支持 php 的 mail 函数直接发送内容,于是只能通过第三方的 SMTP 服务,搜了一下发现腾讯有域名邮箱服务,另外还有一个企业邮箱服务,相同点是都可以使用自己的域名,但是据说企业邮箱的功能强大些,于是出于测试目的,先注册企业邮箱,基本的工作原理是我们需要在 DNS 解析服务器那里,把邮件的 MX 类型的 DNS 请求解析到腾讯的邮箱服务器,也就是说,所有发往我们自定义域名的邮件,都会送到腾讯的服务器,于是去改 DNS 记录,改好了,在 SMTP 的插件中填好用户名密码,开始调试。

那么基本理解了 SMTP 的原理之后,要怎么查问题呢,回去看 SAE 上面的 SMTP 插件,里面有一个发送测试邮件的方法,里面会详尽的输出 debug 信息,写法类似这样,

// Set up the mail variables
$to = $_POST['to'];
$subject = 'WP Mail SMTP: ' . __('Test mail to ', 'wp_mail_smtp') . $to;
$message = __('This is a test email generated by the WP Mail SMTP WordPress plugin.', 'wp_mail_smtp');

// Set SMTPDebug to level 2
$phpmailer->SMTPDebug = 2;

// Start output buffering to grab smtp debugging output
ob_start();

// Send the test mail
$result = wp_mail($to,$subject,$message);

// Strip out the language strings which confuse users
//unset($phpmailer->language);
// This property became protected in WP 3.2

// Grab the smtp debugging output
$smtp_debug = ob_get_clean();

// Output the response

于是照猫画虎,搬到发送的函数里面,终于从服务器的回应中看到,回复邮箱出错,看回代码,我们自己的邮箱是 wp 开头的,但是回复的邮箱却是写的 no-reply 开头的,这样就被服务器拒接了,于是把 no-reply 改成自己的邮箱名,就成功的发出了邮件。

昨天下午就到了这个步骤,但是想到注销企业邮箱试试域名邮箱的时候,却发现一直提示说“域名出于24小时保护期内”,不知道为什么要设这个限制,难道是怕 DNS 解析错乱吗,不管怎么用,知道今天才成功的改换到域名邮箱,其实,域名邮箱跟企业邮箱的区别可以这么来说,企业邮箱就是一套完整的系统,甚至包括一些内部的公告,交流等,是实实在在会多一个邮箱可以用,而域名邮箱,本质是只是给自己的 QQ 邮箱增加一个自定义的域名而已,并没有多一个邮箱。

One thought on “从评论通知学电子邮件的原理

  1. Pingback: 把 WordPress 搬出 SAE | ZRJ

Leave a Reply

Your email address will not be published. Required fields are marked *