Track email opens
Munin can record when a recipient opens an email it sent. Use this when an operator asks "did they read it?" about one message, or wants open rates across a channel.
How it works
Outbound email carries a 1×1 transparent GIF whose URL encodes a signed token for
that delivery. When the recipient's client loads the image, Munin stamps the
delivery row: first open, last open, and a running count. The first open also emits
the conversation.message.opened webhook.
A pixel is only embedded when all of these hold:
- the channel has
outbound.trackOpens: true, - the message has an HTML part (text-only mail carries no image), and
- the server has
MUNIN_KEY_PEPPERset (it signs the token).
Requests that look like bots are ignored, so link-scanners and security appliances mostly don't register as opens.
Turn it on
trackOpens lives on the outbound half of an email channel's config, for both the
smtp and mailer providers:
{
"name": "conv_configure_email_channel",
"arguments": {
"channelId": "chn_…",
"outbound": { "provider": "mailer", "trackOpens": true }
}
}
It is off unless someone turned it on. A channel that has been sending for months with tracking off has no history to backfill — numbers start at the moment it is enabled.
Read the numbers
One conversation. conv_get_conversation returns firstOpenedAt,
lastOpenedAt and openCount on each message.
The three fields distinguish two different "no data" cases, and the distinction matters when reporting:
| Fields | Meaning |
|---|---|
openCount: null | No delivery row — the message never went out over email (an inbound message, an internal note, or a widget/SMS message). Opens don't apply. |
openCount: 0, firstOpenedAt: null | Sent over email, no open recorded. Either genuinely unopened, or opened in a client that blocks images. |
openCount: 3 | Recorded three image loads, the first at firstOpenedAt. |
Note that seenAt on the same message is a different signal: it is the chat
widget's read receipt, set when the recipient scrolls the message into view in the
widget panel, and reported separately as conversation.message.read. Email opens and
widget reads never both apply to one message.
Across a channel. conv_get_email_open_stats aggregates deliveries in a window:
{ "name": "conv_get_email_open_stats", "arguments": { "sinceDays": 30 } }
{
"since": "2026-07-16T09:00:00.000Z",
"sinceDays": 30,
"channels": [
{
"channelId": "chn_…",
"channelName": "Support",
"trackOpens": true,
"sent": 412,
"opened": 233,
"totalOpens": 501,
"openRate": 0.566
}
],
"totals": { "sent": 412, "opened": 233, "totalOpens": 501, "openRate": 0.566 }
}
sentcounts deliveries that actually left the building in the window (statussent), not queued or failed ones.openedcounts deliveries opened at least once;totalOpenscounts every load, so it runs higher when people reopen a thread.openRateisopened / sent, ornullwhen nothing was sent.- Pass
channelIdto scope to one channel. A non-email channel id is rejected.
Report it honestly
Open tracking is best-effort, and the failure modes push in both directions. Say so when you present a number rather than quoting it as fact:
- Under-counts. Gmail, Outlook and most corporate clients block remote images by default. A blocked image is indistinguishable from an unread message.
- Over-counts. Apple Mail Privacy Protection pre-fetches every image the moment mail arrives, so those recipients register an open whether or not a human looked. Some corporate gateways do the same.
trackOpens: falsereads as a 0% rate, because no pixel was ever embedded. Always check the flag in the response before calling a channel's rate low — the stats tool returns it for exactly this reason.
Treat the number as a floor with noise on top: useful for comparing two sends on the same channel, not for telling an operator that a specific person did or didn't read a specific message.
React to opens
Subscribe to conversation.message.opened (see skill://webhooks/subscribe-to-events)
to act on the first open of a message. Payload:
{
"type": "conversation.message.opened",
"payload": {
"deliveryId": "cmd_…",
"messageId": "msg_…",
"firstOpenedAt": "2026-08-15T09:14:02.000Z"
}
}
It fires once per delivery, on the first open only — repeat opens bump
openCount silently. Don't build a "still unread after N days" alert on the absence
of this event alone; blocked images make that alert fire on people who read the mail.