用PHP實現一個Amazon SES的代理服務器

看懂這篇文章須要你有必定的SES使用基礎,若是你不明白,能夠看這個問題裏的討論 http://segmentfault.com/q/1010000000095210php

SES的全稱是Simple Email Service,它是亞馬遜公司推出的一個郵件基礎服務。做爲AWS基礎服務的一部分,它繼承了AWS的傳統優點 -- 便宜redis

是的,真的很是便宜。這就是爲何我沒用mailgun或者其它什麼更牛逼郵件服務的緣由。若是每個月你發10萬封郵件的話,基本也只須要支付十多美刀左右。這和其它那些動輒上百美刀起步的服務來講,價格優點很大。因此,憑着這個我也能忍受它的諸多缺點。segmentfault

可是隨着國內用SES的人增多,他在去年末的某一天忽然被牆了,這可要了命了。因而,我開始嘗試在境外本身的服務器上作一層代理來繼續使用這個服務。同時這也提供了一個契機,讓我能夠有機會對它的api做出改進來實現一些更有價值的功能,好比郵件羣發。api

所以我沒有用境外服務器直接作一個反向代理來玩,這樣只是解決了表面上的問題,但我擴展功能的需求就不可能實現了。所以我爲設計這個SES代理訂立了兩個基本目標服務器

  1. 徹底兼容原有api接口,這意味着原有代碼基本不須要改變就能夠用代理
  2. 實現郵件羣發功能

實現第一點其實很是簡單,其實就是用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郵件代理服務器的整個思路,歡迎你們一同來探討。

相關文章
相關標籤/搜索