Skip to content

Instantly share code, notes, and snippets.

@jezek
Last active December 2, 2021 15:34
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jezek/d3f9e1b92cbacf990bbcd078c5469fc8 to your computer and use it in GitHub Desktop.
Save jezek/d3f9e1b92cbacf990bbcd078c5469fc8 to your computer and use it in GitHub Desktop.
Update of Magento's Ashroder Email module's lib/AmazonSES.php file to work with Signature version 4.
<?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 <info@christophervalles.com>
* @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)));
}
}
@soniclouds
Copy link

soniclouds commented Apr 8, 2021

well done, much appreciated.

@webalexstudio
Copy link

Thanks a lot for the working code! AWS kill brains with their official docs as usuall!
Thx one more time)

@DominicWatts
Copy link

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')
    {
        [...]

@tomakun
Copy link

tomakun commented Jun 3, 2021

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.

@jezek
Copy link
Author

jezek commented Jun 3, 2021

@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. ;)

@DominicWatts
Copy link

@Jazek

I posted the two changes I needed to make

It's the endpoints and the constructor argument

@tomakun
Copy link

tomakun commented Jun 5, 2021

@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.

@jezek
Copy link
Author

jezek commented Jun 5, 2021

@tomakun Good work and thank you for additional info for all future visitors.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment