FirebaseNov 8, 2026 · 6 min read

firebase does not send emails, so i made it send emails

My first contact form went nowhere. I built the form, styled the inputs, added a little success message that popped up on submit, and felt pretty good about it for about a day. Then someone actually used it, and I realized the message had gone exactly nowhere. The button ran a console.log. That was the whole backend. (I had even tested it myself and watched the success message appear, which felt like proof until it very much was not.)

So I went looking for the send-email button in Firebase, because that is where everything else in the app already lived. There is not one. Firebase Auth sends its own emails, password resets and verification links, but those are Firebase's emails, not yours. If your app needs to send a welcome email, a receipt, or a seating chart link, you bring your own mail service, and Firebase gives you two clean ways to call it.

the extension route: write a doc, get an email

The fastest way is the Trigger Email from Firestore extension. You install it from the Extensions Hub in the console, or with one command:

terminalTypeScript
firebase ext:install firebase/firestore-send-email --project=my-app

The install asks for two things that matter. One, an SMTP connection URI, which is how the extension logs in to your mail service. With SendGrid it looks like smtps://apikey:SG.your-key@smtp.sendgrid.net:587, and SendGrid's free tier covers 100 emails a day, which is plenty for a small app finding its feet. Two, a mail collection, which defaults to mail. One detail to know before you start: the extension needs the Blaze pay-as-you-go plan, even though the extension itself and the free tiers underneath it usually cost nothing at this scale.

Once it is installed, sending an email is a Firestore write. That is the whole API. Your app adds a document, the extension picks it up, and the email goes out through your SMTP provider.

ContactForm.jsxTypeScript
import { addDoc, collection } from 'firebase/firestore';
import { db } from './firebase';

await addDoc(collection(db, 'mail'), {
  to: 'guest@example.com',
  message: {
    subject: 'Your seat is ready',
    html: '<p>Hi Sarah, your table is 4. See you Saturday.</p>',
  },
});

The extension then writes a delivery field back onto the same document: state SUCCESS with the time it went out, or state ERROR with the reason it did not. That field is how you debug it. If an email does not arrive, the answer is sitting on the document, not in a log file you have to go hunting for.

Try it: the document your app writes

Type an email, and this is the exact document your app would write to the mail collection. Hit send and watch the delivery field appear, the way the extension writes it back.

{
  "to": "sarah@example.com",
  "message": {
    "subject": "Your seat is ready",
    "html": "<p>Hi Sarah, your table is 4. See you Saturday.</p>"
  }
}

lock the mail collection down

An open mail collection is a free email service for anyone who finds your project, and someone will find it. The rule I use is simple: only signed-in users can create a document, and the to field has to be their own email address. Nobody reads the collection from the client at all, the extension does that server-side.

firestore.rulesTypeScript
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /mail/{docId} {
      allow create: if request.auth != null
        && request.resource.data.to == request.auth.token.email;
      allow read, update, delete: if false;
    }
  }
}

If the email needs to go to someone else, like a guest who did not sign in, the extension route stops being the right tool. That is the function route.

when a doc is not enough: onCall and nodemailer

A callable function runs your code first, so it can check things the rules can not: look up the guest, confirm the event exists, then send. This is the second-gen shape, with nodemailer doing the sending and the API key in Secret Manager instead of in the code.

functions/index.jsTypeScript
const { onCall, HttpsError } = require('firebase-functions/v2/https');
const { defineSecret } = require('firebase-functions/params');
const nodemailer = require('nodemailer');

const sendgridKey = defineSecret('SENDGRID_API_KEY');

exports.sendInviteEmail = onCall(
  { secrets: [sendgridKey] },
  async (request) => {
    if (!request.auth) {
      throw new HttpsError('unauthenticated', 'Sign in first.');
    }
    const { to, name, table } = request.data;
    if (!to || !name) {
      throw new HttpsError('invalid-argument', 'Missing to or name.');
    }
    const transporter = nodemailer.createTransport({
      host: 'smtp.sendgrid.net',
      port: 587,
      auth: { user: 'apikey', pass: sendgridKey.value() },
    });
    await transporter.sendMail({
      from: 'hello@myapp.com',
      to,
      subject: 'Your seat is ready',
      html: `<p>Hi ${name}, your table is ${table}.</p>`,
    });
    return { sent: true };
  }
);

You set the secret once with firebase functions:secrets:set SENDGRID_API_KEY, and the function reads it with sendgridKey.value() at runtime. Deploy with firebase deploy --only functions. One detail worth knowing: first-gen functions kept secrets in functions.config(), and that is gone in second-gen. Secrets are the replacement, and the deploy fails loudly if you forget to set one, which is the kind of failure I prefer.

Calling it from the app is one line:

InviteButton.jsxTypeScript
import { getFunctions, httpsCallable } from 'firebase/functions';

const functions = getFunctions();
const sendInviteEmail = httpsCallable(functions, 'sendInviteEmail');

const result = await sendInviteEmail({
  to: 'guest@example.com',
  name: 'Sarah',
  table: 4,
});

which one i reach for

The extension, when the email is a straight reaction to a write: a signup, a form submit, a status change. The function, when there is a decision in the middle: look something up, check something first, send to someone who did not sign in. Most of my apps end up with both, and that is fine, they share the same SMTP account underneath.

What I learned, plainly: sending email is not a Firebase feature, it is a small pipeline you build on top of Firebase, and the pipeline has three parts, the trigger, the mail service, and the rules that say who can use it. Skip any one of the three and you get either silence, spam, or a bill for someone else's emails. The extension handles the first part for free, SendGrid handles the second for 100 emails a day free, and the rules are ten lines you write once.