Security
Evidence a client can forge is worthless, so no client can write it.
MailSignals is shaped by twelve rules that are not preferences: each is enforced somewhere in the code, and breaking one would change what the product is. They are reproduced here from the project's constitution, with the file that enforces each.
- Gmail OAuth scopes requested
- 0
- Checks in the security suite, run as a real end user against production
- 72
- Unit tests, no emulator needed
- 371
- Secrets in Secret Manager, none in the repository
- 4
The twelve invariants
Written from the code on 2026-09-08. If a citation no longer matches the code, the code moved and the document is stale; the project's working rules require fixing it in the same change.
Article I. The core requests no Gmail OAuth scope
MailSignals reads and writes Gmail only through the page, via InboxSDK. The extension's OAuth request asks for openid, email and profile and nothing more.
Why. A Gmail scope is a restricted scope: Google verification, an annual CASA assessment, and a 100-user cap until it passes. For a self-hosted product that price buys nothing, because everything the product needs is on screen already.
Enforced by
extension/src/background/auth.ts,extension/wxt.config.ts.Article II. Tracking evidence is written only by the server
No client may create or modify a message, an event, a link, a counter, a contact, a statistic or an alert's content. Clients get exactly two write paths: their own settings and profile fields, and the read and snooze flags on their own alerts.
Why. A pixel hit is evidence. Evidence a client can forge is worthless, and a client that can write its own counters can also sidestep the classifier.
Enforced by
firestore.rules,scripts/e2e-security.mjs.Article III. The tracking endpoint answers first and records second
The GIF and the 302 are flushed to the caller before any Firestore work starts. Recording failures are logged and never affect the response.
Why. Mail proxies give up quickly. A slow or failing database must cost us data, never cost the recipient a broken image or a dead link.
Enforced by
functions/src/track.ts.Article IV. The tracking endpoint reveals nothing
An unknown token gets byte-for-byte the same response as a real one. Registering a token owned by someone else answers the same 400 as a malformed one; another user's message answers 404.
Why. Anything else turns the endpoint into an oracle for which tokens exist, and pixel tokens are printed into other people's inboxes.
Enforced by
functions/src/track.ts,functions/src/api/messages.ts.Article V. A recipient's raw IP is never stored
Only a truncated network (/24 for IPv4, /48 for IPv6) and a salted hash. The raw address exists only as a local in the request handler. The same rule applies to the sender.
Why. The truncation is enough to notice "same network as last time"; the hash is enough to de-duplicate. Neither can be turned back into a person, and the secret salt stops the hash being reversed by enumerating the IPv4 space.
Enforced by
functions/src/tracking/record.ts,shared/src/net.ts.Article VI. The extension fails towards sending the mail
The presending handler never cancels a send; every hook is wrapped; anything that rewrites a body returns the original on any doubt; an orphaned content script refuses to plant a pixel rather than plant one that can never be registered.
Why. The extension sits in the path of your correspondence. Any failure that delays, mangles or cancels a send is worse than the entire feature not existing.
Enforced by
extension/src/gmail/compose.ts,extension/src/gmail/index.ts.Article VII. Every number is defensible in one sentence
Every stored event carries a classification, a confidence and a plain-English reason, and the UI shows the reason next to the verdict as body text, not a tooltip.
Why. A tick that cannot be justified is worse than no tick, because the user acts on it.
Enforced by
shared/src/classifier.ts,dashboard/src/pages/EmailDetail.tsx.Article VIII. Uncertainty is reported as uncertainty
An Apple prefetch is delivery, never a read. A proxied open is a lower bound whose "Who" line says it may be the sender. A multi-recipient open never names a recipient. An unrecognised client is recorded but not counted. A bounce for one recipient never overrides engagement by the others.
Why. The failure this product exists to avoid is an inflated number that someone makes a decision on.
Enforced by
shared/src/classifier.ts,shared/src/status.ts,functions/src/aggregates.ts.Article IX. Judgement logic is dependency-free and directly testable
Anything that decides something lives in a module that imports no Firebase and touches no network. Firestore-aware code is a thin shell over it.
Why. It is what lets the whole unit suite run with no emulator in seconds, and why the rules most likely to be tuned can be exercised exactly.
Enforced by
functions/src/lib/,shared/src/.Article X. The shared package is runtime-agnostic
No Node built-ins, no DOM globals, no Firebase imports. WebCrypto through globalThis.crypto with an explicit availability check.
Why. The same module is compiled into a Cloud Function, a service worker, a content script and a browser bundle. A single node:crypto import would break three of the four.
Enforced by
shared/src/tokens.ts,shared/package.json.Article XI. Only a mailbox the user linked is tracked
A message is registered only if the Gmail address it was sent from is linked to the calling account, checked server-side before anything is written, on the agent surface too. The signed-in address is linked automatically; every other one needs a decision.
Why. A browser can hold several Gmail accounts and the content script runs in all of them. Without this gate, mail sent from a colleague's account on a shared machine is filed under whoever is signed in.
Enforced by
functions/src/api/mailboxes.ts,scripts/e2e-security.mjs.Article XII. Nothing leaves the user's project to a third party
No component sends data to any service other than the user's own Firebase project and Google's own services. InboxSDK is loaded with crash reporting and usage tracking off. The dashboard loads no third-party fonts or analytics. Digest mail is handed to the operator's own Trigger Email extension.
Why. "Self-hosted" is the promise. A library that phones home breaks it even when the payload is only telemetry.
Enforced by
extension/src/gmail/index.ts,dashboard/index.html.
The mechanics behind the rules
Signed links
Every tracked link is /l/<token>.<idx>?u=<destination>&s=<sig>, where the signature is HMAC-SHA256 over the token, index and destination with a per-user key, truncated to 128 bits. The redirect verifies before it does anything else; a failed signature is a 400, never a redirect. Without that, the endpoint would be an open redirector and the domain blacklisted within days. The key never leaves the service worker: the content script asks for finished URLs.
Agent keys
A key looks like msk_ plus 43 base64url characters (256 random bits), is shown once, and is stored only as a salted, domain-separated hash. It authorises the agent routes and nothing else, through its own middleware on its own router, so a route added to the main API later cannot become reachable with it by accident. Revoking deletes the lookup document; the key stops on the next request.
Authenticated API
Every route requires a verified Firebase ID token with revocation checked, behind a CORS allowlist of the dashboard origin and the pinned extension id. Routes are mounted under one guard so no page can be added outside it. Firestore rules, not the API, are the real read boundary, which is why the security suite tests through the Firestore REST API as a real end user.
Abuse resistance
The tracking endpoint keeps a per-instance negative cache (5 seconds, 5,000 entries) so a flood of junk tokens cannot turn into a Firestore bill; hits that arrive before their registration are parked, at most ten per token, and replayed inside the registering transaction. Exports guard every string cell against spreadsheet formula injection. Sign-out clears everything the extension stored locally, including queued registrations that hold subject lines and recipient addresses.
Secrets
The IP-hashing salt and the VAPID keys live in Secret Manager and are granted to the runtime by a script. Without the salt the functions fall back to an obvious development value, so a misconfigured deploy is visible rather than silently insecure. The extension id is pinned by a manifest key so the OAuth redirect and the CORS allowlist cannot drift.
What is tested against production
The security suite mints a second identity and checks cross-user isolation through the rules: reading, registering against, marking or bouncing another user's message; forging a link signature with another user's key; an ID token that is not an agent key and an agent key that cannot reach the authenticated API; an owner who cannot forge her own open counts. Test identities are purged after every run.
Reporting a vulnerability
If you believe you have found a security problem in MailSignals, please report it privately rather than in a public issue: say it is a security issue in the subject, and keep exploit detail out of anything public until it is fixed.
Use the repository's private security advisory page once the repository is public; until then, the contact details are in the repository's README.
A machine-readable pointer to this section lives at /.well-known/security.txt. Because every deployment is separate, a fix reaches you when you pull and redeploy; there is no vendor who can patch your instance for you, and no vendor who can be breached on your behalf.
Run it in your own project.
One Firebase project, two subdomains, one deploy script. About an hour the first time, and no subscription afterwards.