Cloud Function to Send Email

March 23rd, 2021 · 17 min read · 3302 words · Programming

Lessons learned creating a Firebase Cloud Function to send an HTML form submission email via MailJet.

The Hire Me form on MarkJonesVoice needed to be handled by something. Before, it was a PHP script, adapted from a the Hugo Zen Theme’s contact.php and running on Google Compute Engine instance.

The PHP solution seems anachronistic and requires a server just be available online, sitting there. Also, the resulting PHP code was not exactly pretty and wasn’t flexible enough to send ad-hoc fields; they needed to be named in the script explicitly. The code could’ve accommodated that but requiring a server sitting there waiting for the occassional email just seemed wasteful.

Formsubmit.io

I came across formsubmit.io and really liked how it handled email with ad-hoc fields, really cool. You can choose from 3 templates to somewhat customize your email. If you want to exclude a field from being added to the email, it only needs to be prefixed with ’_’, also how they require the “control” fields like ‘_reply’, etc…

Ultimately, the lack of style control led me to make my own Cloud Function but I designed it to be more like formsubmit than my lackluster PHP handler.

Firebase Cloud Function

The code segments below contain just the relevant parts of the for handling AJAX (multipart/form-data). You may want to check out any of the recommended reading for links to get you started.

Busboy for “multipart/form-data” (and AJAX) submission

I discovered that I couldn’t receive the POST via AJAX in the Cloud Function without processing it through Busboy. Why? When submitting an HTML Form element using AJAX (on-page submission), it’s always sent as enctype="multipart/form-data". This surprised me as the PHP script seamlessly handled multipart/form-data but node requires something to extract the fields.

Note that this wasn’t an issue for posting directly to the Cloud Function with the default enctype="application/x-www-form-urlencoded". But I wanted on-page submission, so… I tracked down this code to extract the fields:

const Busboy = require('busboy');

function extractMultipartFormData (req: functions.https.Request): Record<string,any> {
  return new Promise((resolve, reject) => {
    if (req.method !== 'POST') {
      return reject(405);
    } else {
      const busboy = new Busboy({ headers: req.headers });
      const fields: Record<string,any> = {};

      busboy.on('field', (fieldname: string, val: any) => (fields[fieldname] = val));
      busboy.on('finish', async () => {
        const result = { fields };
        resolve(result);
      });
      busboy.on('error', reject);
      if (req.rawBody) {
        busboy.end(req.rawBody);
      } else {
        req.pipe(busboy);
      }
    }
  });
};

Email Form Handler Function

