Distributing Cost Reports via Email and Slack
This page walks a food-tech developer through the two concrete channel adapters most operations need: an email delivery that attaches the rendered report, and a Slack post that summarizes the headline numbers with a link. It is the channel-specific implementation of scheduled report distribution; read that first for the routing, idempotency, and retry contract, then wire the adapters here into it.
The task is deceptively fiddly: email needs a correct MIME structure or clients mangle it, Slack needs Block Kit and rate-limit handling, and both must classify their failures into transient (retry) versus permanent (dead-letter) so the parent distributor behaves.
Prerequisites and Data Contract
- Python 3.11+, standard-library
emailandsmtplib, andrequests(orslack_sdk) for Slack. - SMTP credentials and a Slack incoming-webhook or bot token, read from the environment — never hard-coded.
- A rendered artifact on disk (HTML + optional PDF) plus its metadata, produced by generating weekly margin reports with pandas.
Each adapter exposes one method — send(recipient, artifact_path, redaction) — and raises TransientError or ChannelError so the distributor’s retry logic can act.
Step-by-Step Implementation
Step 1 — Build the MIME email
Use EmailMessage; it produces a correct multipart structure without manual boundary handling. Attach the PDF and set the HTML body.
from __future__ import annotations
from email.message import EmailMessage
from pathlib import Path
def build_email(sender: str, recipient: str, subject: str,
html_body: str, pdf_path: Path | None) -> EmailMessage:
msg = EmailMessage()
msg["From"] = sender
msg["To"] = recipient
msg["Subject"] = subject
msg.set_content("Your margin report is attached. View in an HTML-capable client.")
msg.add_alternative(html_body, subtype="html")
if pdf_path is not None:
msg.add_attachment(
pdf_path.read_bytes(), maintype="application", subtype="pdf",
filename=pdf_path.name,
)
return msg
Step 2 — Dispatch over SMTP with error classification
import smtplib
from costing.channels import ChannelError, TransientError
def send_email(msg, host: str, port: int, user: str, password: str) -> None:
try:
with smtplib.SMTP(host, port, timeout=20) as smtp:
smtp.starttls()
smtp.login(user, password)
smtp.send_message(msg)
except (smtplib.SMTPServerDisconnected, smtplib.SMTPConnectError, TimeoutError) as exc:
raise TransientError(str(exc)) from exc # worth retrying
except smtplib.SMTPRecipientsRefused as exc:
raise ChannelError(f"bad recipient: {exc}") from exc # never retry
Step 3 — Post a Slack summary with Block Kit
Do not dump the whole report into chat — post the headline numbers and a link to the full artifact.
import requests
from costing.channels import ChannelError, TransientError
def post_slack(webhook: str, period: str, food_cost_pct: str,
wow: str, report_url: str) -> None:
blocks = [
{"type": "header", "text": {"type": "plain_text",
"text": f"Margin report — {period}"}},
{"type": "section", "fields": [
{"type": "mrkdwn", "text": f"*Food cost:*\n{food_cost_pct}"},
{"type": "mrkdwn", "text": f"*WoW change:*\n{wow}"},
]},
{"type": "actions", "elements": [
{"type": "button", "text": {"type": "plain_text", "text": "Open full report"},
"url": report_url}]},
]
resp = requests.post(webhook, json={"blocks": blocks}, timeout=15)
if resp.status_code == 429:
raise TransientError("slack rate limited") # retry with backoff
if resp.status_code >= 400:
raise ChannelError(f"slack {resp.status_code}: {resp.text}")
Step 4 — Wrap each as an adapter the distributor calls
from pathlib import Path
class EmailAdapter:
def __init__(self, cfg: dict) -> None:
self.cfg = cfg
def send(self, recipient: str, artifact_path: str, redaction: str) -> None:
html = Path(artifact_path).with_suffix(".html").read_text()
pdf = Path(artifact_path).with_suffix(".pdf")
msg = build_email(self.cfg["sender"], recipient, self.cfg["subject"], html,
pdf if pdf.exists() else None)
send_email(msg, **self.cfg["smtp"])
Verification and Validation
- Email renders in a real client. Send to a test inbox and confirm the HTML body displays and the PDF opens; a broken MIME structure shows as raw source or a missing attachment.
- Slack post is a summary, not a wall. Confirm the channel shows headline numbers and a working link, not the full table — the full report lives in the artifact.
- Error classification. Point SMTP at a dead host and confirm a
TransientError(retried); send to a syntactically invalid address and confirm aChannelError(dead-lettered). - Idempotency end to end. Run the distributor twice; the sent-ledger must record one delivery per recipient, and the second run must skip.
Gotchas and Edge Cases
- Secrets in code or logs. SMTP passwords and Slack tokens come from the environment or a secret store, and the send log records the recipient and status — never the credential or the full artifact body.
- Slack’s 3-second and rate limits. Incoming webhooks rate-limit; treat HTTP 429 as transient and back off. Posting the entire report also risks truncation — link out instead.
- Attachment size. A large PDF can exceed a mail server’s limit and bounce as a hard failure. Cap attachment size, and for big reports send a link to the stored artifact rather than the bytes.
- Redaction before send. Apply the role redaction profile to the artifact copy this adapter sends, so a channel-level summary never leaks a field the recipient’s role excludes.
Related
- Scheduled Report Distribution — the routing, idempotency, and retry contract these adapters plug into.
- Generating Weekly Margin Reports with pandas — the artifact these adapters deliver.
- Batch Cost Reporting Automation — where the report is rendered.
- Reporting, CI/CD & Delivery Pipelines — the wider delivery domain.
For library specifics, see the official Python email documentation and Slack Block Kit documentation.