# Send email with Firebase Functions: a production TypeScript guide

> Send email from second-generation Firebase Functions with a server-side API key, Firestore trigger, safe message policy, retry-aware state, local testing, and delivery events.

- **Category:** Developer guide
- **Published:** August 4, 2026
- **Reading time:** 19 min read
- **Author:** Maya Chen, Email infrastructure
- **Canonical page:** [https://emailbump.com/blog/send-email-firebase-functions](https://emailbump.com/blog/send-email-firebase-functions)

Firebase does not deliver application email by itself. A Firebase application can call an email provider from trusted server code, use the official Trigger Email extension with an SMTP service, or publish durable email intent that a worker processes. For product mail, the important boundary is that untrusted clients never control a provider key or an arbitrary From, To, subject, and HTML payload.

This guide uses a second-generation Firestore-triggered Cloud Function and Email Bump's REST API. The function reacts to a server-created email-intent document, loads its own secret, resolves a fixed template policy, submits the message, and stores provider evidence. The same architecture works with another HTTP provider.

> **Firestore events are delivered at least once**
>
> A trigger can run more than once, and a network timeout can occur after the email provider accepted a request. A processed flag reduces ordinary duplicates but cannot create exactly-once delivery across Firestore and a remote provider. Important messages need semantic deduplication, explicit attempt state, and provider idempotency or reconciliation where available.

## Choose the Firebase email path

### Firebase email options

```text
PATH                         GOOD FIT                              WATCH FOR
Callable/HTTP Function       User requests a named action             auth, App Check, abuse limits
Firestore trigger            Durable app-created email intent         duplicate/out-of-order events
Firebase Trigger Email ext.  Firestore-to-SMTP with less custom code   collection rules and SMTP state
Cloud Tasks worker           Controlled retries and scheduled work    task identity and remote ambiguity
Authentication templates    Built-in Firebase Auth messages           template and sender constraints
Browser calls provider       Never with a privileged provider key      credential theft and open relay
```

Firebase's official Trigger Email extension watches a Firestore collection and sends through configured SMTP. It can be a good fit when its document model and status handling meet the requirement. A custom Function is useful when you need an HTTP API, server-owned templates, application-specific authorization, or a message ledger shared with other systems.

## Install and configure the Function

### Firebase setup

```bash
firebase init functions firestore emulators
cd functions
npm install firebase-admin firebase-functions

# Store the value in Google Cloud Secret Manager and bind it only to this function.
firebase functions:secrets:set EMAIL_BUMP_API_KEY

# Use .secret.local for the emulator; never commit it.
firebase emulators:start --only functions,firestore
```

Firebase recommends Secret Manager for sensitive Function configuration. A secret is unavailable to a function unless it is explicitly bound. After setting or rotating the secret, redeploy every function that references it. The legacy functions.config() path is deprecated and scheduled for decommissioning in March 2027, so new code should not copy older tutorials that depend on it.

## Create a narrow email-intent document

### Server-owned Firestore intent

```typescript
await db.collection("emailIntents").doc(`receipt:${paymentId}:v3`).create({
  kind: "payment_receipt",
  recipientUid: customerUid,
  data: { paymentId },
  state: "pending",
  createdAt: FieldValue.serverTimestamp(),
})

// The deterministic document ID is the semantic dedupe key.
// A browser must not be allowed to choose arbitrary recipients, senders, or HTML.
```

Create the intent from trusted backend code in the same logical operation that records the business event. Store stable identifiers, not a complete sensitive message. The worker should re-read authoritative payment, account, consent, and recipient state before sending so a stale queued document cannot email the wrong person.

## Send from a second-generation Firestore trigger

### functions/src/index.ts

```typescript
import { initializeApp } from "firebase-admin/app"
import { FieldValue, getFirestore } from "firebase-admin/firestore"
import { defineSecret } from "firebase-functions/params"
import { onDocumentCreated } from "firebase-functions/v2/firestore"

initializeApp()
const db = getFirestore()
const emailApiKey = defineSecret("EMAIL_BUMP_API_KEY")

export const sendEmailIntent = onDocumentCreated(
  { document: "emailIntents/{intentId}", secrets: [emailApiKey], retry: false },
  async (event) => {
    const snap = event.data
    if (!snap) return

    const intent = snap.data() as {
      kind: string; recipientUid: string; data: { paymentId?: string }
    }
    if (intent.kind !== "payment_receipt" || !intent.data.paymentId) {
      await snap.ref.update({ state: "rejected", errorClass: "invalid_intent" })
      return
    }

    const [userSnap, paymentSnap] = await Promise.all([
      db.doc(`users/${intent.recipientUid}`).get(),
      db.doc(`payments/${intent.data.paymentId}`).get(),
    ])
    const user = userSnap.data()
    const payment = paymentSnap.data()
    if (!user?.email || payment?.customerUid !== intent.recipientUid || payment?.state !== "paid") {
      await snap.ref.update({ state: "rejected", errorClass: "ineligible" })
      return
    }

    const response = await fetch("https://emailbump.com/api/v1/emails", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${emailApiKey.value()}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        from: "Acme Billing <receipts@example.com>",
        to: user.email,
        subject: `Receipt for ${payment.displayAmount}`,
        text: `Payment received. Receipt: ${payment.receiptUrl}`,
        html: `<h1>Payment received</h1><p><a href="${payment.receiptUrl}">View receipt</a></p>`,
      }),
      signal: AbortSignal.timeout(10_000),
    })

    if (!response.ok) throw new Error(`email_api_${response.status}`)
    const result = await response.json() as { id: string; message_id?: string }
    await snap.ref.update({
      state: "accepted", providerId: result.id, providerMessageId: result.message_id ?? null,
      acceptedAt: FieldValue.serverTimestamp(),
    })
  },
)
```

The example deliberately disables automatic event retry because the provider shown does not document an idempotency key for this request. That avoids turning every thrown error into a blind resend, but it also means a transient failure needs a separate, deliberate recovery path. For high-value mail, use an outbox or Cloud Tasks worker with leases, attempt records, bounded backoff, expiry, and reconciliation of ambiguous submissions.

## Escape dynamic HTML and trust only configured URLs

The compact example assumes displayAmount and receiptUrl came from validated server records. Production templates should escape every untrusted value, build URLs from an allowlisted application origin, and preferably render a named/versioned template rather than concatenate HTML. Do not put ID tokens, provider keys, passwords, or long-lived privileged links into Firestore intent documents.

- Resolve the recipient from an authorized UID and current server-side state.
- Keep verified From addresses and templates in code or trusted configuration—not in client-writable documents.
- Use a short-lived, single-purpose token for verification or reset links and store only a hash where practical.
- Render both meaningful HTML and plain text and test with images blocked.
- Avoid logging the complete API request, message HTML, tokens, or provider secret.

## Lock down Firestore rules

### Deny direct client access to the outbox

```sql
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /emailIntents/{intentId} {
      allow read, write: if false;
    }
  }
}

// If a client needs to request an email, call a narrow authenticated Function.
// That Function validates the action and creates the server-owned intent.
```

Firebase's extension documentation explicitly warns that client access to the email collection can create abuse. Even sophisticated rules struggle to prove arbitrary HTML and destinations are safe. Prefer a callable Function such as resendVerification that derives one allowed recipient and template after Authentication, App Check, authorization, cooldown, and account-state checks.

## Design failure states instead of one processed flag

### Email intent states

```text
pending -> leased -> accepted -> delivered
                  |          -> bounced / complained
                  -> retry_wait -> leased
                  -> ambiguous -> reconciled / manual decision
                  -> rejected / expired

Store: semantic intent ID, lease owner/expiry, attempt count, request start,
provider message ID, HTTP class, accepted time, event IDs, and final evidence.
```

A connect failure before bytes are sent differs from a read timeout after the provider might have accepted the message. Retry 429 and retryable 5xx responses with bounded exponential backoff and jitter. Do not retry validation, authentication, or unverified-sender errors unchanged. Put an expiry on time-sensitive messages so a delayed worker does not send yesterday's login code.

## Record delivery events separately from submission

An API 2xx response means the provider accepted the request; it does not prove destination delivery. Store the provider ID, verify signed webhook events against the raw body, deduplicate event IDs, tolerate out-of-order events, and map delivery, bounce, complaint, and suppression evidence back to the immutable intent. Return webhook success quickly and process downstream work asynchronously.

## Test locally and in an email sandbox

- Use the Firestore and Functions emulators plus .secret.local; never point local code at production credentials.
- Replace the transport with a capture adapter for unit and integration tests, or route an isolated staging project to an email sandbox.
- Create the same deterministic intent twice and confirm only one document and one message are produced.
- Simulate a 429, 500, connect timeout, read timeout, malformed response, duplicate trigger, duplicate webhook, and stale payment state.
- Assert the envelope, subject, text, links, escaped variables, template version, and absence of secrets—not only an HTML snapshot.
- Use separate Firebase projects, Firestore collections, provider projects, domains, credentials, queues, and webhook secrets for staging and production.

## Frequently asked questions

## Can Firebase send email directly?

Firebase supplies serverless Functions, Authentication templates, Firestore, Tasks integrations, and an official Trigger Email extension. General application email still needs an SMTP server or email API provider. Do not embed that provider's privileged credential in a browser or mobile app.

## Should I use the Trigger Email extension or a Function?

Use the extension when its Firestore document contract, SMTP transport, templates, and delivery-status model fit. Use a custom Function or worker when you need a provider HTTP API, application-specific authorization, richer state, custom retries, reconciliation, or a shared outbox. Both require restrictive collection rules and server-owned recipient policy.

## How do I prevent duplicate Firebase emails?

Use a deterministic semantic intent ID, create it once, process it through conditional state transitions, and pass a stable provider idempotency key when the provider documents one. A processed boolean alone cannot resolve a timeout that occurs after remote acceptance. Preserve that outcome as ambiguous and reconcile before resending important mail.

## Connect Cloud Functions to observable delivery

Submit verified-sender messages from trusted Firebase code, then trace acceptance, delivery, bounce, complaint, and suppression in one transactional log.

- Server-side REST and SMTP
- Saved templates plus HTML and text
- Delivery and suppression webhooks

[Learn more](https://emailbump.com/docs/transactional-api)

## Build the complete Firebase email path

- [REST email API](https://emailbump.com/blog/send-email-rest-api) — Handle authentication, timeouts, retries, ambiguity, and webhooks.
- [Email sandbox](https://emailbump.com/blog/email-sandbox-testing) — Contain local, emulator, CI, QA, and staging messages.
- [Transactional examples](https://emailbump.com/blog/transactional-email-examples) — Map product events and states to useful messages.
- [Password reset email](https://emailbump.com/blog/password-reset-email-examples) — Implement recovery messages without weakening account security.

## Sources

- [Firebase: Trigger Email extension](https://firebase.google.com/docs/extensions/official/firestore-send-email)
- [Firebase: Configure Function secrets](https://firebase.google.com/docs/functions/config-env)
- [Firebase: Cloud Firestore triggers](https://firebase.google.com/docs/functions/firestore-events)
- [Firebase: App Check for callable Functions](https://firebase.google.com/docs/app-check/cloud-functions)
- [Email Bump transactional API](https://emailbump.com/docs/transactional-api)
