10

看懂这篇文章需要你有一定的SES使用基础,如果你不明白,可以看这个问题里的讨论 http://segmentfault.com/q/1010000000095210

SES的全称是Simple Email Service,它是亚马逊公司推出的一个邮件基础服务。作为AWS基础服务的一部分,它继承了AWS的传统优势 -- 便宜

是的,真的非常便宜。这就是为什么我没用mailgun或者其它什么更牛逼邮件服务的原因。如果每月你发10万封邮件的话,基本也只需要支付十多美刀左右。这和其它那些动辄上百美刀起步的服务来说,价格优势很大。所以,凭着这个我也能忍受它的诸多缺点。

但是随着国内用SES的人增多,他在去年底的某一天突然被墙了,这可要了命了。于是,我开始尝试在境外自己的服务器上做一层代理来继续使用这个服务。同时这也提供了一个契机,让我可以有机会对它的api作出改进来实现一些更有价值的功能,比如邮件群发。

因此我没有用境外服务器直接做一个反向代理来玩,这样只是解决了表面上的问题,但我扩展功能的需求就不可能实现了。因此我为设计这个SES代理订立了两个基本目标

  1. 完全兼容原有api接口,这意味着原有代码基本不需要改变就可以用代理
  2. 实现邮件群发功能

实现第一点其实非常简单,其实就是用php实现了一个反向代理,把发送过来的参数接收到,然后组装后使用curl组件发送给真正的SES服务器,取得回执后再直接输出给客户端。这就是一个标准的代理流程,下面给出我的代码,里面重要的部分我都给出了注释

需要注意的是这些代码需要放在域名的根目录下,当然二级域名也可以

<?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的私有函数,这个函数实际上是实现群发邮件的关键。

说到这里我不得不提一下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邮件代理服务器的整个思路,欢迎大家一同来探讨。


joyqi
16.2k 声望2.3k 粉丝

我的生涯一片无悔,想起那天夕阳下的奔跑,那是我逝去的青春