MailSignals

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.

  1. 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_PROJECT
    gcloud 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_PROJECT

    Create Firestore. The location is permanent, so pick one near you and make it match REGION in functions/src/lib/config.ts (europe-west1 as shipped).

    gcloud firestore databases create --location=europe-west1 \
      --type=firestore-native --project=YOUR_PROJECT

    Set a budget alert at $5 in the Cloud Console. Do not skip this.

  2. 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_PROJECT

    Point the app and track targets in .firebaserc at 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.app

    DNS usually resolves within minutes; the permanent certificate can take hours, but Firebase serves a working temporary one first. scripts/wait-for-certs.sh polls until both domains serve HTTPS.

  3. 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/ and https://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 requests openid 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_KEY in extension/wxt.config.ts and the id into EXTENSION_ORIGINS in functions/src/lib/config.ts. Then enable Google sign-in for the project and save the client id as {"clientId": "..."} in .secrets/oauth-client.json so the config script picks it up.

  4. 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_SUBJECT and IP_SALT in Secret Manager, then grant the runtime access:

    ./scripts/grant-secrets.sh YOUR_PROJECT

    Without IP_SALT the functions fall back to an obvious dev salt, so a misconfigured deploy is visible rather than silently insecure.

  5. 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.txt

    Without 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.

  6. 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.sh

    The 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.json pointing at the vendored tarball; run git checkout functions/package.json before committing.

  7. Digest email (optional)

    The hourly digest writes to a mail collection and stops there. To deliver it, install the Firebase Trigger Email extension in your project, point it at the mail collection and your SMTP provider, and set DIGEST_FROM if 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.

  8. 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.

  9. Load the extension

    Chrome: chrome://extensions → Developer mode → Load unpackedextension/build/chrome-mv3. Edge: edge://extensionsextension/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.

  10. 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.

  11. 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.

Troubleshooting symptoms and causes
SymptomLikely cause
Sign-in fails with redirect_uri_mismatchThe extension id changed, or the redirect URI is missing from the OAuth client
Checkmarks never appearInboxSDK 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 GmailNo 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 ownExpected 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 lowGmail caches the proxied image, so repeat opens may not reach you. Counts from Gmail are a lower bound
Digest never arrivesThe Trigger Email extension is not installed or not pointed at the mail collection; step 7
Functions deploy fails on secretsRun ./scripts/grant-secrets.sh
An agent gets 401 agent-key-invalidThe key was revoked, mistyped, or belongs to another deployment
An agent gets 403 mailbox-not-linkedLink that Gmail address under Settings → Gmail accounts
An agent's mail arrives with two pixelsIt 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.