# Firebase Auth custom email handler

Replace Firebase Auth default emails with your own, sent through emails.sh from a Cloud Function, using generateEmailVerificationLink.

Firebase Auth's built in templates are limited to a small set of fields and send from a Google domain. The Admin SDK can generate the same action links without sending anything, which lets you send your own email through emails.sh. This page gives you the Cloud Function that does it.

## Setup

1. **Create a key** https://emails.sh/dashboard issues a key starting with esh_.
1. **Store it with Secret Manager** firebase functions:secrets:set EMAILSSH_API_KEY. Functions v2 injects declared secrets at runtime, and the value is not in your source or your deploy output.
1. **Generate links instead of sending** generateEmailVerificationLink and generatePasswordResetLink return the action URL without sending Firebase mail, which is what makes the swap possible.
1. **Call your function instead of the client SDK method** Replace sendEmailVerification(user) in your client with a call to this function, otherwise Firebase sends its own email too and the user gets two.

## Install

```bash
npm install firebase-admin firebase-functions
```

## functions/.env (non-secret values only)

```bash
EMAILSSH_FROM="Acme <hello@acme.com>"
APP_URL=https://acme.com

# The API key is a secret, not an env var:
#   firebase functions:secrets:set EMAILSSH_API_KEY
```

## The Cloud Function

functions/src/index.ts (Cloud Functions for Firebase v2)

```ts
import { initializeApp } from 'firebase-admin/app';
import { getAuth } from 'firebase-admin/auth';
import { onCall, HttpsError } from 'firebase-functions/v2/https';
import { defineSecret } from 'firebase-functions/params';

initializeApp();

const emailsshApiKey = defineSecret('EMAILSSH_API_KEY');

async function sendEmail(apiKey: string, to: string, subject: string, html: string, text: string) {
	const response = await fetch('https://emails.sh/v1/emails', {
		method: 'POST',
		headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
		body: JSON.stringify({
			from: process.env.EMAILSSH_FROM ?? 'Acme <onboarding@emails.sh>',
			to: [to],
			subject,
			html,
			text
		})
	});

	if (!response.ok) {
		throw new HttpsError('internal', `emails.sh returned ${response.status}: ${await response.text()}`);
	}
}

export const sendVerification = onCall(
	{ secrets: [emailsshApiKey], region: 'europe-west1' },
	async (request) => {
		if (!request.auth) throw new HttpsError('unauthenticated', 'Sign in first');

		const user = await getAuth().getUser(request.auth.uid);
		if (!user.email) throw new HttpsError('failed-precondition', 'This account has no email address');
		if (user.emailVerified) return { alreadyVerified: true };

		// Generates the action link without sending Firebase's own email, which
		// is the whole reason this works.
		const link = await getAuth().generateEmailVerificationLink(user.email, {
			url: `${process.env.APP_URL}/welcome`,
			handleCodeInApp: false
		});

		await sendEmail(
			emailsshApiKey.value(),
			user.email,
			'Confirm your email',
			`<p>Hi ${user.displayName ?? 'there'},</p>
<p><a href="${link}">Confirm your email address</a></p>
<p>This link expires in one hour. If you did not sign up, ignore this email.</p>`,
			`Confirm your email address: ${link}`
		);

		return { sent: true };
	}
);
```

## The password reset function

functions/src/reset.ts

```ts
import { getAuth } from 'firebase-admin/auth';
import { onCall } from 'firebase-functions/v2/https';
import { defineSecret } from 'firebase-functions/params';

const emailsshApiKey = defineSecret('EMAILSSH_API_KEY');

export const sendPasswordReset = onCall(
	{ secrets: [emailsshApiKey], region: 'europe-west1' },
	async (request) => {
		const email = String(request.data?.email ?? '').trim();
		if (!email.includes('@')) return { sent: true };

		let link: string;
		try {
			link = await getAuth().generatePasswordResetLink(email, {
				url: `${process.env.APP_URL}/signin`
			});
		} catch {
			// Always answer the same way. Telling the caller the address is
			// unknown turns this endpoint into an account enumeration oracle.
			return { sent: true };
		}

		await fetch('https://emails.sh/v1/emails', {
			method: 'POST',
			headers: {
				Authorization: `Bearer ${emailsshApiKey.value()}`,
				'Content-Type': 'application/json'
			},
			body: JSON.stringify({
				from: process.env.EMAILSSH_FROM ?? 'Acme <onboarding@emails.sh>',
				to: [email],
				subject: 'Reset your Acme password',
				html: `<p><a href="${link}">Choose a new password</a></p>
<p>This link expires in one hour. If you did not ask for it, ignore this email.</p>`,
				text: `Choose a new password: ${link}`
			})
		});

		return { sent: true };
	}
);
```

## Calling it from the client

src/lib/auth.ts

```ts
import { getFunctions, httpsCallable } from 'firebase/functions';
import { getApp } from 'firebase/app';

const functions = getFunctions(getApp(), 'europe-west1');

// Use this instead of sendEmailVerification(user) from firebase/auth. Calling
// both means Firebase sends its template and you send yours.
export const sendVerification = httpsCallable<void, { sent: boolean }>(
	functions,
	'sendVerification'
);

export const sendPasswordReset = httpsCallable<{ email: string }, { sent: boolean }>(
	functions,
	'sendPasswordReset'
);
```

## Worth knowing

### generateLink does not send anything

generateEmailVerificationLink and generatePasswordResetLink return the URL and stop there. sendEmailVerification and sendPasswordResetEmail in the client SDK are the ones that send Firebase mail, so replace those calls, do not add to them.

### The action link is single use and short lived

It carries an oobCode that expires (one hour for verification by default) and is consumed on first use. Do not cache it, log it, or put it anywhere a link scanner will prefetch.

### Secrets, not functions.config()

The v1 runtime config API is deprecated. In v2, defineSecret plus firebase functions:secrets:set keeps the key in Secret Manager and out of your repo. Declare it in the secrets array or .value() throws at runtime.

### Do not reveal whether an address exists

generatePasswordResetLink throws auth/user-not-found for unknown addresses. Returning that to the caller lets anyone test which emails have accounts, so answer identically either way, as the reset function does.


## Questions

### How do I customise Firebase Auth emails beyond the built in templates?

Generate the action link with the Admin SDK and send your own email. The built in templates only expose a subject, a body, and a sender name.

### Can I send Firebase auth emails from my own domain?

Yes, once you send them yourself. Verify your domain on emails.sh and set from to an address on it.

### Why is the user getting two verification emails?

Your client still calls sendEmailVerification from firebase/auth as well as your function. Remove the client SDK call.

### Do I need the Blaze plan?

Yes for Cloud Functions, because outbound network requests to a third party API require a billing account.


Docs: https://emails.sh/docs.md