1

I have been trying to integrate the Amazon SES API into my Salesforce project so I can setup a scheduled batch job to send bulk emails.

I've created this class to create a signed request to the SES service.

public class AwsSignatureV4 {
    public static String signSesRequest(String accessKey, String secretKey, String region, String method, String path, String payload, Map<String, String> headers) {
        String amzDate = Datetime.now().formatGMT('yyyyMMdd\'T\'HHmmss\'Z\'');
        String dateStamp = Datetime.now().formatGMT('yyyyMMdd');
        String canonicalRequest = createSesCanonicalRequest(method, path, payload, headers);
        String stringToSign = createStringToSign(region, dateStamp, amzDate, canonicalRequest);
        String signature = calculateSignature(secretKey, region, dateStamp, stringToSign);
        headers.put('Authorization', buildAuthorizationHeader(accessKey, region, 'ses', amzDate, signature));

        // Construct the signed URL
        System.debug('here');
        return constructSignedUrl(method, path, headers, region);
    }

    private static String createSesCanonicalRequest(String method, String path, String payload, Map<String, String> headers) {
        List<String> canonicalHeaders = new List<String>();
        for (String key : headers.keySet()) {
            canonicalHeaders.add(key.toLowerCase() + ':' + headers.get(key).trim());
        }
        canonicalHeaders.sort();
        String canonicalHeadersStr = String.join(canonicalHeaders, '\n');

        String canonicalRequest = method + '\n' + path + '\n\n' + canonicalHeadersStr + '\n\n' + EncodingUtil.base64Encode(Blob.valueOf(payload));
    
        return canonicalRequest;
    }

    private static String createStringToSign(String region, String dateStamp, String amzDate, String canonicalRequest) {
        String stringToSign = 'AWS4-HMAC-SHA256\n' + amzDate + '\n' + dateStamp + '/' + region + '/ses/aws4_request\n' + EncodingUtil.convertToHex(Crypto.generateDigest('SHA-256', Blob.valueOf(canonicalRequest)));
        return stringToSign;
    }

    private static String calculateSignature(String secretKey, String region, String dateStamp, String stringToSign) {
        Blob kDate = Crypto.generateMac('HMACSHA256', Blob.valueOf('AWS4' + secretKey), Blob.valueOf(dateStamp));
        Blob kRegion = Crypto.generateMac('HMACSHA256', kDate, Blob.valueOf(region));
        Blob kService = Crypto.generateMac('HMACSHA256', kRegion, Blob.valueOf('ses'));
        Blob kSigning = Crypto.generateMac('HMACSHA256', kService, Blob.valueOf('aws4_request'));
        Blob signature = Crypto.generateMac('HMACSHA256', kSigning, Blob.valueOf(stringToSign));
        return EncodingUtil.convertToHex(signature);
    }

    private static String buildAuthorizationHeader(String accessKey, String region, String service, String amzDate, String signature) {
        return 'AWS4-HMAC-SHA256 Credential=' + accessKey + '/' + Datetime.now().formatGMT('yyyyMMdd') + '/' + region + '/' + service + '/aws4_request, SignedHeaders=content-type;host;x-amz-target;x-amz-date, Signature=' + signature;
    }

    private static String constructSignedUrl(String method, String path, Map<String, String> headers, String region) {
        // Include only required headers for signing
        String[] signedHeaders = new String[]{'content-type', 'host', 'x-amz-target', 'x-amz-date'};

        String queryString = '';
        for (String signedHeader : signedHeaders) {
            if (headers.containsKey(signedHeader)) {
                queryString += signedHeader + '=' + EncodingUtil.urlEncode(headers.get(signedHeader), 'UTF-8') + '&';
            }
        }
        queryString = queryString.removeEnd('&');
        return path + '?' + queryString;
    }
}

I then use this method to create/call the Http request like this

