Serverless message relay

Using Azure Function Apps and SendGrid for email housekeeping

Houston Haynes

10 minute read


While it’s an elegant and inexpensive result, bringing this side project to a notch point was not exactly straightfoward. Once I determined how the message was to be constructed for the SendGrid v3 API, along with a few tweaks to Function App bindings, it all came together. I added some JavaScript and CSS to animate the Function Apps logo, which provides a more active visual experience.

A Well-Intended, Slightly Naive Adventure

This all started, like many tech digressions, with humble beginnings. I wanted to find if anyone else had used Azure Functions for handling messaging through their static site, and there were several examples. Unfortunately the information had grown stale by the time I found it. The core concept is out there in several repos/blog posts, but there have been changes since then which invalidates a straight fork-and-deploy approach. So I did a bit of my own homework and derived a workable solution. I’ll start with a brief walkthrough - a GIF of an early version of the form in action.

The Function can take up to 20 seconds to respond when initiating from a cold start. So this GIF has been edited as a courtesy. And as much as I’d like to think I’m popular, the likelihood of multiple people clicking “Submit” when a Function App instance is idling is - in a word - remote. Even while testing on an idling Function instance there’s still enough wait time to see the animation “pulse” on screen once or twice. So it’s more to the point that having a bit of action in the page to represent the request/response cycle can indicate to the user that things are happening behind the scenes.

The Code

While I provided inline comments for some of the code blocks, there’s still a few bits of context worth mentioning here. Below is the Node-based Function App message binding, key being the nested from element that contains both the name and email of the submitted form. There are several alternatives to this using “personalization” but it’s really unnecessary with the latest API.

