-
-
Save jezek/d3f9e1b92cbacf990bbcd078c5469fc8 to your computer and use it in GitHub Desktop.
<?php | |
/** | |
* Zend_Http_Client extended for a function to sign a request for AmazonSES with signature version 4. | |
* | |
* @author jEzEk - 20210222 | |
*/ | |
class Zend_Http_Client_AmazonSES_SV4 extends Zend_Http_Client { | |
const HASH_ALGORITHM = 'sha256'; | |
public static $SESAlgorithms = [ | |
self::HASH_ALGORITHM => 'AWS4-HMAC-SHA256', | |
]; | |
/** | |
* Returns header string containing encoded authentication key needed for signature version 4 as described in https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html | |
* | |
* @param DateTime $date | |
* @param string $region | |
* @param string $service | |
* @param string $accessKey | |
* @param string $privateKey | |
* @return string | |
* | |
*/ | |
public function buildAuthKey(DateTime $date, $region, $service, $accessKey, $privateKey){ | |
//Mage::log(__METHOD__); | |
$longDate = $date->format('Ymd\THis\Z'); | |
$shortDate = $date->format('Ymd'); | |
// Add minimal headers | |
$this->setHeaders([ | |
'Host' => $this->uri->getHost(), | |
'X-Amz-Date' => $longDate, | |
]); | |
// Task 1: Create a canonical request for Signature Version 4 | |
// 1. Start with the HTTP request method (GET, PUT, POST, etc.), followed by a newline character. | |
$method = $this->method . "\n"; | |
// 2. Add the canonical URI parameter, followed by a newline character | |
$canonicalUri = $this->pathEncode($this->uri->getPath()) . "\n"; | |
// 3. Add the canonical query string, followed by a newline character. | |
$canonicalQuery = $this->getQuery() . "\n"; | |
// 4. Add the canonical headers, followed by a newline character. | |
$canonicalHeaders = ""; | |
$headers = $this->headers; | |
ksort($headers, SORT_STRING); | |
foreach ($headers as $k => $v) { | |
$canonicalHeaders .= $k . ':' . $this->trimAllSpaces($v[1]) . "\n"; | |
} | |
$canonicalHeaders .= "\n"; | |
// 5. Add the signed headers, followed by a newline character. | |
$signedHeaders = implode(';', array_keys($headers)) . "\n"; | |
// 6. Use a hash (digest) function like SHA256 to create a hashed value from the payload in the body of the HTTP or HTTPS request. | |
$hashedPayload = $this->hash($this->_prepareBody()); | |
// 7. To construct the finished canonical request, combine all the components from each step as a single string. | |
$canonicalRequest = $method . $canonicalUri . $canonicalQuery . $canonicalHeaders . $signedHeaders . $hashedPayload; | |
//Mage::log('canonicalRequest:'); | |
//Mage::log("#####\n" . $canonicalRequest . "\n#####"); | |
// 8. Create a digest (hash) of the canonical request with the same algorithm that you used to hash the payload. | |
$hashedCanonicalRequest = $this->hash($canonicalRequest); | |
// Task 2: | |
// 1. Start with the algorithm designation, followed by a newline character. | |
$algorithm = self::$SESAlgorithms[self::HASH_ALGORITHM] . "\n"; | |
// 2. Append the request date value, followed by a newline character. | |
$requestDateTime = $longDate . "\n"; | |
// 3. Append the credential scope value, followed by a newline character. | |
$credentialScope = $shortDate . '/' .$region. '/' .$service. '/aws4_request' . "\n"; | |
// 4. Append the hash of the canonical request that you created in Task 1: Create a canonical request for Signature Version 4. | |
$stringToSign = $algorithm . $requestDateTime . $credentialScope . $hashedCanonicalRequest; | |
//Mage::log('stringToSign:'); | |
//Mage::log("#####\n" . $stringToSign . "\n#####"); | |
// Task 3: Calculate the signature for AWS Signature Version 4 | |
// 1. Derive your signing key. | |
$dateKey = hash_hmac(self::HASH_ALGORITHM, $shortDate, 'AWS4' . $privateKey, true); | |
$regionKey = hash_hmac(self::HASH_ALGORITHM, $region, $dateKey, true); | |
$serviceKey = hash_hmac(self::HASH_ALGORITHM, $service, $regionKey, true); | |
$signingKey = hash_hmac(self::HASH_ALGORITHM, 'aws4_request', $serviceKey, true); | |
// 2. Calculate the signature. | |
$signature = hash_hmac(self::HASH_ALGORITHM, $stringToSign, $signingKey); | |
// Task 4: Add the signature to the HTTP request | |
// Return string for HTTP Authorization header | |
return trim($algorithm, "\n") . ' Credential=' . $accessKey . '/' . trim($credentialScope, "\n") . ', SignedHeaders=' . trim($signedHeaders, "\n") . ', Signature=' . $signature; | |
} | |
protected function pathEncode($path) { | |
$encoded = []; | |
foreach (explode('/', $path) as $k => $v) { | |
$encoded[] = rawurlencode(rawurlencode($v)); | |
} | |
return implode('/', $encoded); | |
} | |
protected function trimAllSpaces($text) { | |
return trim(preg_replace('| +|', ' ', $text), ' '); | |
} | |
protected function hash($text) { | |
return hash(self::HASH_ALGORITHM, $text); | |
} | |
protected function getQuery() { | |
// From Zend_Http_Client:L946 | |
// Clone the URI and add the additional GET parameters to it | |
$uri = clone $this->uri; | |
if (! empty($this->paramsGet)) { | |
$query = $uri->getQuery(); | |
if (! empty($query)) { | |
$query .= '&'; | |
} | |
$query .= http_build_query($this->paramsGet, null, '&'); | |
if ($this->config['rfc3986_strict']) { | |
$query = str_replace('+', '%20', $query); | |
} | |
$uri->setQuery($query); | |
} | |
return $uri->getQuery(); | |
} | |
} | |
/** | |
* Amazon Simple Email Service (SES) connection object | |
* | |
* Integration between Zend Framework and Amazon Simple Email Service | |
* | |
* @category Zend | |
* @package Zend_Mail | |
* @subpackage Transport | |
* @author Christopher Valles <[email protected]> | |
* @license http://framework.zend.com/license/new-bsd New BSD License | |
*/ | |
class App_Mail_Transport_AmazonSES extends Zend_Mail_Transport_Abstract | |
{ | |
/** | |
* Template of the webservice body request | |
* | |
* @var string | |
*/ | |
protected $_bodyRequestTemplate = 'Action=SendRawEmail&Source=%s&%s&RawMessage.Data=%s'; | |
/** | |
* Remote smtp hostname or i.p. | |
* | |
* @var string | |
*/ | |
protected $_host; | |
/** | |
* Amazon Access Key | |
* | |
* @var string|null | |
*/ | |
protected $_accessKey; | |
/** | |
* Amazon private key | |
* | |
* @var string|null | |
*/ | |
protected $_privateKey; | |
/** | |
* Amazon region endpoint | |
* | |
* @var string|null | |
*/ | |
protected $_region; | |
private $endpoints = array( | |
'US-EAST-1' => 'email.us-east-1.amazonaws.com', | |
'US-WEST-2' => 'email.us-west-2.amazonaws.com', | |
'EU-WEST-1' => 'email.eu-west-1.amazonaws.com', | |
'EU-CENTRAL-1' => 'email.eu-central-1.amazonaws.com', | |
); | |
/** | |
* Constructor. | |
* | |
* @param array|null $config (Default: null) | |
* @param string $host (Default: https://email.us-east-1.amazonaws.com) | |
* @return void | |
* @throws Zend_Mail_Transport_Exception if accessKey is not present in the config | |
* @throws Zend_Mail_Transport_Exception if privateKey is not present in the config | |
*/ | |
public function __construct(Array $config = array(), $region = 'US-EAST-1') | |
{ | |
if(!array_key_exists('accessKey', $config)){ | |
throw new Zend_Mail_Transport_Exception('This transport requires the Amazon access key'); | |
} | |
if(!array_key_exists('privateKey', $config)){ | |
throw new Zend_Mail_Transport_Exception('This transport requires the Amazon private key'); | |
} | |
$this->_accessKey = $config['accessKey']; | |
$this->_privateKey = $config['privateKey']; | |
$this->_region = $region; | |
$this->setRegion($region); | |
} | |
public function setRegion($region) { | |
if(!isset($this->endpoints[$region])) { | |
throw new InvalidArgumentException('Region unrecognised'); | |
} | |
return $this->_host = Zend_Uri::factory("https://" . $this->endpoints[$region]); | |
} | |
/** | |
* Send an email using the amazon webservice api | |
* | |
* @return void | |
*/ | |
public function _sendMail() | |
{ | |
//Build the parameters | |
$params = array( | |
'Action' => 'SendRawEmail', | |
'Source' => $this->_mail->getFrom(), | |
'RawMessage.Data' => base64_encode(sprintf("%s\n%s\n", $this->header, $this->body)) | |
); | |
$recipients = explode(',', $this->recipients); | |
while(list($index, $recipient) = each($recipients)){ | |
$params[sprintf('Destinations.member.%d', $index + 1)] = $recipient; | |
} | |
// Create client | |
$client = new Zend_Http_Client_AmazonSES_SV4($this->_host); | |
$client->setMethod(Zend_Http_Client::POST); | |
$client->setParameterPost($params); | |
// Add authorization header | |
$client->setHeaders(array( | |
'Authorization' => $client->buildAuthKey(new DateTime('NOW'), strtolower($this->_region), 'email', $this->_accessKey, $this->_privateKey) | |
)); | |
// Send request | |
$response = $client->request(Zend_Http_Client::POST); | |
if($response->getStatus() != 200){ | |
throw new Exception($response->getBody()); | |
} | |
} | |
public function getSendStats() | |
{ | |
//Build the parameters | |
$params = array( | |
'Action' => 'GetSendStatistics' | |
); | |
// Create client | |
$client = new Zend_Http_Client_AmazonSES_SV4($this->_host); | |
$client->setMethod(Zend_Http_Client::POST); | |
$client->setParameterPost($params); | |
// hhvm Invalid chunk size fix - force HTTP 1.0 | |
$client->setConfig(array( | |
'httpversion' => Zend_Http_Client::HTTP_0, | |
)); | |
// ----- | |
// Add authorization header | |
$client->setHeaders(array( | |
'Authorization' => $client->buildAuthKey(new DateTime('NOW'), strtolower($this->_region), 'email', $this->_accessKey, $this->_privateKey) | |
)); | |
// Send request | |
$response = $client->request(Zend_Http_Client::POST); | |
if($response->getStatus() != 200){ | |
throw new Exception($response->getBody()); | |
} | |
return $response->getBody(); | |
} | |
/** | |
* Format and fix headers | |
* | |
* Some SMTP servers do not strip BCC headers. Most clients do it themselves as do we. | |
* | |
* @access protected | |
* @param array $headers | |
* @return void | |
* @throws Zend_Transport_Exception | |
*/ | |
protected function _prepareHeaders($headers) | |
{ | |
if (!$this->_mail) { | |
/** | |
* @see Zend_Mail_Transport_Exception | |
*/ | |
throw new Zend_Mail_Transport_Exception('_prepareHeaders requires a registered Zend_Mail object'); | |
} | |
unset($headers['Bcc']); | |
// Prepare headers | |
parent::_prepareHeaders($headers); | |
} | |
/** | |
* Returns header string containing encoded authentication key | |
* | |
* @param date $date | |
* @return string | |
*/ | |
private function _buildAuthKey($date){ | |
return sprintf('AWS3-HTTPS AWSAccessKeyId=%s,Algorithm=HmacSHA256,Signature=%s', $this->_accessKey, base64_encode(hash_hmac('sha256', $date, $this->_privateKey, TRUE))); | |
} | |
} |
Thanks a lot for the working code! AWS kill brains with their official docs as usuall!
Thx one more time)
Maybe this was written with a slightly different version to ours. Was seeing Region unrecognised
exception
I had to make this change
class Aschroder_SMTPPro_Model_Transports_Ses {
public function getTransport($storeId) {
$_helper = Mage::helper('smtppro'); /* @var $_helper Aschroder_SMTPPro_Helper_Data */
$_helper->log("Getting Amazon SES Transport");
$path = Mage::getModuleDir('', 'Aschroder_SMTPPro');
include_once $path . '/lib/AmazonSES.php';
$emailTransport = new App_Mail_Transport_AmazonSES(
array(
'accessKey' => $_helper->getAmazonSESAccessKey($storeId),
'privateKey' => $_helper->getAmazonSESPrivateKey($storeId)
),
'https://email.'.$_helper->getAmazonSESRegion($storeId).'.amazonaws.com',
$_helper->getAmazonSESRegion($storeId)
);
return $emailTransport;
}
}
And tweak App_Mail_Transport_AmazonSES
/**
* Amazon Access Key
*
* @var string|null
*/
protected $_accessKey;
/**
* Amazon private key
*
* @var string|null
*/
protected $_privateKey;
/**
* Amazon region endpoint
*
* @var string|null
*/
protected $_region;
private $endpoints = array(
'us-east-1' => 'email.us-east-1.amazonaws.com',
'us-west-2' => 'email.us-west-2.amazonaws.com',
'eu-west-1' => 'email.eu-west-1.amazonaws.com',
'eu-central-1' => 'email.eu-central-1.amazonaws.com',
);
/**
* Constructor.
*
* @param array|null $config (Default: null)
* @param string $host (Default: https://email.eu-west-1.amazonaws.com)
* @param string $region (Default: us-east-1)
* @return void
* @throws Zend_Mail_Transport_Exception if accessKey is not present in the config
* @throws Zend_Mail_Transport_Exception if privateKey is not present in the config
*/
public function __construct(Array $config = array(), $host = 'https://email.eu-west-1.amazonaws.com', $region = 'eu-west-1')
{
[...]
Hi @jezek,
I am also getting the Region Unrecognized
error. Could you or @DominicWatts elaborate on how to fix this issue? That would be greatly appreciated.
Thank you for your time.
@tomakun I'm really sorry. I did this work for some guy and for him it's working. I don't have time, nor motivation to fix your problem. You're on your own. Happy hacking, I hope you solve your problem. If you solve it, don't forget to post your solution. ;)
@jezek @DominicWatts and to anybody getting the Region Unrecognized
issue, in the end I was able to make it work with using ONLY @jezek 's file above - Here's how:
You need to make sure you are running the latest version of the Aschroder SMTP Pro plugin before replacing the lib/AmazonSES.php file provided by @jezek above.
Once you are up to date and using @jezek's file above, run a self test from the plugin's Logging and Debugging panel in the Magento dashboard. From the self test, if you get a "Email address not verified" "Check if you email address is verified and SES region" error, you will need to update line 206:
public function __construct(Array $config = array(), $region = 'US-EAST-1')
and change US-EAST-1 to whatever region you are using in SES. In my case I had to change it to US-WEST-2. The next self test was successful, all emails being sent without issues.
Thank you @jezek for this Gist.
@tomakun Good work and thank you for additional info for all future visitors.
well done, much appreciated.