public static void sesSender(){
    String payload = '{"BulkEmailEntries":[{"Destination":{"ToAddresses":["<redacted email address>"]},"ReplacementTags":[{"Name":"unsubscribe","Value":"<redacted email address>"}]}],"DefaultContent":{"Template":{"TemplateName":"Welcome_Email"}},"DefaultEmailTags":[{"Name":"user","Value":"test"}],"FromEmailAddress":"<redacted email address>","ReplyToAddresses":["<redacted email address>"]}';
    String accessKey = ACCESS_KEY;
    String secretKey = SECRET_KEY;
    String awsRegion = REGION;
    String method = 'POST';
    Map<String, String> headers = new Map<String, String>{
        'content-type' => 'application/json',
        'x-amz-target' => 'com.amazonaws.services.simpleemailv2.AmazonSimpleEmailServiceV2.SendBulkEmail',
        'x-amz-date' => Datetime.now().formatGMT('yyyyMMdd\'T\'HHmmss\'Z\''),
        'host' => 'email.ap-southeast-2.amazonaws.com'
    };
    
    // Base64 encode the payload
    Blob payloadBlob = Blob.valueOf(payload);
    String base64Payload = EncodingUtil.base64Encode(payloadBlob);

    String endpoint = 'https://email.ap-southeast-2.amazonaws.com';

    // Sign the request
    String signedRequest;
    try{

        signedRequest = AwsSignatureV4.signSesRequest(accessKey, secretKey, awsRegion, method, endpoint, base64Payload, headers);
        System.debug(JSON.serialize(signedRequest));
    }catch(Exception e){
        System.debug(e);
    }
    
    HttpRequest req = new HttpRequest();
    req.setEndpoint(signedRequest);
    req.setMethod(method);
    req.setBody(base64Payload); // Use the Base64 encoded payload
    
    // Set headers in the HttpRequest
    for (String key : headers.keySet()) {
        req.setHeader(key, headers.get(key));
    }
    
    Http http = new Http();
    HttpResponse res = http.send(req);
    
    String responseBody = res.getBody();
    System.debug(res);
    System.debug(responseBody);
    System.debug(res.getHeaderKeys());
    for(String key : res.getHeaderKeys()){
        System.debug(res.getHeader(key));
    }
}

when I call this method, the HttpResponse does return a 200 status however the body is returning {"Output":{"__type":"com.amazon.coral.service#SerializationException"},"Version":"1.0"}

I've tried looking for information about this but have had little luck. From the little information I can find though (Stack Overflow question), my understanding is there is something wrong with the payload but I'm not even sure if that's true or what is wrong if it is. This is what I used to format my payload the way I did Amazon SES API v2 SendBulkEmail

Any help or insight would be greatly appreciated.

UPDATE:

I have tried to make a request using the 'Named Credentials' method following these steps + the example mentioned in the comments below.

Named Credential Information

URL: https://email.ap-southeast-2.amazonaws.com
AWS Region: ap-southeast-2
AWS Service: ses
AWS Access Key: ...
AWS Secret: ...

Creating the request:

    HttpRequest awsRequest = new HttpRequest();
    awsRequest.setEndpoint('callout:AWS_AP_Southeast_2');
    awsRequest.setMethod('POST');
    awsRequest.setHeader('content-type', 'application/json');
    awsRequest.setHeader('x-amz-target', 'com.amazonaws.services.simpleemailv2.2010-12-01.SendBulkEmail');
awsRequest.setBody('{"BulkEmailEntries":[{"Destination":{"ToAddresses":["<Redacted Email Address>"]},"ReplacementTags":[{"Name":"unsubscribe","Value":"<Redacted Email Address>"}]}],"DefaultContent":{"Template":{"TemplateName":"Welcome_Email","TemplateData":""}},"DefaultEmailTags":[{"Name":"user","Value":"test"}],"FromEmailAddress":"<Redacted Email Address>","ReplyToAddresses":["<Redacted Email Address>"]}');
    Http http = new Http();
    HttpResponse awsResponse = http.send(awsRequest);
    System.debug(awsResponse);
    System.debug(awsResponse.getBody());

This returns a status 200 with a body of {"__type":"com.amazon.coral.service#UnknownOperationException"},"Version":"1.0"}

here is the JSON body formatted so it's easier to read.