index.js from Azure Function App project
module.exports = async function (context, req) {
    if ( {
        context.bindings.message = {
            subject: 'Message from ' + + ' via YOUR_SITE_HERE', //fix this line!
            from: { 
            content: [{
                type: 'text/plain',
                value: req.body.message
    return {
            res: {
                status: 200
    } else {
        return {
            res: {
                status: 400

There were no examples online that showed this specific binding pattern. So I cobbled this together by looking at SendGrid’s API docs plus the sample message binding in a SendGrid-native template that Microsoft makes available in the Function Apps portal.

In the HTML form below you’ll see there are several placeholder elements - waiting for actions based the javascript listener and promise response.

HTML form in Contact page

I’ve read several posts from first-order front-end developers disparaging inline styles, but in this case I kept it for - you guessed it - visibilty. It may be something I refactor at a later date.

JavaScript that manages SendGrid request/response and HTML form state
// waits for the button to be clicked on Contact form
document.getElementById('submitMessage').addEventListener('submit', submitMessage);

// this transitions the UI, processes the form input then submits to SendGrid via Azure Function
function submitMessage(e) {
    document.getElementById('container').classList.add('running'); // run dark overlay
    document.getElementById('AzureFunctionHeartbeat').style.visibility = "visible"; // make svg visible
    let name = document.getElementById('name').value;
    let email = document.getElementById('email').value;
    let message = document.getElementById('message').value;
    fetch(' YOUR_AZURE_FUNCTION_URL_GOES_HERE  ', { // fix this line!!
            method: 'POST',
            cache: 'no-cache',
            headers: {
                'content-type': 'application/json'
            body: JSON.stringify({
                name: name,
                email: email,
                message: message
        .then((res) => processResponse(res))

// this clears the on-submit dark gray overlay and heartbeat and then processes Azure Function response
function processResponse(response) {
    document.getElementById('container').classList.remove('running'); // remove gray form overlay
    document.getElementById('AzureFunctionHeartbeat').style.visibility = "hidden"; // remove svg
    if (response.status === 200) { // HTTP 200 is "OK"
        output =
        <div class="alert alert-success" role="alert">
          Thanks, ${document.getElementById('name').value}! We'll be in touch soon!
    } else { // anything else is "not OK"
        output =
        <div class="alert alert-warning" role="alert">
          Oh no! Something went wrong :(
    document.getElementById('output').innerHTML = output; // write to empty output div on page
    document.getElementById('submitMessage').style.visibility = "hidden"; // hide form for clean UI

The inline comments provide enough breadcrumbs to follow the logic. There’s a possibility for more verbose error messaging (see my caveat below) but this works well enough to get things moving along.


The icon animation also came from an online resource. Loading CSS provides a pure CSS3 for certain behaviors, and I modified their heartbeat example for this site.

CSS that creates heartbeat behavior of SVG
.lds-heart {
    display: inline-block;
    position: relative;
    width: 80px;
    height: 80px;
    transform-origin: 60px 60px;
    left: 50%;
    margin-left: -4em;

.lds-heart div {
    position: relative;
    width: 80px;
    height: 80px;
    animation: lds-heart 1.2s infinite cubic-bezier(0.215, 0.61, 0.355, 1);

.lds-heart div:after,
.lds-heart div:before {
    content: " ";
    position: absolute;
    display: block;
    width: 80px;
    height: 80px;

.lds-heart div:before {
    left: -24px;
    border-radius: 50% 0 0 50%;

.lds-heart div:after {
    top: -24px;
    border-radius: 50% 50% 0 0;

@keyframes lds-heart {
    0% {
        transform: scale(0.95);
    5% {
        transform: scale(1.1);
    39% {
        transform: scale(0.85);
    45% {
        transform: scale(1);
    60% {
        transform: scale(0.95);
    100% {
        transform: scale(0.9);


So while I gloss over how well everything works when it’s working, there were a few stumbling blocks that was a mixture of my own cobwebs working with Node and some minor gaps in documentation here and there. Below are two examples of things that cost me a lot more time than they would have if I was a more seasoned front-end developer.

The fault, dear Brutus, is not in our stars…

The first thing that tripped me up was an output binding error message I was seeing when testing in the portal. I had started with an HTTP triggered function template, and added the SendGrid output bindings afterward.

function.json from Azure Function App project
    "bindings": [{
            "authLevel": "anonymous",
            "type": "httpTrigger",
            "direction": "in",
            "name": "req",
            "methods": [
            "type": "sendGrid",
            "name": "message",
            "apiKey": "SENDGRID_API_KEY",
            "direction": "out",
            "to": "YOUR_EMAIL_GOES_HERE"
    "disabled": false

While the bundle decalaration (below) was added when the project was instantiated, the “sendGrid” extension call-out was not there by default.

host.json from Azure Function App project
    "version": "2.0",
    "extensionBundle": {
        "id": "Microsoft.Azure.Functions.ExtensionBundle",
        "version": "[1.*, 2.0.0)"
    "__comment": "the element below was added manually, and delete this!!",
    "extensions": {
        "sendGrid": {}

Fortunately a quick reply on Twitter had me back on track.

So the course correction was relatively quick, but it definitely goes to show that there are gaps in online help (as I’m sure there are gaps in what I present here). My guess was that the helpful folks that posted their own method for handling SendGrid were conversant enough that it was barely worth mentioning the bundle/hosts adds. I’ve built a wide swath of serverless apps in AWS and Azure, but this is the first time I’ve worked with SendGrid. So if anything I should have tempered my expectations that this would not just be another fire-and-forget exercise.

…But in ourselves, that we are underlings

I was in a hurry. That was my first mistake. There were other problems I encountered in troubleshooting that were largely of my own making. My machine is still relatively new and I hadn’t bothered changing my default browser from Microsoft Edge. I would open Firefox or Chrome manually and paste in the local URL when I needed to check the local build on those browsers. In this case that was a strategic mistake. The messages coming back in Edge didn’t give me the detail to show the other mistake I had made - that is - to make sure my CORS settings would allow my localhost as an origin. Once I popped over to Chrome I nearly blew a gasket - both at the lack of verbosity in Edge and in my own impatience for missing this.

When running the test in portal, everything worked fine. The browser is on my desktop so there’s no problem, right? But the origin header for the request from the test in portal is already pre-cleared by the function app defaults.

I had already set up my production and staging endpoints to pass through, but simply forgot to add the localhost origin. And of course I paid almost no attention to the origins that were already listed - which I assume allow the tests in the Funtion App portal to process without issue. I lost more than an hour beating myself up trying to get this to work. Eventually I gave up and simply deployed into my staging instance to run the tests, which was fine - but the find/fix cycle is not nearly as quick as running locally. I suppose if I had configured VSCode for remote debugging I would have found this earlier. That’s what “being in a hurry” gets me…

So now I have Firefox set as my default browser, which is a real acid test for a variety of reasons I’ll delve into with another post.

Above are the settings that allow my localhost to work - your mileage (and localhost declaration pattern/port) may vary. If you’re familiar with serving locally in RStudio/Hugo you’ll know about how ports will change if you happen to trigger more than one server instance. Port 4321 is the intial default - Ctrl/Cmd+Alt+F10 is your friend here.

Finishing touches

I really had fun placing the Azure Function App logo into the form as a spinner. That plus the gray overlay gives the page a guided feel that an empty “static” form simly doesn’t convey. The overlay also acts as a mask to prevent multiple clicks of the submit button, which isn’t a real risk but stil nice to keep the interaction model relatively tidy.

I’m a huge fan of serverless, and am really glad to have this as a resource. It’s essentially free - as the basic SendGrid account allows 25,000 free messages per month. I doubt I see 25 messages per month from my site - outside of my own testing. Of course the first paid tier for Azure Function App in the Consumption plan is after 1 million runs, so again it feels like you’re receiving free enterprise-grade resources. And this will also come in handy for processing notifications out of my GitHub Actions pipelines, which is a subject for a later post.

If you’re interested in trying something similar, feel free to fork the repo and give it a try!

Coda - Reception

As usual, there’s one more thing… the email. In my case, I’m routing to a Gmail account. Since this is coming from SendGrid the messages were automatically flagged as possible SPAM. The answer in my case was to create a filter that ensured it wouldn’t get routed to the SPAM folder.

Note that I used my domain name in the subject line as a flag - which is set in the bindings of the index.js of the Function App. The rule both sets the message to always be flagged as important and never marked as SPAM. That way the message is always routed appropriately. So now messages from your site will route to the top of your inbox and all you have to do is click on the “reply” button and type away. Happy emailing!

Key Value
BuildDateTime 2021-11-07 14:44:45 -0500
LastGitUpdate 2021-11-07 14:13:35 -0500
GitHash 765a5f1
CommitComment capitalization cleanup