Firebase email guides

Send email from Firebase Cloud Functions

Firebase client apps should not send transactional email directly.

If your email API key is available to React, Vue, Angular, Flutter Web or any other frontend code, it should be treated as public. Frontend environment variables are not a safe place for provider secrets.

The usual production pattern is to send email from trusted backend code. In Firebase projects, that normally means Cloud Functions.

This guide shows a safe Firebase Cloud Functions pattern using:

  • Firebase Functions
  • Firebase secrets
  • Firebase Authentication
  • Firebase App Check
  • the EmailsDone Node library

The goal is simple:

Keep the sensitive parts server-side, then send common app emails without building HTML templates from scratch.

What we are building

A typical Firebase email flow looks like this:

  1. A signed-in user performs an action in your app.
  2. The frontend calls a Firebase Cloud Function.
  3. The function checks the user is authenticated.
  4. Firebase App Check helps confirm the request came from your real app.
  5. The function reads the EmailsDone API key from a Firebase secret.
  6. The function sends a transactional email using the EmailsDone SDK.
  7. The frontend receives a simple success response.

The important part is that the email API key never goes into your frontend code.

Why email should go through Cloud Functions

Sending email usually creates a side effect: a message is queued, delivered, retried, bounced, complained about or blocked.

That is not something you want random browser code triggering freely.

A Cloud Function gives you a controlled place to:

  • check the user is signed in
  • validate the requested email action
  • protect the endpoint with App Check
  • keep API keys out of the browser
  • add retry protection where needed
  • log safe operational details
  • return a simple response to the frontend

The Firebase Function is the trusted integration point.

EmailsDone then handles the app email template, so the function does not become a pile of HTML strings.

Install the EmailsDone Node library

Inside your Firebase Functions project, install the EmailsDone package:

npm install emailsdone

The package should be used from backend code.

Do not create an EmailsDone client in frontend code if that would expose your API key.

Store the EmailsDone API key as a Firebase secret

Do not hard-code the API key.

Do not commit it to your repository.

Do not put it in frontend environment variables.

Use Firebase secrets instead:

firebase functions:secrets:set EMAILSDONE_API_KEY

In your function code, define the secret:

import { defineSecret } from "firebase-functions/params";

const emailsDoneApiKey = defineSecret("EMAILSDONE_API_KEY");

Then attach it to the function that needs it:

{
  secrets: [emailsDoneApiKey]
}

That makes the secret available to that function at runtime without putting it in your frontend bundle or source code.

Create a callable function

For app-triggered email, a callable function is often a good fit because Firebase client SDKs can call it directly and include Firebase Authentication context automatically.

Here is a simple example:

import { onCall, HttpsError } from "firebase-functions/v2/https";
import { defineSecret } from "firebase-functions/params";
import { EmailsDone } from "emailsdone";

const emailsDoneApiKey = defineSecret("EMAILSDONE_API_KEY");

export const sendWelcomeEmail = onCall(
  {
    secrets: [emailsDoneApiKey],
    enforceAppCheck: true,
  },
  async (request) => {
    if (!request.auth?.token.email) {
      throw new HttpsError("unauthenticated", "You must be signed in.");
    }

    const emailsDone = EmailsDone.fromApiKey(emailsDoneApiKey.value());

    await emailsDone
      .authentication()
      .welcome("https://app.example.com/get-started")
      .send(request.auth.token.email);

    return { queued: true };
  }
);

This function does four important things:

  1. It reads the EmailsDone API key from a backend secret.
  2. It enforces Firebase App Check.
  3. It checks that the caller is authenticated.
  4. It sends a welcome email using a built-in EmailsDone template.

The send itself is the small part:

await emailsDone
  .authentication()
  .welcome("https://app.example.com/get-started")
  .send(request.auth.token.email);

That is the main difference with a template-first email API.

You still use Firebase Cloud Functions as the secure backend layer, but you do not have to build the welcome email HTML yourself.

Add Firebase App Check

Firebase App Check helps protect your backend resources by checking that incoming traffic is coming from your real app rather than an unverified client.

For callable Cloud Functions, you can enforce App Check with:

enforceAppCheck: true

In the full function example, that appears here:

export const sendWelcomeEmail = onCall(
  {
    secrets: [emailsDoneApiKey],
    enforceAppCheck: true,
  },
  async (request) => {
    // function body
  }
);

For web apps, Firebase App Check can use reCAPTCHA providers. It is not a replacement for authentication or validation, but it is a useful extra layer for functions that trigger side effects such as sending email.

Use all three:

  • App Check: is this request coming from your real app?
  • Firebase Auth: who is the user?
  • Validation: is this action allowed?

Check Firebase Authentication

App Check helps protect the function from unwanted clients, but it does not tell you who the user is.

That is why the function still checks:

if (!request.auth?.token.email) {
  throw new HttpsError("unauthenticated", "You must be signed in.");
}

