Sending Emails with Firebase

A Complete Guide Using Cloud Functions and Nodemailer

Stanislav Sopov
6 min readApr 13, 2024

Websites and apps use emails to keep users informed. This includes confirmations (sign-up, password reset), notifications (deliveries, receipts), and even marketing (newsletters, promotions).

Firebase apps can’t send emails directly, but Cloud Functions bridge the gap using libraries like Nodemailer to connect to email services and send emails upon receiving triggers or HTTP requests.

This guide dives deep into equipping your Firebase app with email functionality. We’ll walk you through setting up Firebase Cloud Functions and utilizing Nodemailer to send emails.

Setup

This guide uses the New Cloud Functions (2nd gen).

To get started, create a Firebase project and install Firebase CLI.

If you’re adding Cloud Functions to an existing TypeScript project or migrating an existing JavaScript project to TypeScript, follow these instructions.

In many cases, new features and bug fixes are available only with the latest version of the Firebase CLI and the firebase-functions SDK. It's a good practice to frequently update both the Firebase CLI and the SDK:

npm install firebase-functions@latest firebase-admin@latest --save
npm install -g firebase-tools

Add Cloud Functions to your Firebase project:

firebase init functions

When adding Cloud Functions, Firebase CLI gives you options to build the project with JavaScript or TypeScript. Choosing TypeScript will create the necessary configs.

Adding Cloud Functions will create a file named functions/src/index.ts This is where your cloud functions reside. They can be called from your client-side code.

Firebase Emulator Suite

You’ll need the Emulator Suite for testing your code locally. For troubleshooting and detailed instructions for installing Emulator Suite refer to this page.

Add Emulator Suite to your Firebase project:

firebase init emulators

Identify emulators to be installed and optionally specify emulator port settings. init emulators is non-destructive; accepting defaults will preserve the current emulator configuration.

Nodemailer

Nodemailer integrates smoothly with Firebase Cloud Functions’ Node.js environment, making it an ideal choice for setting up email functionality within your Firebase website.

Install Nodemailer:

npm install nodemailer

Email Service Providers (ESPs)

Nodemailer acts as a bridge between your Firebase Cloud Functions and various email service providers (ESPs) like Gmail or SendGrid. In our example we’ll be using Gmail.

In order to use Nodemailer with Gmail you must create an app password. An app password is a 16-digit passcode that gives a less secure app or device permission to access your Google Account.

Cloud Functions Code

Here’s what your Cloud Functions code will look like in functions/src/index.ts:

import { onRequest } from 'firebase-functions/v2/https';
import { createTransport } from 'nodemailer';
import express from 'express';
import rateLimit from 'express-rate-limit';

// Create an Express app
const app = express();

// Create a rate limiter that allows 1 request per 15 minutes
const rateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15-minute window
max: 1, // Maximum 1 request per 15-minute window per IP
message: 'Too many requests. Please try again later.',
headers: true,
handler: (request, response, options) => {
console.error('Rate limit exceeded:', request.ip);
response.status(429).json({ error: 'Too many requests. Please try again later.' });
},
});

// Apply the rate limiter middleware to the Express app
app.use(rateLimiter);

// Create a Nodemailer transporter using SMTP
const transporter = createTransport({
service: 'Gmail',
auth: {
user: process.env.GMAIL_EMAIL_ADDRESS,
pass: process.env.GMAIL_APP_PASSWORD,
}
});

interface EmailData {
name?: string;
subject?: string;
message?: string;
}

// The POST endpoint that handles email sending
app.post('*', async (request, response) => {
// Since CORS consists of two requests (a preflight OPTIONS request, and a main request that follows the OPTIONS request),
// to handle a preflight request, you must set the appropriate Access-Control-Allow-* headers to match the requests you want to accept.
// Learn more about CORS and preflight requests at: https://cloud.google.com/functions/docs/writing/write-http-functions#cors
response.set('Access-Control-Allow-Origin', '*');
if (request.method === 'OPTIONS') {
response.set('Access-Control-Allow-Methods', 'GET');
response.set('Access-Control-Allow-Headers', 'Content-Type');
response.set('Access-Control-Max-Age', '3600');
response.status(204).send('');
}

const data = request.body.data as EmailData;

// Check if the data object contains the required fields
if (!data.name || !data.subject || !data.message) {
response.status(400).json({ error: 'Missing required fields' });
return;
}

// Create the email message
const message = `
From: ${data.name}
Subject: ${data.subject}
Message: ${data.message}
`;

// The response from a client endpoint is always a JSON object.
// At a minimum it contains either `result` or `error`, along with any optional fields.
// If the response is not a JSON object, or does not contain data or error,
// the client SDK should treat the request as failed with Google error code INTERNAL (13).
// Learn more about the response format at: https://firebase.google.com/docs/functions/callable-reference#response_body
return transporter.sendMail({
from: data.name,
to: RECEPIENT_EMAIL_ADDRESS,
subject: data.subject,
text: message,
}).then(() => {
response.status(200).json({ result: 'Email sent successfully'});
}).catch(error => {
response.status(500).json({ error: `Error sending email: ${error.message}` });
});
});

