SMTP checks can tell you whether a server appears healthy. They do not always tell you whether a real message made it through the path your users depend on.

This recipe uses MailWebhook as the arrival detector and Zabbix as the monitoring system. The goal is simple: send a tagged canary email, report success only when that email arrives, and let Zabbix alert when the success heartbeat goes missing.

Pattern

flowchart LR
  Cron["Cron canary sender"] --> Sender["External sender"]
  Sender --> Mailbox["Mailbox under test"]
  Mailbox --> MailWebhook["MailWebhook watches mailbox"]
  MailWebhook --> Zabbix["Zabbix history.push() OK heartbeat"]
  Zabbix --> Trigger{"OK heartbeat missing?"}
  Trigger -- "No" --> Healthy["No alert"]
  Trigger -- "Yes, threshold exceeded" --> Alert["Zabbix alert"]

When the canary arrives, MailWebhook calls Zabbix history.push(). Zabbix alerts only if no OK heartbeat arrives within the expected window.

The key idea is to alert on missing success, not on every possible delivery failure.

Canary tagging

Do not send a generic test email. Send a tagged canary:

Subject: MW-CANARY v=1 env=prod mailbox=mx1-prod sender=postmark id=<uuid> ts=<utc>

Each tag has a job:

MW-CANARY      route this email as a canary
env            avoid mixing prod/staging
mailbox        map the OK to the right Zabbix item
sender         identify the external sender path
id             correlate sent vs received canary
ts             keep sent time for troubleshooting or future latency checks

For the first version, Zabbix only needs the OK heartbeat. This canary:

MW-CANARY v=1 env=prod mailbox=mx1-prod sender=postmark id=7f3a9c ts=2026-05-18T12:00:00Z

maps to this Zabbix item key:

mail.canary.ok[prod,mx1-prod]

Zabbix setup

Create a host:

Host: mail-deliverability

Create one Zabbix trapper item per mailbox path:

Name: Canary OK: prod mx1-prod
Key: mail.canary.ok[prod,mx1-prod]
Type: Zabbix trapper
Type of information: Numeric unsigned

Then add a trigger expression using nodata():

nodata(/mail-deliverability/mail.canary.ok[prod,mx1-prod],10m)=1

Example tuning:

Send interval: 1 minute
Warning: nodata(...,5m)=1
Critical: nodata(...,10m)=1

For noisier paths:

Send interval: 5 minutes
Warning: nodata(...,15m)=1
Critical: nodata(...,30m)=1

This avoids flapping because one delayed email does not immediately create an incident. Zabbix only alerts when the OK heartbeat has been missing for longer than the threshold.

MailWebhook endpoint

Create a MailWebhook endpoint pointing to the Zabbix JSON-RPC API:

https://zabbix.example.com/zabbix/api_jsonrpc.php

Add a custom header:

Authorization: Bearer <ZABBIX_API_TOKEN>

MailWebhook route

Use MailWebhook route rules to match only canary emails:

{
  "to_emails": ["deliverability-check@example.com"],
  "from_domains": ["your-canary-sender.example"],
  "subject_regex": [
    "^.*\\bMW-CANARY\\b.*\\benv=[A-Za-z0-9_-]+\\b.*\\bmailbox=[A-Za-z0-9_-]+\\b.*$"
  ]
}

MailWebhook custom JSON payload

Use map.custom_json to emit the Zabbix history.push() request. The expressions below use the MailWebhook JsonLogic-style DSL for regex.replace and cat.

{
  "pipeline": {
    "steps": [
      {
        "name": "map.custom_json",
        "args": {
          "version": "v1",
          "vars": [
            {
              "name": "env",
              "expr": {
                "regex.replace": {
                  "value": { "var": "message.subject" },
                  "pattern": "^.*\\benv=([A-Za-z0-9_-]+)\\b.*$",
                  "with": "\\1"
                }
              }
            },
            {
              "name": "mailbox_id",
              "expr": {
                "regex.replace": {
                  "value": { "var": "message.subject" },
                  "pattern": "^.*\\bmailbox=([A-Za-z0-9_-]+)\\b.*$",
                  "with": "\\1"
                }
              }
            },
            {
              "name": "zabbix_key",
              "expr": {
                "cat": [
                  "mail.canary.ok[",
                  { "var": "vars.env" },
                  ",",
                  { "var": "vars.mailbox_id" },
                  "]"
                ]
              }
            }
          ],
          "output": {
            "jsonrpc": "2.0",
            "method": "history.push",
            "params": [
              {
                "host": "mail-deliverability",
                "key": { "var": "vars.zabbix_key" },
                "value": 1
              }
            ],
            "id": 1
          }
        }
      }
    ]
  }
}

For the example subject above, MailWebhook sends this to Zabbix:

{
  "jsonrpc": "2.0",
  "method": "history.push",
  "params": [
    {
      "host": "mail-deliverability",
      "key": "mail.canary.ok[prod,mx1-prod]",
      "value": 1
    }
  ],
  "id": 1
}

Canary sender

Here is a minimal cron sender:

#!/usr/bin/env bash
set -euo pipefail

ENV="prod"
MAILBOX_ID="mx1-prod"
SENDER_ID="postmark"
TO="deliverability-check@example.com"
FROM="canary@your-canary-sender.example"

CANARY_ID="$(uuidgen)"
TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"

SUBJECT="MW-CANARY v=1 env=${ENV} mailbox=${MAILBOX_ID} sender=${SENDER_ID} id=${CANARY_ID} ts=${TS}"

sleep "$(( RANDOM % 20 ))"

printf 'deliverability canary\n' | mail \
  -s "$SUBJECT" \
  -r "$FROM" \
  "$TO"

Cron:

* * * * * /opt/mail-canary/send-canary.sh

The jitter prevents all checks from landing at the exact same second.

Operational notes

Start simple:

flowchart TD
  Base["mail.canary.ok[prod,mx1-prod]"] --> Item["One Zabbix trapper item"]
  Item --> Trigger["One nodata trigger"]
  Base -. "add sender dimension only when needed" .-> Postmark["mail.canary.ok[prod,mx1-prod,postmark]"]
  Base -. "add sender dimension only when needed" .-> Ses["mail.canary.ok[prod,mx1-prod,ses]"]

Multi-sender checks are useful when you need to distinguish mailbox-path failures from sender-path failures.

One caveat: direct delivery to Zabbix is the simplest implementation, but Zabbix will not validate MailWebhook webhook signatures or a canary HMAC token by itself. For stricter security, put a tiny gateway in front: verify the MailWebhook signature, optionally validate a canary token, then forward history.push() to Zabbix.