Reporting Cicd Delivery Pipelines

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 email and smtplib, and requests (or slack_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.

Email and Slack channel adapters A send job routes by channel to one of two adapters. The email adapter builds a multipart MIME message with an HTML body and a PDF attachment and dispatches it over SMTP. The Slack adapter builds a Block Kit summary and posts to a webhook. Both classify any failure as a transient error for retry or a permanent channel error for dead-lettering. Send job by channel Email adapter MIME + SMTP Slack adapter Block Kit + webhook Operator inbox Team channel

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 a ChannelError (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.

For library specifics, see the official Python email documentation and Slack Block Kit documentation.