Automate Shopify Email Reports: HTML Templates, Scheduling, and Delivery
Build automated Shopify email reports using Python — HTML templates, daily sales summaries, scheduled delivery, and conditional alerts that land in your team inbox every morning.
Your morning routine should not involve logging into Shopify and copy-pasting numbers into a spreadsheet. Yet that is what most store owners and ecommerce managers do: open the admin panel, check yesterday’s revenue, check the orders page, maybe export a CSV.
Every piece of data you look at is available through the Shopify Admin API. The missing piece is a formatted report that runs itself and lands in your inbox on a schedule. This guide builds exactly that: a Python pipeline that fetches your store data, renders it into a polished HTML email, and delivers it to your team every morning before anyone has to ask for numbers.
Who This Is For
- Store owners who check Shopify admin every morning and wish the numbers just appeared
- Ecommerce managers who build weekly reports by exporting CSVs and formatting them manually
- Operations teams who need consistent daily metrics delivered to stakeholders without reminders
- Vibe coders who want a reporting pipeline that runs itself with zero manual steps
Python helps but the concepts work in any language. If you have sent an email from code before, you are ready for this.
The Email Report Pipeline
Unlike Excel-based reporting, email reports land where your team already works: the inbox. No one has to open a shared drive, find the latest file, or check a dashboard.
What You Will Need
pip install requests pandas jinja2
- Shopify Admin API token (
read_orders,read_products,read_inventoryscopes) - SMTP credentials — Gmail app passwords work for small teams; use Amazon SES or SendGrid for production
Step 1: Build the Shopify Data Fetcher
All email reports start at the same place: the API call that pulls yesterday’s data.
import os
import requests
import pandas as pd
from datetime import datetime, timedelta
from jinja2 import Template
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
class ShopifyData:
"""Fetch and aggregate Shopify data for email reports."""
def __init__(self, shop_url: str, api_token: str):
self.api_url = f"https://{shop_url}/admin/api/2026-01"
self.session = requests.Session()
self.session.headers["X-Shopify-Access-Token"] = api_token
self.session.headers["Content-Type"] = "application/json"
def get_all(self, endpoint: str, params: dict = None) -> list:
"""Paginated fetch from any Shopify endpoint."""
url = f"{self.api_url}/{endpoint}"
results = []
while url:
resp = self.session.get(url, params=params)
resp.raise_for_status()
data = resp.json()
key = [k for k in data.keys() if k != "errors"][0]
results.extend(data[key])
link = resp.headers.get("Link", "")
url = None
params = None
if 'rel="next"' in link:
parts = link.split(",")
for p in parts:
if 'rel="next"' in p:
url = p.split("<")[1].split(">")[0]
return results
Fetch and Aggregate Orders
def daily_sales(self, days: int = 1) -> dict:
"""Fetch recent orders and return daily summary metrics."""
since = (datetime.now() - timedelta(days=days)).isoformat()
raw = self.get_all("orders.json", {
"created_at_min": since,
"status": "any",
"limit": 250,
})
df = pd.DataFrame([{
"created_at": pd.to_datetime(o["created_at"]),
"total": float(o["total_price"]),
"items": len(o.get("line_items", [])),
"email": o.get("email", ""),
"status": o.get("financial_status", ""),
} for o in raw])
if df.empty:
return {"orders": 0, "revenue": 0, "aov": 0, "items": 0, "customers": 0}
df["date"] = df["created_at"].dt.date
today = df[df["date"] == df["date"].max()]
return {
"orders": len(today),
"revenue": round(today["total"].sum(), 2),
"aov": round(today["total"].mean(), 2),
"items": int(today["items"].sum()),
"customers": today["email"].nunique(),
}
def top_products(self, days: int = 7) -> list[dict]:
"""Get best-selling products for the report body."""
since = (datetime.now() - timedelta(days=days)).isoformat()
raw = self.get_all("orders.json", {
"created_at_min": since,
"status": "any",
"limit": 250,
})
items = []
for order in raw:
for li in order.get("line_items", []):
items.append({
"title": li["title"],
"qty": li["quantity"],
"revenue": float(li["price"]) * li["quantity"],
})
df = pd.DataFrame(items)
if df.empty:
return []
top = df.groupby("title").agg(
units=("qty", "sum"), revenue=("revenue", "sum")
).sort_values("revenue", ascending=False).head(10).reset_index()
return top.to_dict("records")
Step 2: Render HTML Email Templates
A table of numbers is data. A styled email with colour-coded trends is a report your team actually reads.
DAILY_TEMPLATE = """
<html>
<body style="font-family: -apple-system, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="border-bottom: 2px solid #2563eb; padding-bottom: 12px; margin-bottom: 24px;">
<h2 style="margin: 0; color: #1e293b;">{{ date }}</h2>
<p style="margin: 4px 0 0; color: #64748b;">Daily Sales Summary</p>
</div>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 24px;">
<tr>
<td style="padding: 12px; background: #f8fafc; border-radius: 8px 0 0 8px;">
<div style="font-size: 12px; color: #64748b;">Revenue</div>
<div style="font-size: 24px; font-weight: 700; color: #16a34a;">{{ "£%.2f"|format(s.revenue) }}</div>
</td>
<td style="padding: 12px; background: #f8fafc;">
<div style="font-size: 12px; color: #64748b;">Orders</div>
<div style="font-size: 24px; font-weight: 700;">{{ s.orders }}</div>
</td>
<td style="padding: 12px; background: #f8fafc;">
<div style="font-size: 12px; color: #64748b;">AOV</div>
<div style="font-size: 24px; font-weight: 700;">{{ "£%.2f"|format(s.aov) }}</div>
</td>
<td style="padding: 12px; background: #f8fafc; border-radius: 0 8px 8px 0;">
<div style="font-size: 12px; color: #64748b;">Customers</div>
<div style="font-size: 24px; font-weight: 700;">{{ s.customers }}</div>
</td>
</tr>
</table>
{% if products %}
<h3 style="color: #1e293b; font-size: 14px; margin-bottom: 8px;">Top Products (7 Days)</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr style="background: #f1f5f9; font-size: 12px; color: #64748b;">
<th style="padding: 8px; text-align: left;">Product</th>
<th style="padding: 8px; text-align: right;">Units</th>
<th style="padding: 8px; text-align: right;">Revenue</th>
</tr>
{% for p in products %}
<tr style="border-bottom: 1px solid #e2e8f0; font-size: 13px;">
<td style="padding: 8px;">{{ p.title }}</td>
<td style="padding: 8px; text-align: right;">{{ p.units }}</td>
<td style="padding: 8px; text-align: right;">{{ "£%.2f"|format(p.revenue) }}</td>
</tr>
{% endfor %}
</table>
{% endif %}
<p style="margin-top: 24px; font-size: 11px; color: #94a3b8;">Generated automatically at {{ time }}.</p>
</body>
</html>
"""
Step 3: Add Conditional Alerts
The most valuable email reports are the ones you get only when something needs attention.
class AlertChecker:
"""Evaluate metrics and return alert messages if thresholds are breached."""
def __init__(self, data: ShopifyData):
self.data = data
def check_revenue_drop(self, threshold_pct: float = 20.0) -> str | None:
"""Alert if today's revenue dropped more than threshold vs yesterday."""
today = self.data.daily_sales(days=1)
yesterday = self._get_yesterday()
if yesterday["revenue"] == 0:
return None
drop = (yesterday["revenue"] - today["revenue"]) / yesterday["revenue"] * 100
if drop > threshold_pct:
return f"Revenue dropped {drop:.0f}% compared to yesterday"
return None
def _get_yesterday(self) -> dict:
"""Fetch yesterday's summary for comparison."""
since = (datetime.now() - timedelta(days=2)).isoformat()
until = (datetime.now() - timedelta(days=1)).isoformat()
raw = self.data.get_all("orders.json", {
"created_at_min": since,
"created_at_max": until,
"limit": 250,
})
df = pd.DataFrame([{
"created_at": pd.to_datetime(o["created_at"]),
"total": float(o["total_price"]),
} for o in raw])
if df.empty:
return {"revenue": 0}
yesterday_df = df[df["created_at"].dt.date == (datetime.now() - timedelta(days=1)).date()]
return {"revenue": yesterday_df["total"].sum()}
def check_low_stock(self, threshold: int = 5) -> list[dict]:
"""Alert when products drop below stock threshold."""
inventory = self.data.get_all("inventory_items.json", {"limit": 250})
low = [i for i in inventory if i.get("inventory_level", 0) <= threshold]
return low[:5] # Return top 5 low-stock items
Step 4: Send the Report
SMTP is the simplest delivery method. Wrap it in a function that takes HTML content and a recipient list.
def send_email_report(
html: str,
recipients: list[str],
subject: str | None = None,
):
"""Deliver an HTML email report via SMTP."""
host = os.environ["SMTP_HOST"]
port = int(os.environ.get("SMTP_PORT", 587))
user = os.environ["SMTP_USER"]
password = os.environ["SMTP_PASS"]
msg = MIMEMultipart("alternative")
msg["Subject"] = subject or f"Shopify Report — {datetime.now().strftime('%d %B %Y')}"
msg["From"] = user
msg["To"] = ", ".join(recipients)
msg.attach(MIMEText(html, "html"))
with smtplib.SMTP(host, port) as server:
server.starttls()
server.login(user, password)
server.send_message(msg)
print(f"Report sent to {len(recipients)} recipient(s)")
Step 5: Wire the Pipeline and Schedule It
def run_morning_report():
"""Fetch data, render template, conditionally alert, and email the team."""
shop_url = os.environ["SHOPIFY_SHOP_URL"]
api_token = os.environ["SHOPIFY_API_TOKEN"]
data = ShopifyData(shop_url, api_token)
summary = data.daily_sales(days=1)
products = data.top_products(days=7)
# Render HTML
template = Template(DAILY_TEMPLATE)
html = template.render(
date=datetime.now().strftime("%A, %d %B %Y"),
time=datetime.now().strftime("%H:%M"),
s=summary,
products=products,
)
# Check for alerts
alerts = AlertChecker(data)
revenue_alert = alerts.check_revenue_drop()
low_stock = alerts.check_low_stock()
if revenue_alert or low_stock:
alert_html = '<div style="background: #fef2f2; padding: 12px; border-radius: 8px; margin-bottom: 16px;">'
if revenue_alert:
alert_html += f'<p style="color: #dc2626; margin: 0;">⚠️ {revenue_alert}</p>'
if low_stock:
for item in low_stock[:3]:
name = item.get("title", "Unknown product")
alert_html += f'<p style="color: #dc2626; margin: 4px 0 0;">⚠️ Low stock: {name}</p>'
alert_html += "</div>"
html = html.replace("<body", f"<body>{alert_html}")
# Deliver
recipients = os.environ.get("REPORT_RECIPIENTS", "").split(",")
send_email_report(html, recipients)
if __name__ == "__main__":
run_morning_report()
Schedule with Cron
# Every morning at 7:30, before the team starts
30 7 * * * cd /app && python morning_report.py
For production scheduling with retries, error alerts, and a web UI, see Schedule and Orchestrate Workflows with Prefect.
Email Report vs Dashboard vs Excel
| Criteria | Email Report | Dashboard | Excel Export |
|---|---|---|---|
| How team consumes | Inbox (passive) | Browser (active) | File manager |
| Best for | Daily snapshot, alerts | Real-time exploration | Deep analysis |
| Setup effort | Medium | High | Low |
| Requires login? | No | Yes | No |
| Historical trends | Week-over-week | Full history | Manual |
| Alerting | Built-in | Separate setup | None |
Email reports are the lowest-friction way to keep a team informed. They do not replace dashboards for deep analysis — they handle the daily check-in that 80% of your team needs.
What This Replaces
| Manual Process | Automated Equivalent |
|---|---|
| Log into Shopify admin every morning | API fetches data before anyone arrives |
| Export CSV from Orders page | Pandas aggregates in seconds |
| Calculate daily totals in spreadsheet | Single function call |
| Format numbers and highlights | Jinja2 template with colour coding |
| Draft and send an email update | SMTP delivers automatically |
| Remember to check for problems | Conditional alerts trigger on thresholds |
Common Pitfalls in Email Report Pipelines
-
HTML that breaks in email clients — Use inline styles, not classes. Gmail strips
<style>blocks. Test with Litmus or Email on Acid before rolling out. -
SMTP rate limits — Gmail allows ~500 emails/day. For teams larger than 10, switch to SendGrid or Amazon SES.
-
Missing API scopes —
read_ordersandread_productsare standard. Addread_inventoryfor stock alerts. -
Time zone confusion — Shopify returns UTC. Convert timestamps to your store’s time zone before aggregating daily totals.
-
Failing silently — Wrap the pipeline in a try/except and send a failure alert to yourself. An email that does not arrive is worse than no email at all.
Next Steps
For the complete picture — how email reports fit into a broader Shopify reporting architecture — see The Complete Guide to Shopify Reporting and Analytics. That guide covers when to use email, dashboards, Excel exports, and how all three integrate.
For building a self-updating dashboard that replaces the need for some of these email reports, see Shopify Automated Reporting: Build a Self-Updating Sales Dashboard. To track store speed alongside sales metrics, set up Shopify performance monitoring with automated alerts.
For automating reports across Shopify, WooCommerce, and BigCommerce together, see Ecommerce Reporting API: Automate Store Data Collection. To secure the API tokens powering this pipeline, see Secure Python Automation: Managing Secrets and Keys.
Ecommerce optimisation services include setting up automated email reporting as part of a full performance improvement process.
Get in touch to set up automated email reports for your store.
Enjoyed this article?
Get notified when I publish new articles on automation, ecommerce, and data engineering.
Get in touch