// Export the Express app with rate limiter applied and CORS enabled
export const sendEmail = onRequest({
cors: [
'http://localhost:5002',
'http://127.0.0.1:5002',
'https://www.your-allowed-domain.com',
],
}, app);

There are a few things to note here:

CORS

By default, HTTP functions don't have CORS configured, which may result in the following error:

Access to fetch at 'https://YOUR_FUNCTION_URL' from origin 'https://YOUR_DOMAIN' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

We use the cors option to control which origins can access our function. At a minimum, we’ll need http://localhost:5002 to allow test requests made to Firebase emulators.

In addition, we handle a preflight request, which utilizes the OPTIONS method and precedes the main request. We must respond to the preflight request with a 204 response code and additional headers.

Rate Limiting

Firebase Cloud Functions do not automatically debounce or rate limit requests. This can lead to performance issues, unexpected costs, and security risks such as denial-of-service attacks.

Luckily, express-rate-limit offers an easy solution for this problem. By integrating this library into your function’s code, you can define limits on the number of requests allowed within a specific timeframe.

Response body format

The response from a client endpoint is always a JSON object, and must contain either result or error, along with any optional fields. Otherwise the request fails with Google error code INTERNAL (13).

Client-Side Code

Here’s what your client-side code looks like:

import { initializeApp, getApp } from 'firebase/app';
import { httpsCallable, getFunctions, connectFunctionsEmulator } from 'firebase/functions';

const app = initializeApp(YOUR_FIREBASE_PROJECT_SETTINGS);
const functions = getFunctions(app);

// Connect your app to the Cloud Functions Emulator running on localhost for local testing
// When running locally, Cloud Functions will be available at http://localhost:5001/<projectId>/<region>/<functionName>
// Learn more about testing Cloud Functions locally: https://firebase.google.com/docs/emulator-suite/connect_functions
if (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") {
connectFunctionsEmulator(functions, '127.0.0.1', 5001);
}

const sendEmail = httpsCallable(functions, 'sendEmail');

sendEmail({
name: NAME_INPUT_VALUE,
subject: SUBJECT_INPUT_VALUE,
message: MESSAGE_INPUT_VALUE,
}).then((result) => {
console.log('Email sent:', result);
}).catch((error) => {
console.error('Error sending email:', error);
});

Testing with Firebase Emulator

You can run emulators for any or all of the supported Firebase products. For any products you are not emulating, your code will interact with the live resource (database instance, storage bucket, function, etc.).

Code changes you make during an active session are automatically reloaded by the emulator. If your code needs to be transpiled (TypeScript, React) make sure to do so before running the emulator.

You can run your transpiler in watch mode with commands like tsc -w to transpile and reload code automatically as you save. If you’re using a packager tool, e.g. Parcel, don’t forget to run npm run build .

To run the Cloud Functions emulator, use the emulators:start command:

firebase emulators:start

Starting the emulator in the terminal will show the following:

✔  hosting[YOUR_FIREBASE_PROJECT_ID]: Local server: http://127.0.0.1:5002
✔ functions: Loaded functions definitions from source: sendEmail.
✔ functions[us-central1-sendEmail]: http function initialized (http://127.0.0.1:5001/<projectId>/us-central1/sendEmail).

Now your project is available at http://localhost:5002 using the Cloud Functions emulator.

Security

You might have noticed that the Cloud Functions code uses process.env to retrieve the Gmail address and app password. While this is okay for testing, it is not a secure way to store sensitive information.

When writing code for production, consider using Google Cloud Secret Manager. This encrypted service stores configuration values securely, while still allowing easy access from your functions when needed.

Similar to Cloud Functions to how .env.local take precedence over .env you can override secrets values by setting up a .secret.local file. This makes it easy for you to test your functions locally.

Refer to the Firebase Cloud Functions documentation to learn how to safely configure your environment for production and testing with the Cloud Functions Emulator.

Conclusion

Sending emails with Firebase Cloud Functions might involve some initial setup, but it offers a robust and secure solution in the long run. By leveraging Nodemailer and external email service providers, you can automate emails without managing a separate server.

Moreover, Firebase Security Rules ensure only authorized functions can access sensitive information like email credentials. This combination provides a reliable and secure way to integrate email functionality within your Firebase app.

--

--