For a welcome email, using the authenticated user’s email is usually safer than accepting an arbitrary email address from the request body.

Avoid this pattern unless you have a specific reason:

const email = request.data.email;

If the caller can choose any email address, your function can become an email-sending endpoint for other people.

Prefer deriving the recipient from Firebase Auth or from server-side data you trust.

Validate the email action

Even if the user is authenticated, you should still validate what they are allowed to do.

For example:

  • before sending a billing email, check that the user belongs to the relevant account
  • before sending a team invitation, check that the user has permission to invite people
  • before sending a password reset or verification email, make sure the flow matches your app’s auth model

A simple welcome email might not need much input:

export const sendWelcomeEmail = onCall(
  {
    secrets: [emailsDoneApiKey],
    enforceAppCheck: true,
  },
  async (request) => {
    const email = request.auth?.token.email;

    if (!email) {
      throw new HttpsError("unauthenticated", "You must be signed in.");
    }

    const actionUrl = "https://app.example.com/get-started";

    const emailsDone = EmailsDone.fromApiKey(emailsDoneApiKey.value());

    await emailsDone
      .authentication()
      .welcome(actionUrl)
      .send(email);

    return { queued: true };
  }
);

More sensitive flows should check more.

Keep email code in one helper

Do not scatter email calls across lots of functions.

Create a small email helper instead:

import { EmailsDone } from "emailsdone";

export function createEmailService(apiKey: string) {
  const emailsDone = EmailsDone.fromApiKey(apiKey);

  return {
    sendWelcomeEmail(email: string, actionUrl: string) {
      return emailsDone
        .authentication()
        .welcome(actionUrl)
        .send(email);
    },

    sendVerifyEmail(email: string, verificationUrl: string) {
      return emailsDone
        .authentication()
        .verifyEmail(verificationUrl)
        .send(email);
    },

    sendPasswordReset(email: string, resetUrl: string) {
      return emailsDone
        .authentication()
        .passwordReset(resetUrl)
        .send(email);
    },
  };
}

Then your Firebase Function stays focused on Firebase concerns:

  • auth
  • App Check
  • validation
  • loading data
  • calling the email helper

The email helper stays focused on email.

Avoid obvious duplicate sends

Email functions create side effects, so it is worth thinking about retries.

A user might double-click a button, a frontend request might be retried, or a function call might be repeated after a timeout. For most simple emails this is not a major problem, but for password resets, login codes, invitations and billing messages, duplicate sends can be annoying.

EmailsDone supports optional idempotency keys for these cases:

await emailsDone
  .authentication()
  .loginCode("123456", {
    idempotencyKey: `login-code-${userId}-${requestId}`,
  })
  .send(email);

If the same key is reused, EmailsDone returns the original accepted message for 24 hours instead of creating another send.

You do not need this for every email. Use it where retries are likely or duplicate messages would be confusing.

What not to do

Avoid these patterns:

// Bad: API key in frontend code
const emailsDone = EmailsDone.fromApiKey(import.meta.env.VITE_EMAILSDONE_API_KEY);
// Bad: arbitrary recipient from client request
await emailsDone
  .authentication()
  .welcome(actionUrl)
  .send(request.data.email);
// Bad: HTML template strings scattered through functions
html: `<h1>Welcome</h1><p>...</p>`;
// Bad: no auth, no App Check, public email-sending endpoint
export const sendEmail = onRequest(async (req, res) => {
  // send whatever the request asks for
});

A safer pattern is:

  • backend only
  • Firebase secret
  • App Check
  • Firebase Auth
  • server-side validation
  • template-first send call
  • one small email helper

Testing the function

For a first pass, test the function in three ways:

  1. Confirm the function cannot run without authentication.
  2. Confirm App Check enforcement is enabled for real app calls.
  3. Confirm the EmailsDone API returns an accepted or queued response.

A full local emulator walkthrough is a separate topic, but the production checklist is:

  • the API key is stored as a Firebase secret
  • the key is not present in frontend code
  • the function has secrets: [emailsDoneApiKey]
  • the function has enforceAppCheck: true
  • the function checks request.auth
  • the recipient is derived from trusted data
  • the EmailsDone call returns successfully
  • retry-prone sends use stable idempotency keys where needed

The useful shortcut

Cloud Functions solve the trusted backend problem.

They do not solve the email-template problem.

If you use a normal email provider directly, you still need to create and maintain the email content yourself: subject lines, HTML, text versions, buttons, layout, responsive rendering and template variables.

EmailsDone keeps the Firebase side safe and boring, but removes the template work for common app emails.

For example:

await emailsDone
  .authentication()
  .passwordReset(resetUrl)
  .send(email);

Or:

await emailsDone
  .authentication()
  .verifyEmail(verificationUrl)
  .send(email);

Or:

await emailsDone
  .authentication()
  .welcome(actionUrl)
  .send(email);

The function is still yours.

The API key still stays server-side.

The email template is already done.