Adding One-Click Unsubscribe SMTP Headers To My Comment Emails In ColdFusion
Last year, Google announced that it would start enforcing easy unsubscribe functionality for people sending bulk emails. On my blog, I don't send bulk emails; however, each blog post represents a subscription opportunity for my readers. As such, I thought it would be a fun learning opportunity to add the required one-click unsubscribe SMTP headers to my outbound ColdFusion emails.
According to Postmark's support article, there are two pathways that can power a one-click unsubscribe: mailto
and HTTP POST
. Seeing that this experiment is for a blog with a dynamic ColdFusion back-end, the HTTP POST
seems like the obvious—and easiest—choice.
Aside: If you are sending bulk mail through a broadcast stream in Postmark, Postmark will automatically inject these SMTP headers and manage the unsubscribe mechanics on your behalf.
To tell an email client that an easy unsubscribe feature is available, two different SMTP headers needs to be included:
List-Unsubscribe-Post
:List-Unsubscribe=One-Click
List-Unsubscribe
:<POST_URL>
The first SMTP header tells the email client that an HTTP POST
option is available and the second SMTP header defines the remote URL to be triggered with a POST
request (if and when the user clicks on the easy unsubscribe option in their email client).
Note: The
<
and>
characters are required to wrap the URL.
Adding headers to an outbound email in ColdFusion is easy - we just add CFMailParam
tags. However, these SMTP headers aren't magical—we still need to define a remote URL that actually does something meaningful when it's invoked.
To this end, I created a ColdFusion page on my blog that serves two purposes. When you access the page via HTTP GET
, it tells you the status of a given blog post subscription. And, when you access it via HTTP POST
, it changes the status of the given blog post subscription (either subscribing or unsubscribing the given user).
Of course, it creates a significant security concern when you provide a public URL to mutate the application state. In order to ensure that the given URL cannot be tampered with, I need to create a signed URL using a hashed message authentication code (HMAC). Which means that all access to this URL must be accompanied by both a user ID and a cryptographically secure signature.
Here's the pseudo-code that I'm using to send the comment notification emails with the necessary SMTP headers. Each subscriber must receive their own, unique one-click unsubscribe URL (and unique cryptographic signature).
component {
private void function sendCommentEmails( required numeric commentID ) {
// Get all members that are currently subscribed to receive new comment
// notifications for this blog post.
var comment = getComment( commentID );
var post = getPost( comment.postID );
var subscribedMembers = getSubscribedMembers( post.id );
// Loop-over the subscribed members. Each subscriber must get a UNIQUE one-click
// unsubscribe link so that we don't have people unsubscribing each other.
for ( var member in subscribedMembers ) {
// The easy unsubscribe section of the blog is locked-down to a specific user.
// As such, each outbound email must receive a unique HMAC signature specific
// to the given subscribed user.
var signature = generateMemberSignature( member );
// Every one-click unsubscribe link must have BOTH a `memberID` AND the
// member-specific `signature`. This ensures that the URL cannot be hacked.
var subscriptionUrl = (
"index.cfm" &
"?event=blogSubscriber.entry" &
"&entryID=#entry.id#" &
"&memberID=#member.id#" &
"&signature=#signature#"
);
// The ^^ above ^^ URL is for the HTTP GET version (which displays the status
// of the subscription to the given blog entry). This vv version vv is for the
// HTTP POST that mutates the state. Notice the `action=unsubscribe` appended
// to the URL.
var oneClickUnsubscribeUrl = "#subscriptionUrl#&action=unsubscribe";
cfmail(
to = member.email,
from = "BenNadel.com <ben@bennadel.com>",
subject = "A new comment was posted!",
type = "HTML"
) {
// Tells mail client that a one-click unsubscribe POST is available.
cfmailparam(
name = "List-Unsubscribe-Post",
value = "List-Unsubscribe=One-Click"
);
// Tells mail client which URL to invoke when unsubscribing (via POST).
cfmailparam(
name = "List-Unsubscribe",
value = "<#oneClickUnsubscribeUrl#>"
);
include "comment.cfm";
}
}
}
public string function generateMemberSignature( required struct member ) {
var message = "#member.id#:#member.salt#";
return( hexToBase64url( hmac( message, secretKey, "HmacSha512" ) ) );
}
}
As you can see, when I loop over the collection of subscribed members (aka, users, aka readers), I'm invoking generateMemberSignature()
for each of them. This creates a unique Hmac-SHA512
signature for each member; and serves to lock-down access to the given member's subscription information.
Note: I'm using a 128-byte key for my
hmac()
call as recommended by Microsoft. I generated this key usingopenssl
:
openssl rand -base64 128
The URL embedded in the SMTP header will be used by the mail client to invoke the easy unsubscribe page on my blog. Here's the pseudo code for this ColdFusion page:
<cfscript>
// The signature locks this page down to the given member. It must be provided with
// every request to this page (POST or GET).
param name="request.attributes.memberID" type="numeric";
param name="request.attributes.signature" type="string";
param name="request.attributes.entryID" type="numeric";
// One-click unsubscribe will define this as "unsubscribe".
param name="request.attributes.action" type="string" default="";
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
// Every time we get the member, we have to VERIFY the signature. This ensures that
// the URL hasn't been tampered with.
member = getSubscriber(
memberID = val( request.attributes.memberID ),
signature = request.attributes.signature
);
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
// Only mutate the state with a POST to make sure this isn't accidentally triggered
// with some sort of bot scan or something to that effect.
if ( getMethod() == "POST" ) {
if ( request.attributes.action == "subscribe" ) {
// ... CREATE subscription ...
}
if ( request.attributes.action == "unsubscribe" ) {
// ... DELETE subscription ...
}
// ... Refresh page to show updated subscription status ...
}
</cfscript>
Notice that when I'm getting the current member, I have to pass-in both the memberID
and the signature
. This way, the signature can be validated against the given member for every single request to this page, ensuring that URL tampering has not taken place. Internally, the getSubscriber()
method just re-generates the expected signature—using the earlier generateMemberSignature()
method&mdahs;and performs a case-sensitive comparison before returning the member record:
component {
public struct function getSubscriber(
required numeric memberID,
required string signature
) {
var member = getMember( memberID );
var expectedSignature = generateMemberSignature( member );
// Case SENSITIVE Base64 comparison.
if ( compare( signature, expectedSignature ) ) {
throw( type = "SignatureMismatch" );
}
return( member );
}
}
In this post, I left out a lot of detail that is specific to my blog. But, I'm hoping that I've left in enough detail here to demonstrate how I'm trying to expose the easy one-click unsubscribe functionality for my ColdFusion emails.
Ironically, I can't actually get the one-click Call To Action (CTA) to show up in GMail (which is apparently very finicky about showing it). But, I can look at the email source and see that the necessary SMTP headers are defined:
As a final note, I'll just reiterate that this functionality isn't really necessary for transactional emails (which is what new comment notifications are). But, I'm adding it as a learning experience; and, because it forced me to start fleshing-out a more robust subscription management workflow for my readers.
Want to use code from this post? Check out the license.
Reader Comments
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →