Why an agent's mail is invisible to a tracker
A Gmail tracking extension does its work during Gmail's own send: it plants the pixel and rewrites the links in the compose window as you press Send. An assistant that sends through the Gmail API never opens a compose window. Nothing is planted, nothing is registered, and the message never appears anywhere in your tracking history. Whatever the agent sent on your behalf, you learn nothing about what happened to it.
Drafts are already covered
The gap is narrower than it first looks. If the agent leaves a draft for you to review and send from Gmail, that draft opens as an ordinary compose window and the extension's send hook fires on it. Only mail the agent sends itself is invisible. Keep that distinction in mind, because it is also the source of the commonest mistake below.
The design: prepare server-side, send yourself
MailSignals closes the gap with an authenticated surface on your own deployment. The agent hands over the message and gets it back with the tracking added; then it sends the result with its own Gmail access. Four properties fall out of that shape:
- MailSignals gains no Gmail access. The agent keeps its own credentials and does its own sending.
- The agent holds no policy. It does not choose the pixel token, never sees the link-signing key, cannot skip your do-not-track list, and cannot register a message for a mailbox you have not linked.
- The message goes through the same registration as the extension's, so from the pixel onwards everything is identical: the same classifier, the same alerts, the same dashboard, with a "sent by an agent" tag.
- The body must survive. If anything about preparing it is in doubt, the agent gets its own HTML back byte-for-byte, untracked, and sends that.
whoami
Once, at start-up. Confirms the key works and which Gmail addresses are linked, so the agent can fail with something a person can act on rather than with a 403 per message later.
prepare_email
Immediately before sending. Send the mailbox, recipients and HTML body; receive the body with typed URLs linkified, eligible links signed through your tracking domain, your disclosure line if you use one, and the pixel. If policy refuses (every recipient is you, or one is on the do-not-track list) the reply says so and returns your original body, which the agent should send anyway.
send
With the agent's own Gmail access, unchanged.
confirm_sent, or cancel
Confirm with the message and thread ids Gmail returned, so the row in your Gmail gets its checkmark and a reply on the thread can be matched. If the send did not happen, cancel, so an abandoned preparation does not sit in the dashboard forever as "sent, never opened".
Wiring it up with MCP
In the dashboard, open Settings → Agent keys → Create key. The key looks like msk_ followed by 43 characters and is shown once; only a salted hash of it is stored. The same screen has a "Copy MCP config" button. A key authorises the agent routes and nothing else: it cannot read your history, change a setting, or mint another key, and revoking it stops it on the next request.
The MCP server is a small stdio program in the repository exposing five tools: mailsignals_whoami, mailsignals_prepare_email, mailsignals_confirm_sent, mailsignals_cancel and mailsignals_status. It holds no policy: each tool is one HTTP call, and error codes come back as a sentence naming the fix. Build it from a checkout (pnpm --filter @mailsignals/mcp build) and point your MCP client at it:
{
"mcpServers": {
"mailsignals": {
"command": "node",
"args": ["/path/to/mailsignals/mcp/dist/index.js"],
"env": {
"MAILSIGNALS_API": "https://app.yourdomain.com/api",
"MAILSIGNALS_AGENT_KEY": "msk_…"
}
}
}
}
An agent that does not speak MCP can call the REST routes directly with the key in an Authorization header; the MCP server is an adapter over them, not the product.
The three things that go wrong
| Symptom | Cause | Fix |
|---|---|---|
| A message arrives with two pixels | The agent prepared a draft that you then sent from Gmail, where the extension tracked it again. Gmail strips the marker attributes when it loads a draft, so the extension cannot detect the first pixel | Prepare only mail the agent sends itself. The tool description says so; nothing enforces it |
| The first open on an agent-sent message is judged "delivery-time fetch" | The send time defaults to the moment of preparation, and the too-fast rule measures from there. An agent that prepares long before sending weakens that rule for the message | Prepare immediately before sending |
| The message is in the dashboard but the Gmail row has no checkmark | The agent never confirmed the ids, or the ids the Gmail API returned differ from the ones Gmail's page reports for that message | Always confirm. If the ids genuinely differ, tracking still works; only the row decoration is missing |
What this changes about privacy
For mail you send from Gmail in a browser, MailSignals never sees the body. For mail an agent sends, it has to: to put a pixel in a message it never sees, it must be given the message. The agent posts the HTML to your own deployment, which rewrites it in memory and hands it straight back. The body is not stored; what is kept is the same as for any other message, the subject, recipients and the URLs of tracked links, and it stays inside your Firebase project throughout. If you would rather that never happen, do not create an agent key.
Related: the agent API feature page · agent keys in the security model · the privacy page.