Update
I no longer use nor recommend this setup, see my new setup.
Introduction
I previously hosted my own email server, but found that I don’t particularly like administering one. But I still use my public fronting email to send and receive email, as a custom domain for Gmail. My solution to this utilizes MailGun’s free tier. I won’t cover how I setup the forwarding, as there are many guides. This solution works quite well, except when an email you sent (or was sent to you) fails to be delivered. While MailGun logs the failure, it does not notify you.
This post covers how I hooked up MailGun Events Webhooks to a Google Apps Script to send email notifications for dropped messages and hard bounces. Since I require TLS connections with valid certificates, MailGun (correctly) refuses to deliver my email to poorly run email servers. An unfortunate instance of this was my mortgage lender not receiving my emails, and it wasn’t until I checked my logs that I discovered the problem. Obviously, I needed notifications!
MailGun
MailGun has an extensive API, and most of their customers likely use it exclusively. But users like me don’t bother when setting up our Gmail forwarding; setup the routes and SMTP credentials and we forget we’re even using MailGun. My first thought was to periodically query their events API and send an email notification when a new error occurred.
But this polling was unnecessary, considering that their webhooks will send me an HTTP POST for the events I’m trying to monitor. MailGun makes this really easy: they even provide an API test service. Under routes you can “Send A Sample POST” to any URL. Even better, you can generate a POST receiver at bin.mailgun.net. Take the URL, enter it in the field, then refresh the “Postbin” to see an example event. But now we need something of our own to receive this POST and actually email us the event.
Google Script
I created a trivial web app using Google Script to receive and process
the POST. This service lets you write JavaScript in your browser like a Google
Doc, and deploy it with a click of a button. No servers, no networking, no cost.
You write a function doPost(e) { ... }
, and it receives the POST at the URL
Google provides. My function looks like this:
var API_KEY = "key-abcd1234...";
var EMAIL = "[email protected]";
function doPost(e) {
// The output is not consumed, but required by the Google Scripts API.
var output = ContentService.createTextOutput("Post received, thanks!");
var params = e["parameter"];
if (!verify(API_KEY, params["token"], params["timestamp"], params["signature"])) {
// TODO: Notify or log if you want.
return output;
};
var subject = "MailGun " + params["event"] + " a message!";
var body = params;
try {
var headers = JSON.stringify(JSON.parse(params["message-headers"]), null, 2);
body =
"Domain: " + params["domain"] + "\n" +
"Description: " + params["description"] + "\n" +
"Recipient: " + params["recipient"] + "\n" +
"Message ID: " + params["Message-Id"] + "\n" +
"Headers: " + headers + "\n";
} catch (e) {
body += "\n" + e.message;
}
MailApp.sendEmail(EMAIL, subject, body);
return output;
}
Besides the easy deployment, Google Script also provides a decent set
of script services. Unfortunately, I ran into problems with it
while writing my verification code. I don’t want any POST to this URL to
possibly send me an email, and MailGun provides a signature
and documentation on how to verify the incoming POST. The original
verify()
function I wrote looked like this:
function verify(key, token, timestamp, signature) {
var computed = Utilities.computeHmacSha256Signature(timestamp + token, key);
return computed == signature;
}
But look at Byte[] computeHmacSha256Signature(String value, String key)
, it returns a byte array. Specifically, one like this: [90, -45, -75, 57, ...]
. Okay, what’s the problem? Surely we can take a Byte[]
and
convert it to a String
, or vice versa and get a valid comparison. This is not
as easy as it appears.
This issue suggests using Utilities.newBlob(bytes).getDataAsString();
to
convert us from Byte[]
to String
, but (like noted in the issue) it has a
problem with negative values in the byte array. Rather than work around this
problem by, say, including and using jsSHA (which I actually tried,
successfully), let’s instead understand the actual problem.
What we really need is a refresher on the computer science
fundamentals: two’s complement and hexadecimal representation of bytes.
The negative integers in the array are a misinterpretation of the computed
bytes, which are in two’s complement. If we can understand the data
computeHmacSha256Signature()
is giving us, then we can convert it correctly.
Let’s take a look at the first two bytes of this array [90, -45, ...]
.
90
Positive integers in two’s complement are unchanged.
90
in binary is 0b1011010
.
But we’re dealing with bytes, so we need exactly eight bits.
Pad the left with zeroes until we have the byte 0101 1010
.
Each half of a byte (four bits) is known as a “nibble”, and each nibble spans
the decimal range [0, 15]
, or the hexadecimal range [0, F]
.
Left nibble 0101
in hex is 5
.
Right nibble 1010
in hex is A
.
Hence the byte 90
in hex is 0x5A
.
-45
Negative integers in two’s complement are the “one’s complement plus one” of the absolute value.
45
in binary is 0b1011010
. The byte is 0010 1101
.
But our byte was -45
, not 45
. So the first step is to take the one’s
complement (i.e. invert the bytes).
One’s complement is 1101 0010
.
The next step is to simply add one (arithmetically): 1101 0011
.
Now we have the two’s complement byte representation of -45
.
Left nibble 1101
in hex is D
.
Right nibble 0011
in hex is 3
.
Hence the byte -45
in hex is 0xD3
This is very different from the number -45
in hex, which is simply -0x2D
.
It is this misinterpretation of the byte values that is the root of our
problem.
[90, -45, …]
Proceeding to convert the rest of the byte array in this manner would yield the full hexadecimal representation of our computed signature:
5ad3b5393dd4c0795f8b381e2616646b25d12847b5c1b870b2f45a4f1e010d29
Now we just need to do this programmatically.
function toHexString(bytes) {
return bytes.map(function(byte) {
return ('0' + (byte & 0xFF).toString(16)).slice(-2);
}).join('')
}
This code seems odd, doesn’t it? Why can’t we just call byte.toString(16)
? The
problem comes down to padding and signs. For each byte in the array, we need to
convert exactly two nibbles into two hexadecimal digits.
So the first operation we apply is byte & 0xFF
, or, “bitwise AND
with 1111 1111
”. This operation appears to be a no-op, after all 1101 0011 & 1111 1111 = 1101 0011
, but it ensures we’re looking only at the least significant
(rightmost) byte, and zeroes every bit to the left. When dealing with Google
Script’s Byte
this is necessary to prevent toString(16)
from converting the
integer -45
instead of the byte 1101 0011
. I’m still uncertain why this
happens; I expected a Byte
to be treated exactly as a byte, but the behavior
is as though it was a multi-byte integer.
So after we take (byte & 0xFF).toString(16)
we have the hex string 2d
. Now
padding comes into play. Take for example the byte 13
, or 0000 01101
. In
hex, this is 0xD
, and more precisely, (13).toString(16)
returns d
. But we
started with two nibbles, and need two hex digits, because the byte must be
represented as 0d
. So we pad it by prepending '0'
, and then slice(-2)
to
obtain exactly the two digits we’re looking for.
Now that we can convert the Byte[]
hash to a hex string, we can fix the
verify()
function:
function verify(key, token, timestamp, signature) {
var computed = toHexString(Utilities.computeHmacSha256Signature(timestamp + token, key));
return computed == signature;
}
Putting it all together
This following embedded Gist is the final code I currently use in my Google
Script (with the email and API key replaced). I spent more time than I care to
admit making the email look at least a bit pretty, and writing test code to
debug the script. The Logger
class only logs when running functions locally,
so I had to mock the input from a POST.
Deploying the script is easy: click Publish
then Deploy as web app...
, set
Who has access to the app
to Anyone, even anonymous
, and copy the URL into
the each event’s webhook you want to monitor (I monitor Dropped Messages
and Hard Bounces
, but used Delivered Messages
for integration
testing).