Last year I wrote the guide How to integrate Serverless contact form using Firebase Cloud functions in React. Now we will improve our contact form with Express.
Install all dependencies
The guide requires 6 dependencies.
npm install cors express express-validator firebase-admin firebase-functions nodemailer
Functions tree
After executing the firebase init functions
(find more information on my previous post) we need to navigate to functions' folder and create the following folders and files.
functions
|-- api
| \-- contacts.js
|
|-- config
| |-- db.js
| \-- firebase-adminsdk.json
|
|-- .gitignore
|-- .runtimeconfig.json
|-- index.js
Now let's go through all the files:
In .gitignore file
Here we prevent from uploading to the git
repository all sensitive information from firebase
.
node_modules/
.runtimeconfig.json
firebase-adminsdk.json
In .runtimeconfig.json file
We can generate this file with firebase
(details in the previous post) or just create the JSON file and write the credentials like in the snippet below.
{
"gmail": {
"password": "****",
"email": "example@mail.com"
}
}
NB: Never write your password in plain text, even in this JSON file. Create an App password from your google account. This way you can always revoke access from everywhere. With App password
, we don't need to turn off two-factor authentication.
In config > db.js and firebase-adminsdk.json file
First, we need to generate a service account key from firebase
to access the database.
We can download the firebase-adminsdk.json
file. Navigate to Firebase's Project settings > Service accounts > Firebase Admin SDK and press the Generate new private key
button.
Next, we create db.js
and write the code below. We need to replace the databaseURL
with ours. To find the URL, check again the Firebase Admin SDK
page. The URL is just above the Generate new private key
button.
const admin = require('firebase-admin');
const serviceAccount = require('./firebase-adminsdk.json');
exports.initDb = admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
databaseURL: 'https://webpage.firebaseio.com'
});
In index.js file
Here we add Express and set routes. We need to enable CORS
because routes will be from Firebase origin and add middleware to parse incoming requests with JSON payloads. The route we define is /api/contacts
and when we hit it, we call the file /api/contacts.js
.
'use strict';
const functions = require('firebase-functions');
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors({ origin: true }));
// Init Middleware
app.use(express.json());
// Define routes
app.use('/api/contacts', require('./api/contacts'));
exports.app = functions.https.onRequest(app);
In api > contacts.js
Here is all the back-end logic. We import all the needed libraries and files that we need.
To send a notification email, we use nodemailer
. All the logic is in the sendEmail
method. The settings are straight from the nodemailer documentation and we use environment variables to hide our email credentials. To set the variables, we use the following command in the terminal:
firebase functions:config:set gmail.email="<user@gmail.com>" gmail.password="<pass>"
To check what we have set we use the command:
functions:config:get
The line will output object like:
{
"gmail": {
"email": "user@gmail.com",
"password": "pass"
}
}
We can access the password with:
const gmailPassword = functions.config().gmail.password;
NB: Never commit password
or API keys
. Almost all services support a way to hide or mask them. Search for secrets
or environment variables
. If you commit a sensitive information and then commit again to hide it you need to squash and rebase them and all between to erase history.
To handle a POST request to api/contacts
we use router.post()
. The first param is the path, and Express uses folders and filenames to construct the routes. That is why we put only '/'
and not api/contacts/
. For example, if we want to hit api/contacts/list
, we put '/list
.
Second param is a validation array, for more information check the express-validator docs. I find it more practical to use this node library than to write validation checks.
The third param is a callback function which handles the request. If there is a validation error, we notify the front-end. If all is good, we emailed a message, write the data into our database and finally notify the front-end that all is successful.
'use strict';
const functions = require('firebase-functions');
const express = require('express');
const nodemailer = require('nodemailer');
const router = express.Router();
const { check, validationResult } = require('express-validator');
const { initDb } = require('../config/db');
const db = initDb.firestore();
router.post(
'/',
[check('email', 'Please include a valid email').isEmail(), check('message', 'Message is required').not().isEmpty()],
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(400).json({ errors: errors.array() });
return;
}
sendEmail(req.body);
db.collection('contacts').add(req.body);
res.status(200).send({ isEmailSend: true });
}
);
const sendEmail = (entry) => {
const mailTransport = nodemailer.createTransport({
service: 'gmail',
auth: {
user: functions.config().gmail.email,
pass: functions.config().gmail.password
}
});
mailTransport.sendMail({
from: entry.email,
replyTo: entry.email,
to: functions.config().gmail.email,
subject: `pantaley.com ${entry.email}`,
text: entry.message,
html: `<p>${entry.message}</p>`
});
};
module.exports = router;
In our Front-End JS file
Here is how we make a POST request to the server. Check the route, the origin is from cloudfunctions.net
and not your domain, that is why we need CORS
in the back end. You can find your route when you upload the function log into firebase and navigate to Functions section.
NB: I you native fetch api, but you can use your own library or XMLHttpRequest.
Handle errors
If we have validation errors res.errors
will be an array of object(s). Every object will have value, msg, param, location
properties. In the back-end we set this validation for the email:
check('email', 'Please include a valid email')
In the check
method, first is the param
second is the msg
. From here we can create a React Hook to fetch all errors and another to toggle a loading spinner.
The guide is created and tested with React and that is why it is in the title, but it is not required.
fetch('https://ourProjectName.cloudfunctions.net/app/api/contacts', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-type': 'application/json'
},
body: JSON.stringify(data)
})
.then((res) => res.json())
.then((res) => {
if (res.errors) {
// handle errors
}
if (res.isEmailSend) {
// handle when status is 200
}
return res;
})
.catch((errors) => {
console.log(errors);
});