export const emailFormHandler =  functions.https.onRequest(async (req,res) => {
    try {

    const origin = ''+req.headers.origin;
    if (/markjonesvoice.com/.test(origin)) {
        res.set('Access-Control-Allow-Origin', origin);
        res.set('Vary', 'Origin');    
    } else {
        console.log('Unauthorized origin domain: '+origin);
        res.status(403);
        res.send(`Domain ${origin} not authorized.`);
        return;
    }
    const mpfd = await extractMultipartFormData(req);
    const fields = mpfd.fields;

    const json = fields.hasOwnProperty('_json');

    if (req.method !== "POST") {
        if (json) {
            res.json({'status':'Error','message':'Requires POST method.'});
        } else {
            res.status(405).end();
        }
        return;
    }

    // set some defaults in case required control fields are missing
    const mSubject = (fields.hasOwnProperty('_subject')) ? fields._subject : "New Form Submission";
    const fromUrl = (fields.hasOwnProperty('_referer') ? fields._referer : req.get('referer'));
    const fromEmail = (fields.hasOwnProperty('_from_email') ? fields._from_email : "mark@markjonesvoice.com");
    const fromName = (fields.hasOwnProperty('_from_name') ? fields._from_name : "Mark Jones Voice");
    const nextUrl = (fields.hasOwnProperty('_next') && fields['_next'] !== '') ? fields._next : fromUrl;
    const includeAll = (fields.hasOwnProperty('_include_fields') && fields._include_fields === 'all'); 
    const includeEmpty = (fields.hasOwnProperty('_include_fields') && fields._include_fields === 'empty'); 

    // honey trap... '_url' or '_honey' are never set except by bots
    if (fields.hasOwnProperty('_honey') && fields._honey !== "" 
    || fields.hasOwnProperty('_url') && fields._url !== ""
    || fields.hasOwnProperty('name') && 
        (fields.name === undefined || fields.name === "No Script" || fields.name === "")) {
    if (json) {
        res.json({status:"Error",message:"Rejected for honeypot or name."});
    } else {
        res.status(400).end();
    }
    functions.logger.warn('HoneyPot or invalid name submission rejected:',fields);
    return;
    }

    let reportHTML = 
    `<div style="font-size: 1.1em; background-color: #301F2F; color: #DADADA; text-align: center; padding: 1.5em 0.5em;">
    <div><a style="font-weight: 900; font-size: 2.5em; text-decoration: none;" href="https://markjonesvoice.com/"><img alt="Mark Jones Voice" style="max-width: 100%; width: 75%; min-width: 300px;" src="${logoGreen}"></a></div>
    <p><i>Received: ${(new Date()).toLocaleString("en-US",{timeZone:"America/New_York"})}</i>.</p>
    <table width="100%" border=0 cellspacing=0>
    <thead>
    <tr style="color: #DADADA; background-color: #241d24; font-size: 1.2em;">
        <th style='padding: 0.5em 0.75em;'>⚿</th>
        <th style='padding: 0.5em 0.75em;'>🛈</th>
    </tr>
    </thead><tbody>`;
    for (const key in data) {
        const item = data[key];
        reportHTML += `<tr><td style='background-color: #301F2F; color: #AAA; padding: 0.5em; text-align: right; font-size: 75%;'><b>${key.toUpperCase().replace(/_/g,' ')}</b></td>
        <td style='background-color: #F4F4F4; border-bottom: 3px solid #301F2F; border-top: 3px solid #301F2F; color: #111; padding: 0.5em; text-align: left;'>${item.toString().replace(/\n\r?/g,"<br>")}</td></tr>`;
    }
    reportHTML += `</tbody></table>
    <p>From: <a style="color: #96ab37;" href="${fromUrl}">${fromUrl}</a></p>
    </div>`;

    const reportOpts = {
        // from: `${fields.name} <${fields.email}>`,
        from: `${fromName} <${fromEmail}>`, // sender address
        to: reportRecipients,
        cc: fields.hasOwnProperty('cc') ? fields.email : '',
        replyTo: `${fields.name} <${fields.email}>`,
        subject: mSubject, // Subject line
        // text: '!' + reportHTML, // plain text body
        html: reportHTML, // html body
    };

  /* send reporting email */
    const transporter = nodemailer.createTransport(
        `smtps://userid:password@in-v3.mailjet.com`
    );

    //this is callback function to return status to firebase console
    const getDeliveryStatus = function (error: any, info: { messageId: any; }) {
        if (error) {
            return console.log(error);
        }
        console.log('Message sent: %s', info.messageId);
        // Message sent: <b658f8ca-6296-ccf4-8306-87d57a0b4321@example.com>
    };

    if ( ! fields.hasOwnProperty('cc') && fields.hasOwnProperty('_autoreply')) {
        const replyHTML = `<div style="font-size: 1.2em; padding: 1.5em;">
    <div><a style="font-weight: 900; font-size: 2.5em; text-decoration: none;" href="https://markjonesvoice.com/"><img alt="Mark Jones Voice" style="max-width: 100%; width: 75%; min-width: 400px;" src="${logoPurple}"></a></div>
    <p>Hi ${fields.name},</p>
    <p>${fields._autoreply}</p>
    <p>I've received your request and will get back to you shortly.</p>
    <p>- Mark</p>
    <div><small style="color: #333"><i>Received: ${(new Date()).toLocaleString("en-US",{timeZone:"America/New_York"})}</i></small></div>
    <div><small style="color: #333"><i>From: <a style="color: #61435e;" href="${fromUrl}">${fromUrl}</a></i></small></div>
    </div>`;
        const replyOpts = {
            from: `${fromName} <${fromEmail}>`, // sender address
            to: fields.email, // list of receivers
            subject: (fields.hasOwnProperty('_autoreply_subject') ? fields._autoreply_subject : 'MJV Submission Received'), // Subject line
            html: replyHTML,
        };
        // if (false) console.log('RO',transporter,reportOpts,firestore,replyOpts,getDeliveryStatus);
        transporter.sendMail(replyOpts, getDeliveryStatus);
    }

    //call of this function send an email, and return status
    transporter.sendMail(reportOpts, getDeliveryStatus);
    if (json) {
        res.json({status:"Sent",message:"Your message was sent"});
    } else { 
        res.redirect(nextUrl+"?status=Sent");
    }

    } catch (err) {
        console.log('Error in emailFormHandler()',err);
    }
});

Bonus: Store Submission Data in Firestore

Firestore must be set up and inialized by this point.

admin.initializeApp({
    credential: admin.credential.applicationDefault(),
    databaseURL: "path/to/your/firestore",
});

interface IContactData extends Record<string,any> {
    date: Date,
    subject: string,
    email: string,
    name?: string,
    message?: string,
    fromURL?: string,
}
/* store data */
const data: IContactData = {
    date: new Date(),
    subject: mSubject,
    email: fields.email,
    name: fields.name,
    from_URL: fromUrl,
}
function addField(fieldName:string) {
    if (fields.hasOwnProperty(fieldName) 
    && (includeEmpty || fields[fieldName] !== '')) {
        data[fieldName] = fields[fieldName];
    }
}
for (const key in fields) {
    if (includeAll || ! key.startsWith('_')) {
      addField(key);
    }
}

const collection = firestore.collection('web_forms');
const docref = collection.add(data)
.then(function() {
    console.log("Saved!",docref);
}).catch(function (error) {
    console.log("Error while saving,",error);
});
/* end store data */

Because this page assumes you’ve already got yourself set up with Firebase, I skip all that. But perhaps you’d like to start with someone’s getting started guide:

Conclusion

My cloud function does what I want it to do and does a fair job filtering spam thanks to the honeypot. The only real trick that I’m sharing is how to handle the AJAX with busboy; I didn’t find a great reference for just wanting to deal with the fields, most of the time Busboy is introduced to receive the file for upload, hence multipart.