{
    "BulkEmailEntries": [
        {
            "Destination": {
                "ToAddresses": [
                    "<email>"
                ]
            },
            "ReplacementTags": [
                {
                    "Name": "unsubscribe",
                    "Value": "<email>"
                }
            ]
        }
    ],
    "DefaultContent": {
        "Template": {
            "TemplateName": "Welcome_Email",
            "TemplateData": "{\"unsubscribe\": \"TestEmailPerson\"}"
        }
    },
    "DefaultEmailTags": [
        {
            "Name": "user",
            "Value": "test"
        }
    ],
    "FromEmailAddress": "<email>",
    "ReplyToAddresses": [
        "<email>"
    ]
}
11
  • 1
    Yes I've posted this similar question in multiple places, including the re:post forums Commented Dec 14, 2023 at 1:50
  • 1
    @identigral okay I did remove the 'x-amz-target' and instead added the path to the named credentials. First I tried a GET for the GetAccount like you suggested and that returned the expected response body, so I tried adding the bulk emails path to the named credential Url and got the response status 400 {"message":"The default template data does not exist"}, so I think it's definitely a probalem with my body parameters thankyou Commented Dec 14, 2023 at 6:47
  • 1
    Deleted some of our comments to avoid having other readers going down the wrong path...but if GetAccount worked with Named Cred/Sig V4 then other calls should work too. The error message regarding the template confirms that the signature is working. The default template data does not exist is related to the payload itself and SES config rather than the signature, e.g. something along these lines
    – identigral
    Commented Dec 14, 2023 at 6:51
  • 1
    @JakeColledge Now that the Salesforce side of your question (the signature) appears to have been answered, you're in AWS land. Your odds of getting the answer here are low. Try StackOverflow or re:Post.
    – identigral
    Commented Dec 14, 2023 at 7:37
  • 1
    @identigral seems like it, I'll update here when I solve the issue just for clarity for anyone else in the future Commented Dec 14, 2023 at 23:01

1 Answer 1

1

To give an update for anyone in the future who has the same problem.

I have successfully managed to send an email through SES using Apex.

Steps:

  1. Setup an Named Credential in salesforce using the AWS Signature Version 4 Auth Protocol
  • URL: 'https://email..amazonaws.com/v2/email/outbound-emails'
  1. create the http request in your apex class

example

HttpRequest awsRequest = new HttpRequest();
awsRequest.setEndpoint('callout:AWS_AP_Southeast_2');
awsRequest.setMethod('POST');
awsRequest.setBody('<YOUR JSON BODY HERE>');
Http http = new Http();
HttpResponse awsResponse = http.send(awsRequest);

The problem I was having was caused in my JSON, so to test that I was connecting to AWS SES I did a simple 'GET' request using the GetAccounts method. This returned the expected result. So I went back to the 'POST' sendEmails method and instead of using a 'Template' I decided to use the 'Simple' content parameter. This time it successfully posted, and I received an email to the email I specified from the other email I specified.

My next steps will be to figure out what is wrong when using the template method and then to get this working with bulk emails.

Big thanks to @identigral for helping me along the way!

If I solve the templated email issue I will update again but for now this is the solution I will use.

UPDATE

Moving back to the SendBulkEmail path, I have successfully managed to send an email using this method

For context: you can add personalized fields to email templates by wrapping a variable in '{{}}' tags. This is where I assumed I was configuring things wrong. So to test this I created an email template without any of the personalized tags and sent a request using this json body

{
    "BulkEmailEntries": [
        {
            "Destination": {
                "ToAddresses": [
                    "<Redacted>"
                ]
            },
            "ReplacementEmailContent": {
                "ReplacementTemplate": {
                    "ReplacementTemplateData": "{}"
                }
            }
        }
    ],
    "DefaultContent": {
        "Template": {
            "TemplateName": "TestEmail",
            "TemplateData": "{}"
        }
    },
    "FromEmailAddress": "<Redacted>",
    "ReplyToAddresses": [
        "<Redacted>"
    ]
}

this managed to get an email sent. So I then updated the email template to add the '{{test}}' tag and sent a request.

This time I received a 200 status but never received an email. I assume because SES recognized there was a tag in the email that it didn't have any replacement data for.

I then filled the JSON 'TemplateData' field & 'ReplacementTemplateData' field with

"{\"test\": \"Unsubscribe\"}"

and sent the request that way

This received the status 400 with a null error message again

This tells me it is definitely a formatting issue for the replacement data that I don't understand but thought I'd post this update.

FINAL UPDATE:

Such an easy answer but I was formatting the TemplateData/ReplacementTemplateData wrong

in the end it had to look like this

"{test: valuehere}"

and that was it. Status 200 with a received email, with the correct data showing.

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .