front end dev, back end dev
Serverless Contact Form
Collecting contact submissions in Sheets and Slack

After experimenting with Google's Cloud Functions while working on "Serverless Cloud Maker" at Next 2019 and seeing first hand how easy and cheaper it is to deploy javascript, I wanted to find a good use case for myself. Looking for something that warranted a solution like this, a situation where building a full back end server would be over kill (I really don't want to have to monitor and keep up another small node server). This website is entirely static, and I'm constantly building and rebuilding it as I learn new skills. Using basic node.js and express.js patterns, I wanted an easy way to handle an email contact form in a secure manner. While it ended up needing a small node server anyways to handle the captcha code, this is a great tool that's easy to deploy. The Cloud Function adds a row to a spreadsheet and sends a notification to a slack channel, making it easy to receive emails from users without needing to interface with any SMTP.

Setup

Node

Assuming some familiarty with node and javascript, create a folder somewhere logical (for me, this is in a directory right next to all the web stuff, in a parent project folder). npm init, create a file to deploy as a Cloud Function.

The simplest function is something like this:

// /index.js
exports.contactForm = (req, res) => {
    console.log("Function Fired!");
    console.log("___________________________________");
    // CORS headers
    res.header('Access-Control-Allow-Origin', '*');
    res.header('Access-Control-Allow-Headers', 'Content-Type');
    //respond to CORS preflight requests
    if (req.method == 'OPTIONS') {
        res.status(204).send('');
    }
    // Response
    res.status(200).send("Success");
};

It needs CORS to handle client-side AJAX requests. We can add more interesting actions later using any node module, like sending data to a Slack webhook or integrating with the Sheets API.

GCP

This requires some initial legwork to get a GCP account started and credentials worked out.

reate an account (with $300 free credits for a year) at console.cloud.google.com, and start a new project. Name it something descriptive, because the management UX is still being worked out.

Download the gcloud SDK for the command line.

Navigate to the IAM & Admin sidebar tab, and create a Service Account and download the .json key.

Enable these APIs:

gcloud auth login
gcloud config set project <project_name>
gcloud beta functions deploy contactForm --trigger-http

And that's it for deployment! The command will return a URL to make requests to, and anytime it receives a POST request, the console logs will appear in Stackdriver.

Now we can do the fun stuff.

Sheets

This is a bit complicated, because Sheets require us to get access tokens using the Service Account creds before accessing the API, but easy enough to follow along. Most of it comes from the google-spreadsheet module.

npm i --save google-spreadsheet
const GoogleSpreadsheet = require('google-spreadsheet');
const async = require('async');

exports.contactForm = (req, res) => {
    console.log("Function Fired!");
    console.log("___________________________________");

    // CORS headers
    res.header('Access-Control-Allow-Origin', '*');
    res.header('Access-Control-Allow-Headers', 'Content-Type');
    //respond to CORS preflight requests
    if (req.method == 'OPTIONS') {
        res.status(204).send('');
    }


    var doc = new GoogleSpreadsheet('<spreadsheet ID from Sheets URL');
    var sheet;

    async.series([
        function setAuth(step) {
            var creds = require('./credentials.json');
            doc.useServiceAccountAuth(creds, step);
        },
        function sheetInfo(step) {
            doc.getInfo(function(err, info) {
                console.log("Doc Loaded!!!!! -------------");
                sheet = info.worksheets[0];
                console.log('sheet 1: ' + sheet.title + ' ' + sheet.rowCount + 'x' + sheet.colCount);
                step();
            });
        },
        function appendRow(step) {
            console.log("APPENDING ROW!!!! --------------");
            sheet.addRow(bodyData, function(err, info) {
                console.log("callback");
                step();
            });
        },
        function success(step) {
            res.status(200).send("Success");
            step();
        }
    ], function(err) {
        if (err) {
            console.log('Error: ' + err);
            res.status(500).send("Error");
        }
    });

};

Slack

This is a super easy integration. After creating a special slack channel to post messages into and registering an application in the team settings, all it takes is a webhook URL. Most of the pain here comes from crafting the message to fit into Slack's spec.

const GoogleSpreadsheet = require('google-spreadsheet');
const async = require('async');
const { IncomingWebhook } = require('@slack/client');
const url = process.env.SLACK_WEBHOOK_URL;
const webhook = new IncomingWebhook(url);


const rawSlack = {
    "attachments": [{
        "fallback": "Email received from _________ on _________",
        "color": "#36a64f",
        "pretext": "Message from ________ on ________",
        "fields": [],
        "footer": "Contact Bot! Email Back.",
        "footer_icon": "https://platform.slack-edge.com/assets/img/default_application_icon.png",
        "ts": 123456789
    }]
}

exports.contactForm = (req, res) => {
    console.log("Function Fired!");
    console.log("___________________________________");

    // CORS headers
    res.header('Access-Control-Allow-Origin', '*');
    res.header('Access-Control-Allow-Headers', 'Content-Type');
    //respond to CORS preflight requests
    if (req.method == 'OPTIONS') {
        res.status(204).send('');
    }

    var bodyData = {}

    function parseBody(data) {
        bodyData.date = new Date();
        if (data.name) {
            bodyData.name = data.name

            rawSlack.attachments[0].pretext = "Email received from " + data.name + " on " + bodyData.date;
            rawSlack.attachments[0].fallback = "Email received from " + data.name + " on " + bodyData.date;

            var push = {
                "title": "Name",
                "value": data.name,
                "short": false
            }
            rawSlack.attachments[0].fields.push(push);
            var push = {}

        }
        if (data.email) {
            bodyData.email = data.email
            var push = {
                "title": "Email",
                "value": "<mailto:" + data.email + ">",
                "short": false
            }
            rawSlack.attachments[0].fields.push(push);
            var push = {}

        }
        if (data.company) {
            bodyData.company = data.company

            var push = {
                "title": "Company",
                "value": data.company,
                "short": false
            }
            rawSlack.attachments[0].fields.push(push);
            var push = {}
        }
        if (data.message) {
            bodyData.message = data.message

            var push = {
                "title": "Message",
                "value": data.message,
                "short": false
            }
            rawSlack.attachments[0].fields.push(push);
            var push = {}
        }

        console.log("parsed", bodyData, data);
    }

    async.series([
        function parse(step) {
            parseBody(req.body);
            step();
        },
        function success(step) {
            // Send simple text to the webhook channel
            webhook.send(rawSlack, function(err, res) {
                if (err) {
                    console.log('Error:', err);
                } else {
                    console.log('Message sent');
                }
            });

            res.status(200).send("Success");
            step();
        }
    ], function(err) {
        if (err) {
            console.log('Error: ' + err);
            res.status(500).send("Error");
        }
    });

};

Client-Side HTML

Just an AJAX call, no sweat.

$('#contact').submit(function(event) {
    event.preventDefault();
    var formData = $('#contact').serialize();
    $.ajax({
        url: "<functions URL>",
        type: 'POST',
        data: formData,
        success: function (data) {
            $('#contact')[0].reset();
        }
    });
});