Guide
Send Grafana Alert Emails without SMTP
Route Grafana alert emails to recipients by pointing a webhook contact point at a handler that runs nylas email send, instead of configuring Grafana's built-in SMTP server.
Written by Caleb Geene Director, Site Reliability Engineering
Reviewed by Qasim Muhammad
Why skip Grafana's built-in SMTP server?
Grafana's email contact point needs a working SMTP relay configured in the [smtp] section of grafana.ini: a host, port 587, a user, and a password that lives in the config or an environment variable. Google disabled less-secure-app SMTP access in May 2022, and Microsoft 365 turned off basic SMTP auth, so that password is now an app password or a relay you maintain yourself. The Nylas CLI moves email delivery out of Grafana.
With a webhook contact point, Grafana only speaks HTTP to a local handler, and the handler owns delivery. According to the Grafana webhook notifier docs, the integration takes a single URL plus optional auth headers, then POSTs a JSON body on every alert. That is the whole contract. You stop maintaining the [smtp] block, a TLS setting, and a password 2 systems both depend on. The CLI authenticates with one API key and refreshes OAuth tokens on its own.
How do I configure the Grafana webhook contact point?
A Grafana webhook contact point is a notification target that POSTs the alert group as JSON to a URL you control. You create it under Alerting then Contact points, choose the Webhook integration, and set the URL to your handler. Grafana groups and batches alerts through its notification policy, so the handler receives grouped events rather than one request per evaluation.
You can define the contact point in the UI or as file provisioning. The provisioning file below registers a contact point named nylas-email pointing at a handler on localhost:9095. Grafana applies provisioned contact points on startup and on a config reload, and the default notification policy waits a 30-second group wait before the first POST, so a burst of 40 related alerts arrives as 1 batched payload.
apiVersion: 1
contactPoints:
- orgId: 1
name: nylas-email
receivers:
- uid: nylas_email_webhook
type: webhook
settings:
url: http://localhost:9095/alert
httpMethod: POSTSave the file under provisioning/alerting/ and restart Grafana, or use the Alerting API to apply it without downtime. Send a test notification from the contact point page to confirm the handler receives the POST before you wire it into a live notification policy.
What does the webhook handler look like?
The handler is a short HTTP server that decodes the Grafana payload and shells out to the CLI once per batch. Grafana's webhook body is documented JSON: a top-level status field plus an alerts array, where each alert carries labels and annotations maps and a generatorURL. The example below uses about 30 lines of Python and the standard library only.
The nylas email send command sends a message across providers with no SMTP relay. It reads NYLAS_API_KEY from the environment, takes the grant ID as its first argument, and the --yes flag skips the interactive confirmation so it runs unattended. The handler builds the subject from the alert name and status, then passes the rendered summary as the body.
import json, os, subprocess
from http.server import BaseHTTPRequestHandler, HTTPServer
GRANT = os.environ["NYLAS_GRANT_ID"]
TO = os.environ["ALERT_EMAIL_TO"]
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers["Content-Length"]))
data = json.loads(body)
status = data.get("status", "firing")
names = sorted({a["labels"].get("alertname", "alert") for a in data["alerts"]})
subject = f"[{status.upper()}] {', '.join(names)}"
lines = []
for a in data["alerts"]:
summary = a["annotations"].get("summary", a["labels"].get("alertname", ""))
lines.append(f"- {a['labels'].get('severity', 'none')}: {summary} ({a.get('generatorURL', '')})")
subprocess.run([
"nylas", "email", "send", GRANT,
"--to", TO,
"--subject", subject,
"--body", "\n".join(lines),
"--yes", "--json",
], check=True)
self.send_response(200)
self.end_headers()
HTTPServer(("127.0.0.1", 9095), Handler).serve_forever()Run the handler with NYLAS_API_KEY, NYLAS_GRANT_ID, and ALERT_EMAIL_TO exported in its environment. Install the CLI first with brew install nylas/nylas-cli/nylas (see getting started for other install methods).
How do I route Grafana alerts by severity?
Severity routing sends critical pages to one address and warnings to another, using Grafana's notification policy tree rather than handler logic. You add a nested policy that matches on the severity label and assigns a different contact point per branch. Each contact point points at the same handler on a distinct path, so the handler reads which recipient list to use from the URL it was hit on.
The provisioning below splits traffic: alerts labeled severity = critical route to the nylas-critical contact point at /alert/critical, while everything else falls through to the default. This keeps the 2 audiences separate without duplicating alert rules. A critical database-down alert reaches the on-call rotation in under 60 seconds, while a disk-at-80-percent warning goes to a lower-priority inbox the team reviews daily.
apiVersion: 1
policies:
- orgId: 1
receiver: nylas-email
routes:
- receiver: nylas-critical
object_matchers:
- ['severity', '=', 'critical']
contactPoints:
- orgId: 1
name: nylas-critical
receivers:
- uid: nylas_critical_webhook
type: webhook
settings:
url: http://localhost:9095/alert/critical
httpMethod: POSTWhy does the handler need to track send failures?
A failed email send must not look like a healthy Grafana alert pipeline. If the handler returns HTTP 200 after nylas email send exits non-zero, Grafana records the notification as delivered and moves on. Returning a 5xx on send failure makes Grafana retry the webhook, and the contact point's notification log dedupes firing-and-resolved pairs so a brief outage on your side does not produce duplicate emails once delivery recovers.
The CLI prints a JSON object with the sent message ID when --json is passed, which gives you an audit record per alert email. Log that ID, then have the handler raise a 502 when the subprocess exits non-zero so the failure surfaces in Grafana's state history. Pair email with a second channel for the highest-severity alerts so one delivery problem never hides a production incident. Rotating the sender means updating exactly 1 secret, the NYLAS_API_KEY the handler reads, not an SMTP password in grafana.ini.
result = subprocess.run([
"nylas", "email", "send", GRANT,
"--to", TO, "--subject", subject, "--body", "\n".join(lines),
"--yes", "--json",
], capture_output=True, text=True)
if result.returncode != 0:
self.send_response(502) # tell Grafana to retry the webhook
self.end_headers()
return
msg_id = json.loads(result.stdout).get("id", "unknown")
print(f"sent alert email: {msg_id}")
self.send_response(200)
self.end_headers()Next steps
- Send Sentry Issue Alerts by Email (CLI) — Turn a Sentry issue alert webhook into an enriched email with…
- Email Prometheus alerts without SMTP – the same webhook pattern for Alertmanager receivers
- Terraform email alerts – provision the alert sender and recipients as code
- Debug email delivery from the CLI – trace why an alert email never arrived
- Email to GitHub issues – turn high-severity alert mail into tracked issues
- Email to Snowflake – archive alert emails into a data warehouse
- Command reference –
nylas email sendflags and JSON output - Grafana contact points – integrations, provisioning, and test notifications
- Grafana Alerting overview – alert rules, notification policies, and grouping
- RFC 5322 – the internet message format the CLI emits