Self-hosting guide
One Firebase project, two subdomains, one deploy script.
Everything runs in one Firebase project that you own. Budget about an hour for a first run, most of it waiting for DNS and certificates. Typical cost is $0–3 a month, almost all of it the domain.
What you need
- Node 22 and pnpm 10
- The Firebase CLI (npm i -g firebase-tools) and the gcloud CLI
- A Google account
- A domain you control, with two spare subdomains
- A credit card for the Blaze plan. Cloud Functions do not run on the free Spark plan. The free quotas still apply on Blaze, so a personal instance normally bills nothing; set a budget alert anyway.
Every command below runs from the root of a checkout of the repository.
Create the project
Create a Google Cloud project, link billing, add Firebase, and enable the APIs.
gcloud projects create YOUR_PROJECT --name="MailSignals" gcloud billing projects link YOUR_PROJECT --billing-account=YOUR_BILLING_ID firebase projects:addfirebase YOUR_PROJECTgcloud services enable \ firebase.googleapis.com firestore.googleapis.com cloudfunctions.googleapis.com \ cloudbuild.googleapis.com run.googleapis.com artifactregistry.googleapis.com \ eventarc.googleapis.com cloudscheduler.googleapis.com cloudtasks.googleapis.com \ secretmanager.googleapis.com identitytoolkit.googleapis.com \ firebasehosting.googleapis.com firebaserules.googleapis.com \ fcm.googleapis.com pubsub.googleapis.com \ --project=YOUR_PROJECTCreate Firestore. The location is permanent, so pick one near you and make it match
REGIONinfunctions/src/lib/config.ts(europe-west1as shipped).gcloud firestore databases create --location=europe-west1 \ --type=firestore-native --project=YOUR_PROJECTSet a budget alert at $5 in the Cloud Console. Do not skip this.
Hosting sites and DNS
Two sites: the dashboard and the tracking domain. Keeping them apart matters, because the tracking host should serve nothing else, so its reputation stays clean.
firebase hosting:sites:create YOUR_PROJECT-app --project YOUR_PROJECT firebase hosting:sites:create YOUR_PROJECT-track --project YOUR_PROJECTPoint the
appandtracktargets in.firebasercat those site ids. Add both as custom domains in the Firebase console, then create the CNAME records your registrar needs:app CNAME YOUR_PROJECT-app.web.app t CNAME YOUR_PROJECT-track.web.appDNS usually resolves within minutes; the permanent certificate can take hours, but Firebase serves a working temporary one first.
scripts/wait-for-certs.shpolls until both domains serve HTTPS.Authentication
In the Google Cloud console under Google Auth Platform, configure the consent screen, then create an OAuth client → Web application with the dashboard origin and the Firebase auth domain as JavaScript origins, and two redirect URIs:
https://<EXTENSION_ID>.chromiumapp.org/andhttps://YOUR_PROJECT.firebaseapp.com/__/auth/handler. Add yourself under Test users, or publish the app. Publishing needs branding fields but no security assessment, because MailSignals only requestsopenid email profile.Get the extension id by generating a key first:
openssl genrsa -out .secrets/extension-key.pem 2048 openssl rsa -in .secrets/extension-key.pem -pubout -outform DER \ -out .secrets/extension-pub.der node -e "const f=require('fs'),c=require('crypto');const d=f.readFileSync('.secrets/extension-pub.der'); const h=c.createHash('sha256').update(d).digest();let i=''; for(let n=0;n<16;n++){i+=String.fromCharCode(97+(h[n]>>4))+String.fromCharCode(97+(h[n]&15));} f.writeFileSync('.secrets/extension-id.txt',i); f.writeFileSync('.secrets/extension-key-b64.txt',d.toString('base64'));console.log(i);"Put the base64 public key into
EXTENSION_KEYinextension/wxt.config.tsand the id intoEXTENSION_ORIGINSinfunctions/src/lib/config.ts. Then enable Google sign-in for the project and save the client id as{"clientId": "..."}in.secrets/oauth-client.jsonso the config script picks it up.Secrets
# VAPID keypair for Web Push (the server side is wired; no client subscribes yet) node -e "const c=require('crypto');const{publicKey,privateKey}=c.generateKeyPairSync('ec',{namedCurve:'prime256v1'}); const p=publicKey.export({type:'spki',format:'der'}).subarray(-65); const s=privateKey.export({type:'pkcs8',format:'der'}).subarray(36,68); const b=b=>b.toString('base64').replace(/\+/g,'-').replace(/\//g,'_').replace(/=+\$/,''); require('fs').writeFileSync('.secrets/vapid.json',JSON.stringify({publicKey:b(p),privateKey:b(s)},null,2));" # Salt for hashing IPs node -e "require('fs').writeFileSync('.secrets/ip-salt.txt',require('crypto').randomBytes(32).toString('base64url'))"Create the secrets
VAPID_PUBLIC_KEY,VAPID_PRIVATE_KEY,VAPID_SUBJECTandIP_SALTin Secret Manager, then grant the runtime access:./scripts/grant-secrets.sh YOUR_PROJECTWithout
IP_SALTthe functions fall back to an obvious dev salt, so a misconfigured deploy is visible rather than silently insecure.InboxSDK app id
Register a free app id at register.inboxsdk.com (it signs in with Google) and save it:
echo sdk_yourapp_xxxxxxxxxx > .secrets/inboxsdk-app-id.txtWithout it the extension still works but Gmail shows an "InboxSDK Developer Warning: Invalid AppId" banner. The SDK's telemetry is turned off in code regardless of the id.
Configure and deploy
Edit the project ids, origins and Firebase web config in
scripts/apply-config.mjs(they are public identifiers), then:pnpm install node scripts/apply-config.mjs ./scripts/deploy.shThe config script finds each setting by key and rewrites it in place, so it is safe to re-run whenever a value changes. The deploy script vendors the shared package, runs every unit test, refuses to deploy if any fail, deploys rules, indexes, both functions and the hosting sites, then builds the Chrome and Edge extension bundles. It leaves
functions/package.jsonpointing at the vendored tarball; rungit checkout functions/package.jsonbefore committing.Digest email (optional)
The hourly digest writes to a
mailcollection and stops there. To deliver it, install the Firebase Trigger Email extension in your project, point it at themailcollection and your SMTP provider, and setDIGEST_FROMif you want a different sender than the default. Nothing in the deploy does this, and nothing in the app can tell whether it is installed.Agent sending (optional)
If a program sends mail for you through the Gmail API, it can have the same tracking. In the dashboard, Settings → Agent keys → Create key, copy the secret (it is shown once), and give it to the program. For an MCP client, the same screen has a "Copy MCP config" button. Anything the agent leaves as a draft for you to send from Gmail is already tracked by the extension and must not be prepared this way, or the message gets two pixels. More on the agent API.
Load the extension
Chrome:
chrome://extensions→ Developer mode → Load unpacked →extension/build/chrome-mv3. Edge:edge://extensions→extension/build/edge-mv3. Confirm the id matches.secrets/extension-id.txt; if it does not, the manifest key did not apply and the OAuth redirect will fail. Edge ignores the manifest key, so the Edge build has a different id; add its redirect URI to the OAuth client too.Turn off your own image loading
This is the step people skip, and then wonder why their own opens are counted. The extension suppresses self-opens on desktop Gmail by correlating what you are looking at. It cannot do that in the official Gmail mobile app. So in Gmail settings on every device, set images to Ask before displaying external images: on the web under Settings → See all settings → General → Images; on Android and iOS under Menu → Settings → your account → Images.
Send a test
Send a tracked email to a different address: messages to yourself are never tracked, by design. Open it somewhere else and watch the checkmark turn green and the event appear in the dashboard with its classification, reason and "Who" line.
Verifying a deployment
GOOGLE_CLOUD_PROJECT=YOUR_PROJECT SIGNER_SA=firebase-adminsdk-xxxxx@YOUR_PROJECT.iam.gserviceaccount.com \
node scripts/e2e.mjs # functional
node scripts/e2e-security.mjs # cross-user isolation, through the rules
node scripts/e2e-links-replies.mjs # links, replies, bounces, closed threads
node scripts/e2e-agent.mjs # agent keys and agent-sent mail
The suites mint custom tokens with the Admin SDK, so they need application default credentials that can sign as the named service account (gcloud auth application-default login). They create and then purge their own test identities. Against the reference deployment on 2026-09-08 they ran 240 checks in total, all green.
Troubleshooting
The symptoms that come up, and what they almost always mean.
| Symptom | Likely cause |
|---|---|
| Sign-in fails with redirect_uri_mismatch | The extension id changed, or the redirect URI is missing from the OAuth client |
| Checkmarks never appear | InboxSDK failed to load; check the Gmail tab console. Confirm pageWorld.js is in the build output root and listed in web_accessible_resources |
| Red "InboxSDK Developer Warning: Invalid AppId" banner in Gmail | No registered app id; see step 5 |
| Banner "MailSignals was updated. Reload this Gmail tab" | You reloaded the extension under an open tab; reload the tab |
| Compose notice "Not tracking: … is not linked" | A second Gmail account in the browser; link it in the dashboard's Settings if it is yours |
| Every open shows as your own | Expected if you opened it in the same browser profile. Send to a different address |
| Opens show as "delivered, unconfirmed" | The recipient uses Apple Mail Privacy Protection. That is the honest answer, not a bug |
| Open count seems low | Gmail caches the proxied image, so repeat opens may not reach you. Counts from Gmail are a lower bound |
| Digest never arrives | The Trigger Email extension is not installed or not pointed at the mail collection; step 7 |
| Functions deploy fails on secrets | Run ./scripts/grant-secrets.sh |
| An agent gets 401 agent-key-invalid | The key was revoked, mistyped, or belongs to another deployment |
| An agent gets 403 mailbox-not-linked | Link that Gmail address under Settings → Gmail accounts |
| An agent's mail arrives with two pixels | It prepared a draft that the extension then also tracked. Prepare only mail the agent sends itself |
Something not covered here?
The repository carries the full documentation: the twelve invariants, the architecture and its change-impact map, a specification per capability, the data model, the API contract and the reasoning behind every decision.