The model is clean, the measures are right — and three people opened the dashboard last month. That's not a dashboard problem, it's a format problem: an executive wants five numbers on a Friday morning, on a phone, without logging into anything. So stop asking them to come to the data. Send it.
What you get
One email. No attachment, no login, nothing to install:
Subject — Group KPI, daily
Northwind Logistics — executive overview
Generated 2026-08-02 · figures in the unit shown per row
| YoY % | YTD | YTD PY | MTD | MTD PY | |
|---|---|---|---|---|---|
| Group indicatorsexcl. intercompany and contract logistics | |||||
| Revenue$m | +18.4% | 512.3 | 432.7 | 84.5 | 71.4 |
| GP$m | +11.1% | 58.9 | 53.0 | 9.7 | 8.8 |
| GP % | — | 11.5% | 12.2% | 11.5% | 12.3% |
| TEUsk | +6.8% | 96.4 | 90.3 | 12.1 | 11.4 |
| Ch. weightkt | +14.1% | 22.6 | 19.8 | 4.1 | 3.6 |
| Contract logistics | |||||
| Revenue$m | +8.8% | 7.4 | 6.8 | 1.1 | 0.9 |
| GP$m | -37.9% | 1.8 | 2.9 | 0.2 | 0.4 |
| GP by country$m, excl. contract logistics | |||||
| Brazil | +21.0% | 9.8 | 8.1 | 1.6 | 1.3 |
| Canada | -6.7% | 4.2 | 4.5 | 0.7 | 0.8 |
| Denmark | +10.5% | 2.1 | 1.9 | 0.4 | 0.3 |
| France | +2.7% | 7.6 | 7.4 | 1.2 | 1.3 |
| Japan | +32.7% | 6.9 | 5.2 | 1.1 | 0.8 |
| Mexico | -10.0% | 5.4 | 6.0 | 0.9 | 1.1 |
| Norway | +18.2% | 1.3 | 1.1 | 0.2 | 0.2 |
| Poland | +26.1% | 8.7 | 6.9 | 1.5 | 1.1 |
| Portugal | -8.8% | 3.1 | 3.4 | 0.5 | 0.6 |
| Sweden | +15.3% | 9.8 | 8.5 | 1.6 | 1.3 |
Drill by country, month, trade lane — opens the Power BI report this email was built from.
- It arrives before anyone asks. Friday, 6 am, every week — whether or not someone happened to be curious.
- It's the official number. Computed by the same DAX that backs the report, so there's nothing to reconcile when it's quoted in a meeting.
- It reads in seconds. A mail preview on a phone, not a workspace load.
- It has one button. Open the full report — deep-linked and pre-filtered, for the one reader in ten who wants the why.
- It costs about 120 lines of Python. No new platform, no licence, no AI in the loop.
The building blocks
Everything runs inside Microsoft Fabric. Six steps, one code block each.
1. Pick a source — anything Fabric can reach
The semantic model is the obvious one: it's already behind the report, and Semantic Link runs DAX against it and hands back a DataFrame. But the notebook doesn't care where the number comes from:
from sempy.fabric import evaluate_dax
import com.microsoft.spark.fabric # Warehouse connector, preinstalled
# Semantic model — DAX in, DataFrame out
kpi = evaluate_dax("<semantic-model>", "EVALUATE { [Revenue] }")
# Warehouse, or a lakehouse's SQL analytics endpoint
wh = spark.read.synapsesql("<warehouse>.dbo.fact_sales")
# Lakehouse table
lh = spark.sql("select sum(amount) _revenue from fact_sales")
# OneLake files
files = spark.read.parquet("Files/exports/sales_2026.parquet")Pick one. Everything below stays the same: a number in, a display string out.
2. Query it
A two-method wrapper. The 1×1 → scalar unwrap is what keeps the rest
readable — most of these queries return exactly one number.
from sempy.fabric import evaluate_dax
class Engine:
def __init__(self, model):
self.model = model
def dax(self, query):
df = evaluate_dax(self.model, query)
# A 1×1 result is a single KPI — unwrap it instead of returning a frame.
return df.iat[0, 0] if df.shape == (1, 1) else df
query = Engine(model="<your-semantic-model-id>")3. Collect the metrics
Ordinary DAX, one scalar per call, current period and prior year. Compute the period boundaries once, at the top of the notebook, from a single reference date — then "what does MTD mean on the 3rd" is answered in one place, not in fourteen filters.
REVENUE = """
EVALUATE
{ CALCULATE(
SUM('Fact'[Amount]),
'Fact'[Measure] = "Revenue",
ALL('Fact'[Date]),
'Fact'[Date] >= {start},
'Fact'[Date] <= {end}
) }
"""
rev_ytd = query.dax(REVENUE.format(start="DATE(2026,1,1)", end="DATE(2026,7,31)"))
rev_ytd_py = query.dax(REVENUE.format(start="DATE(2025,1,1)", end="DATE(2025,7,31)"))
metrics = [{
"name": "Revenue",
"change": f"{(rev_ytd - rev_ytd_py) / rev_ytd_py:.1%}",
"ytd": f"{rev_ytd:,.0f}",
"ytd_py": f"{rev_ytd_py:,.0f}",
}]Format here, not in the template. By the time a value reaches the HTML it's already a display string, so the template only ever does layout.
4. Format it — for a phone
Email HTML is 2006 forever: tables for layout, inline styles on every cell,
web-safe fonts, no <style> block. Colour the variance here — green and red are
a presentation decision, and the metrics list stays plain and testable.
def kpi_table(metrics):
rows = "".join(
f"""<tr>
<td style="padding:12px 10px;font-size:14px;color:#666;">{m['name']}</td>
<td style="padding:12px 10px;text-align:right;font-weight:600;
color:{'#2e7d32' if not m['change'].startswith('-') else '#c62828'};">
{m['change']}</td>
<td style="padding:12px 10px;text-align:right;">{m['ytd']}</td>
<td style="padding:12px 10px;text-align:right;color:#666;">{m['ytd_py']}</td>
</tr>"""
for m in metrics
)
return f"""
<body style="margin:0;padding:16px 12px;font-family:Arial,Helvetica,sans-serif;">
<h2 style="margin:0;font-size:20px;">Executive overview</h2>
<table cellspacing="0" width="100%" style="border-collapse:collapse;background:#fff;">
{rows}
</table>
</body>"""End with the button. The email answers what; the report answers why. Without a link, every "wait, which country?" becomes a message to you.
REPORT_URL = "https://app.powerbi.com/groups/<workspace>/reports/<report-id>"
cta = f"""
<tr><td style="padding:20px 10px 8px;">
<a href="{REPORT_URL}?ctid=<tenant-id>"
style="background:#4f46e5;color:#fff;text-decoration:none;
padding:10px 16px;border-radius:6px;font-weight:600;
font-family:Arial,Helvetica,sans-serif;font-size:14px;">
Open the full report →
</a>
</td></tr>"""- Link the page, not the workspace —
…/reports/<id>/<section-id>lands on the tab that matches the email. - Keep
?ctid=<tenant-id>— or a phone signed into a personal Microsoft account hits a permissions wall. - Carry the filter —
?filter=Country/Name eq 'Poland'opens the report already pointed at the thing that prompted the click.
5. Send it
A thin wrapper over Microsoft Graph: client-credentials token, then POST to
/sendMail. Register an app in Entra ID, grant it Mail.Send, and it sends as
the mailbox in sender_upn.
import json, urllib.request, urllib.parse
class Emailer:
def __init__(self, tenant, client_id, client_secret, sender_upn):
self.tenant, self.client_id = tenant, client_id
self.client_secret, self.sender_upn = client_secret, sender_upn
def _token(self):
req = urllib.request.Request(
f"https://login.microsoftonline.com/{self.tenant}/oauth2/v2.0/token",
data=urllib.parse.urlencode({
"client_id": self.client_id,
"client_secret": self.client_secret,
"scope": "https://graph.microsoft.com/.default",
"grant_type": "client_credentials",
}).encode(),
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
return json.loads(urllib.request.urlopen(req).read())["access_token"]
def send_html(self, to, html, subject="Report"):
body = json.dumps({
"message": {
"subject": subject,
"body": {"contentType": "HTML", "content": html},
"toRecipients": [{"emailAddress": {"address": a}} for a in to],
},
"saveToSentItems": True,
}).encode()
req = urllib.request.Request(
f"https://graph.microsoft.com/v1.0/users/{self.sender_upn}/sendMail",
data=body,
headers={"Authorization": f"Bearer {self._token()}",
"Content-Type": "application/json"},
)
with urllib.request.urlopen(req) as r:
return r.getcode() in (200, 202, 204)
mailer = Emailer(tenant="…", client_id="…", client_secret="…", sender_upn="…")
mailer.send_html(["ceo@company.com"], kpi_table(metrics), subject="Group KPI, daily")Keep the secret in a Key Vault and read it through notebookutils.credentials.
Never in the cell.
6. Trigger it — from Fabric, or from anywhere
Mark one notebook cell as parameters — recipients, reference date, model id — and every trigger below can override them without touching the code.
- On a schedule. A Fabric Pipeline: one Notebook activity, one schedule trigger. Friday, 6 am. No Functions app, no cron box.
- On demand. Run in the notebook or the pipeline. Same code, same email.
- From outside Fabric. One
POSTto the Job Scheduler API — from a ticket, a chat bot, a month-end script, anything holding a token:
FABRIC = "https://api.fabric.microsoft.com/v1"
NOTEBOOK = f"{FABRIC}/workspaces/<workspace-id>/items/<notebook-id>"
# fabric_token: the Emailer's client-credentials flow again,
# with scope "https://api.fabric.microsoft.com/.default"
req = urllib.request.Request(
f"{NOTEBOOK}/jobs/RunNotebook/instances",
data=json.dumps({"executionData": {"parameters": {
"recipients": {"value": "cfo@company.com", "type": "string"},
}}}).encode(),
headers={"Authorization": f"Bearer {fabric_token}",
"Content-Type": "application/json"},
)
urllib.request.urlopen(req) # 202 Accepted — poll the Location headerAnd one guard rail inside the notebook, so a debugging run never reaches the CEO:
from datetime import datetime
now = datetime.now()
if now.weekday() == 4 and now.day > 2: # Fridays, skipping the 1st/2nd
mailer.send_html(recipients, html, subject="Group KPI, daily")Log every send — timestamp plus the metrics as JSON. It answers "what did the email say on the 14th" without anyone forwarding a screenshot.
Why it works
Instant access without a dashboard in the way: the numbers are in the inbox, readable in the seconds it takes to unlock a phone, in a format that needs no legend. And it's a funnel, not a wall — the button under the table takes anyone who wants to dig into the real report, already filtered to the number they just read.