看懂这篇文章须要你有必定的SES使用基础,若是你不明白,能够看这个问题里的讨论 http://segmentfault.com/q/1010000000095210php
SES的全称是Simple Email Service
,它是亚马逊公司推出的一个邮件基础服务。做为AWS基础服务的一部分,它继承了AWS的传统优点 -- 便宜。redis
是的,真的很是便宜。这就是为何我没用mailgun或者其它什么更牛逼邮件服务的缘由。若是每个月你发10万封邮件的话,基本也只须要支付十多美刀左右。这和其它那些动辄上百美刀起步的服务来讲,价格优点很大。因此,凭着这个我也能忍受它的诸多缺点。segmentfault
可是随着国内用SES的人增多,他在去年末的某一天忽然被墙了,这可要了命了。因而,我开始尝试在境外本身的服务器上作一层代理来继续使用这个服务。同时这也提供了一个契机,让我能够有机会对它的api做出改进来实现一些更有价值的功能,好比邮件群发。api
所以我没有用境外服务器直接作一个反向代理来玩,这样只是解决了表面上的问题,但我扩展功能的需求就不可能实现了。所以我为设计这个SES代理订立了两个基本目标服务器
实现第一点其实很是简单,其实就是用php实现了一个反向代理,把发送过来的参数接收到,而后组装后使用curl
组件发送给真正的SES服务器,取得回执后再直接输出给客户端。这就是一个标准的代理流程,下面给出个人代码,里面重要的部分我都给出了注释并发
须要注意的是这些代码须要放在域名的根目录下,固然二级域名也能够app
<?php include __DIR__ . '/includes.php'; // 这里是几个比较重要的header,其它不须要关注 $headers = array( 'Date: ' . get_header('Date'), 'Host: ' . SES_HOST, 'X-Amzn-Authorization: ' . get_header('X-Amzn-Authorization') ); // 而后再次组装url以请求这正的SES服务器 $url = 'https://' . SES_HOST . '/' . (empty($_SERVER['QUERY_STRING']) ? '' : '?' . $_SERVER['QUERY_STRING']); $ch = curl_init(); curl_setopt($ch, CURLOPT_USERAGENT, 'SimpleEmailService/php'); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 1); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 1); // 须要处理的就是`POST`和`DELETE`方法,`GET`方法比较繁多我就不一一实现了 // 其实都是一些得到当前信息的方法,这些信息你能够直接到后台看 switch ($_SERVER['REQUEST_METHOD']) { case 'GET': break; case 'POST': global $HTTP_RAW_POST_DATA; $data = empty($HTTP_RAW_POST_DATA) ? file_get_contents('php://input') : $HTTP_RAW_POST_DATA; $headers[] = 'Content-Type: application/x-www-form-urlencoded'; parse_data($data); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST'); curl_setopt($ch, CURLOPT_POSTFIELDS, $data); break; case 'DELETE': curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); break; default: break; } curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_HEADER, false); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); $response = curl_exec($ch); $content_type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); header('Content-Type: ' . $content_type, true, $status); echo $response;
这段代码很是简单,但也有些技巧须要注意,其中我处理POST
方法时使用了一个名为parse_data
的私有函数,这个函数其实是实现群发邮件的关键。curl
说到这里我不得不提一下SES发邮件的API,SES只提供一个简单的邮件发送API,其中它的发送对象支持多个,但当你发送给多个收件人时,它也会在收件人栏看到其余收件人的地址。固然它也支持cc
或者bcc
的抄送功能,但当你在使用这种抄送功能来实现群发邮件时,收件者会看到本身是在抄送对象中,而不是在接收人中。对于一个正规网站来讲,这些显然是不能容忍的。异步
所以咱们须要真正的并发接口来发送邮件,要知道SES分配给个人配额是每秒钟能够发送28封邮件(每人配额不一样),要是彻底利用的话每小时能够发送10万封邮件,彻底能够知足中型网站的需求了。函数
所以我产生了一个想法,在彻底不改变客户端接口的状况下,我在代理服务器上将发送过来的有多个收件人的一封邮件拆包成一个一个单个收件人的多封邮件,而后再将这些邮件用异步队列的方式发送到SES上。这就是parse_data
函数所作的事情,下面我直接给出includes.php
里的代码,这里包含了全部要用到的私有函数,前面的define
定义请根据本身的需求修改
<?php define('REDIS_HOST', '127.0.0.1'); define('REDIS_PORT', 6379); define('SES_HOST', 'email.us-east-1.amazonaws.com'); define('SES_KEY', ''); define('SES_SECRET', ''); /** * get_header * * @param mixed $name * @access public * @return void */ function get_header($name) { $name = 'HTTP_' . strtoupper(str_replace('-', '_', $name)); return isset($_SERVER[$name]) ? $_SERVER[$name] : ''; } /** * my_parse_str * * @param mixed $query * @param mixed $params * @access public * @return void */ function my_parse_str($query, &$params) { if (empty($query)) { return; } $decode = function ($str) { return rawurldecode(str_replace('~', '%7E', $str)); }; $data = explode('&', $query); $params = array(); foreach ($data as $value) { list ($key, $val) = explode('=', $value, 2); if (isset($params[$key])) { if (!is_array($params[$key])) { $params[$key] = array($params[$key]); } $params[$key][] = $val; } else { $params[$key] = $decode($val); } } } /** * my_urlencode * * @param mixed $str * @access public * @return void */ function my_urlencode($str) { return str_replace('%7E', '~', rawurlencode($str)); } /** * my_build_query * * @param mixed $params * @access public * @return void */ function my_build_query($parameters) { $params = array(); foreach ($parameters as $var => $value) { if (is_array($value)) { foreach ($value as $v) { $params[] = $var.'='.my_urlencode($v); } } else { $params[] = $var.'='.my_urlencode($value); } } sort($params, SORT_STRING); return implode('&', $params); } /** * my_headers * * @param mixed $headers * @access public * @return void */ function my_headers() { $date = gmdate('D, d M Y H:i:s e'); $sig = base64_encode(hash_hmac('sha256', $date, SES_SECRET, true)); $headers = array(); $headers[] = 'Date: ' . $date; $headers[] = 'Host: ' . SES_HOST; $auth = 'AWS3-HTTPS AWSAccessKeyId=' . SES_KEY; $auth .= ',Algorithm=HmacSHA256,Signature=' . $sig; $headers[] = 'X-Amzn-Authorization: ' . $auth; $headers[] = 'Content-Type: application/x-www-form-urlencoded'; return $headers; } /** * parse_data * * @param mixed $data * @access public * @return void */ function parse_data(&$data) { my_parse_str($data, $params); if (!empty($params)) { $redis = new Redis(); $redis->connect(REDIS_HOST, REDIS_PORT); // 多个发送地址 if (isset($params['Destination.ToAddresses.member.2'])) { $address = array(); $mKey = uniqid(); $i = 2; while (isset($params['Destination.ToAddresses.member.' . $i])) { $aKey = uniqid(); $key = 'Destination.ToAddresses.member.' . $i; $address[$aKey] = $params[$key]; unset($params[$key]); $i ++; } $data = my_build_query($params); unset($params['Destination.ToAddresses.member.1']); $redis->set('m:' . $mKey, my_build_query($params)); foreach ($address as $k => $a) { $redis->hSet('a:' . $mKey, $k, $a); $redis->lPush('mail', $k . '|' . $mKey); } } } }
能够看到parse_data
函数从第二个收件人开始,把它们组装成一个一个单独的邮件,放到redis队列里,供其余独立进程读取发送。
为何不从第一个收件人开始?
由于要兼容原有协议,客户端发过来一个发邮件请求你总要给它返回一个东西吧,我又懒得伪造,所以它的第一个收件人的发邮件请求是直接发出去了,而并无进入队列,这样我能够取得一个真实的SES服务器回执返回给客户端,客户端代码也无需作任何修改,就能够处理这个返回。
SES的邮件都是要签名的怎么办?
是的,全部的SES邮件都须要签名。所以在你解包之后,邮件数据改变了,所以签名也必须改变。my_build_query
函数就是作这个事情的,它会对请求参数作从新签名。
下面是这个代理系统的最后一个组成部分,邮件发送队列实现,它也是一个php文件,你能够根据本身的配额大小,在后台用nohup php
命令启动若干个php进程,来实现并发邮件发送。它的结构也很是简单,就是读取队列里的邮件而后用curl
发送请求
<?php include __DIR__ . '/includes.php'; $redis = new Redis(); $redis->connect(REDIS_HOST, REDIS_PORT); do { $pop = $redis->brPop('mail', 10); if (empty($pop)) { continue; } list ($k, $id) = $pop; list($aKey, $mKey) = explode('|', $id); $address = $redis->hGet('a:' . $mKey, $aKey); if (empty($address)) { continue; } $data = $redis->get('m:' . $mKey); if (empty($data)) { continue; } my_parse_str($data, $params); $params['Destination.ToAddresses.member.1'] = $address; $data = my_build_query($params); $headers = my_headers(); $url = 'https://' . SES_HOST . '/'; $ch = curl_init(); curl_setopt($ch, CURLOPT_USERAGENT, 'SimpleEmailService/php'); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST'); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $data); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_TIMEOUT, 10); curl_exec($ch); curl_close($ch); unset($ch); unset($data); } while (true);
以上就是我编写SES邮件代理服务器的整个思路,欢迎你们一同来探讨。