Consolidating Daily Content Emails: From Platform‑Specific Sends to a Unified Apple‑Style Summary
TL;DR: We replaced multiple platform‑specific email jobs with a single, metadata‑driven consolidator that builds one Apple‑style summary. The change cut duplication, eliminated missing‑field errors, and made the daily workflow easier to maintain.
The Problem
In our previous setup, each platform (Bluesky, Dev.to, Substack, Medium, etc.) had its own GitHub Action that triggered src/notifier.py to send a daily email. The email body was built directly from the platform’s content files. This architecture caused two major issues:
- Duplication of effort – Every platform required its own email template, leading to 6 separate code paths that almost always stayed in sync.
-
Fragile metadata – The email generation relied on the presence of specific keys in
metadata.json. When a post missed thebluesky_urisfield, the notifier raised aKeyError, aborting the entire daily send:
Traceback (most recent call last):
File "src/notifier.py", line 112, in send_summary
bluesky_cell = f'<a href="{metadata["bluesky_uris"]["bluesky"]}">…'
KeyError: 'bluesky_uris'
The result was a flaky daily workflow that required manual fixes and manual updates to the email templates whenever the content structure changed.
What I Tried First
Initially I attempted a refactor of each individual workflow:
- Updated each GitHub Action to use a shared
notifierlibrary. - Centralized the email template but kept separate
send_*functions for each platform.
This approach still required maintaining six distinct functions and duplicated logic for reading metadata, constructing links, and handling missing fields. The code still broke when a single post was missing a field for one platform.
The Implementation
1. Centralized Metadata Reader
I created src/daily_consolidator.py to read all content files for a given day and aggregate the necessary data into a single dictionary:
# src/daily_consolidator.py
import json
import os
from pathlib import Path
def load_metadata(date: str) -> dict:
"""Load all metadata for a given date and merge them."""
base = Path(f"content/{date}")
meta_files = list(base.glob("*/metadata.json"))
merged = {}
for f in meta_files:
with f.open() as fp:
data = json.load(fp)
merged.update(data)
return merged
This file now becomes the single source of truth for what content exists each day.
2. Unified Email Builder
src/notifier.py was rewritten to accept the consolidated metadata and build a single Apple‑style email:
# src/notifier.py
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
def build_email_body(metadata: dict) -> str:
"""Construct a single Apple‑style email body from metadata."""
body = "<html><body>"
body += f"<h1>Daily Content Summary – {metadata['date']}</h1>"
body += "<ul>"
for platform, data in metadata.get("platforms", {}).items():
if data.get("published"):
link = data.get("uri", "#")
body += f"<li><a href='{link}'>{platform.title()}</a></li>"
body += "</ul>"
body += "</body></html>"
return body
def send_summary(to: str, metadata: dict) -> None:
msg = MIMEMultipart("alternative")
msg["Subject"] = f"📬 Daily Summary – {metadata['date']}"
msg["From"] = "noreply@vibecoding.com"
msg["To"] = to
html = build_email_body(metadata)
part = MIMEText(html, "html")
msg.attach(part)
# SMTP sending logic omitted for brevity
Key changes:
-
Single function (
send_summary) instead of six platform‑specific ones. - Apple‑style UI: minimal CSS, clean links, and a consistent layout.
- Graceful handling: If a platform’s data is missing, it simply skips that entry instead of throwing an error.
3. Daily Consolidator Script
src/main.py now orchestrates the whole process:
# src/main.py
import os
from datetime import date
from daily_consolidator import load_metadata
from notifier import send_summary
if __name__ == "__main__":
today = date.today().strftime("%Y-%m-%d")
metadata = load_metadata(today)
metadata["date"] = today
send_summary("team@vibecoding.com", metadata)
The script is lightweight, runs in the CI environment, and is the single entry point for the daily email.
4. GitHub Actions Refactor
The old workflow files:
.github/workflows/bluesky-daily.yml.github/workflows/devto-daily.yml
were removed. A new consolidated workflow was added:
yaml
# .github/workflows/daily-email.yml
name: Daily Content Email
on:
schedule:
- cron: '0 6 * * *' # 6 AM UTC
jobs:
send-email:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run consolidator
run: python src/main.py
env:
EMAIL_HOST
---
*Part of my [Build in Public](https://dev.to/zaerohell) series — sharing the real process of building SaaS projects from Playa del Carmen, México.*
*Repo: `zaerohell/content-automation` · 2026-08-03*
\#playadev #buildinpublic
Top comments (0)