From 7099518e0f86656120afe3c282c03ddbef70a35d Mon Sep 17 00:00:00 2001 From: Jan Rummel Date: Sun, 8 Mar 2026 22:08:26 +0100 Subject: [PATCH 01/10] Add Email Automation example design doc Co-Authored-By: Claude Opus 4.6 --- .../2026-03-08-email-automation-design.md | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 docs/plans/2026-03-08-email-automation-design.md diff --git a/docs/plans/2026-03-08-email-automation-design.md b/docs/plans/2026-03-08-email-automation-design.md new file mode 100644 index 0000000..1b65ea3 --- /dev/null +++ b/docs/plans/2026-03-08-email-automation-design.md @@ -0,0 +1,155 @@ +# Email Automation Example — Design + +**Date:** 2026-03-08 +**Pattern:** `trigger → transform → deliver` +**Status:** Approved + +## Goal + +Third runnable example for workflow-patterns. A template-based email automation system that renders personalized emails from presets and delivers them as HTML files (default) or via SMTP (optional). Demonstrates the simplest workflow pattern without AI dependency. + +## Architecture + +``` +select_template() → render_email() → save_or_send() +trigger transform deliver +``` + +## Module Structure + +``` +examples/email-automation/ +├── run.py # CLI entry point +├── .env.example # SMTP config template (optional) +├── .gitignore # .env, __pycache__, output/ +├── pyproject.toml # no runtime dependencies +├── src/email_workflow/ +│ ├── __init__.py +│ ├── models.py # Dataclasses: Template, Recipient, Email +│ ├── templates.py # 5 email template presets +│ ├── renderer.py # Template rendering (variable substitution, HTML) +│ ├── sender.py # File output (default) + SMTP delivery (--send) +│ └── display.py # Terminal formatting +├── tests/ +│ ├── test_models.py +│ ├── test_templates.py +│ ├── test_renderer.py +│ ├── test_sender.py +│ └── test_display.py +└── output/ # Rendered emails (gitignored) +``` + +## Modules + +### models.py +```python +@dataclass +class Template: + name: str + subject: str + body: str # with {variable} placeholders + variables: list[str] # required variable names + +@dataclass +class Recipient: + name: str + email: str + +@dataclass +class Email: + recipient: Recipient + subject: str + body_html: str + body_text: str +``` + +### templates.py +5 curated email templates: + +| Template | Variables | Use Case | +|----------|-----------|----------| +| Order Confirmation | name, order_id, items, total | E-Commerce | +| Welcome Email | name, product | Onboarding | +| Invoice Reminder | name, amount, due_date | Billing | +| Event Invitation | name, event, date, location | Events | +| Password Reset | name, reset_link | Security | + +Interactive selection menu (same UX as chatbot personas). Also available via `--template` flag. + +### renderer.py +- `render_email(template, recipient, variables)` → `Email` +- Substitutes `{variable}` placeholders with provided values +- Generates both HTML (with basic styling) and plain text versions +- Validates all required variables are provided + +### sender.py +- `save_email(email, directory)` → saves as `.html` file (default mode) +- `send_email(email, smtp_config)` → sends via SMTP (with `--send` flag) +- SMTP config from `.env`: host, port, username, password + +### display.py +- `format_header(title)` — boxed header (same style as chatbot) +- `format_template_menu(templates)` — template selection list +- `format_preview(email)` — show rendered email before saving/sending + +## UX Flow + +``` +$ uv run python run.py + +╔══════════════════════════════════════════╗ +║ Email Automation — Setup ║ +╚══════════════════════════════════════════╝ + +Choose a template: + + 1. Order Confirmation E-Commerce order receipt + 2. Welcome Email New user onboarding + 3. Invoice Reminder Payment due notification + 4. Event Invitation Event RSVP + 5. Password Reset Security reset link + +Template (1-5): 2 + +── Welcome Email ── + +Recipient name: Jan +Recipient email: jan@example.com +product: Workflow Patterns Pro + +Preview: +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Subject: Welcome to Workflow Patterns Pro! +To: Jan +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Hi Jan, + +Welcome to Workflow Patterns Pro! We're excited ... +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Email saved to output/2026-03-08_welcome-email_jan.html +``` + +## Key Differences from Other Examples + +| Aspect | Content Creation | Chatbot | Email Automation | +|--------|-----------------|---------|-----------------| +| API style | `messages.create()` | `messages.stream()` | No AI — pure templates | +| Interaction | One-shot | Interactive loop | One-shot with preview | +| Pattern | `api → ai → transform → deliver` | `trigger → ai → data → deliver` | `trigger → transform → deliver` | +| Dependencies | anthropic | anthropic | None (stdlib only) | +| Output | Markdown file | Terminal + JSON | HTML file or SMTP | + +## Dependencies + +- No runtime dependencies (stdlib only: `smtplib`, `email`, `string`) +- `pytest` (dev) + +## Testing Strategy + +- Dataclass tests for models +- Template validation tests (all variables present, unique names) +- Renderer tests (variable substitution, HTML generation, missing variable handling) +- Sender tests (file output with tmp_path, SMTP mocking) +- Display formatting tests +- Target: ~20-25 tests From 3c87fac9c21cc78640148b262f85ab1a04950de8 Mon Sep 17 00:00:00 2001 From: Jan Rummel Date: Sun, 8 Mar 2026 22:10:19 +0100 Subject: [PATCH 02/10] Add Email Automation implementation plan Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-03-08-email-automation-plan.md | 974 ++++++++++++++++++ 1 file changed, 974 insertions(+) create mode 100644 docs/plans/2026-03-08-email-automation-plan.md diff --git a/docs/plans/2026-03-08-email-automation-plan.md b/docs/plans/2026-03-08-email-automation-plan.md new file mode 100644 index 0000000..46da6a6 --- /dev/null +++ b/docs/plans/2026-03-08-email-automation-plan.md @@ -0,0 +1,974 @@ +# Email Automation Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a template-based email automation system with 5 presets, HTML rendering, and optional SMTP delivery as the third runnable example. + +**Architecture:** CLI selects a template, collects variables interactively, renders HTML + plain text, and saves to file (default) or sends via SMTP (`--send`). Pattern: `trigger → transform → deliver`. No AI dependency — pure stdlib. + +**Tech Stack:** Python 3.12+, stdlib only (`string.Template`, `smtplib`, `email`), `pytest`, `uv` + +--- + +### Task 1: Project scaffolding + +**Files:** +- Create: `examples/email-automation/pyproject.toml` +- Create: `examples/email-automation/.env.example` +- Create: `examples/email-automation/.gitignore` +- Create: `examples/email-automation/src/email_workflow/__init__.py` + +**Step 1: Create pyproject.toml** + +```toml +[project] +name = "email-automation" +version = "0.1.0" +description = "Email Automation workflow: trigger -> transform -> deliver" +requires-python = ">=3.12" +dependencies = [] + +[dependency-groups] +dev = ["pytest"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/email_workflow"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +``` + +**Step 2: Create .env.example** + +``` +# Optional: only needed if you use --send to deliver via SMTP +# cp .env.example .env +# +# .env is in .gitignore — it will never be committed. +SMTP_HOST=smtp.gmail.com +SMTP_PORT=465 +SMTP_USER=your-email@gmail.com +SMTP_PASSWORD=your-app-password +SENDER_EMAIL=your-email@gmail.com +``` + +**Step 3: Create .gitignore** + +``` +.venv/ +output/ +__pycache__/ +*.egg-info/ +.env +``` + +**Step 4: Create empty `src/email_workflow/__init__.py`** + +**Step 5: Run `uv sync` to verify project setup** + +Run: `cd examples/email-automation && uv sync` +Expected: Resolves and installs dev dependencies + +**Step 6: Commit** + +```bash +git add examples/email-automation/ +git commit -m "Scaffold Email Automation example project" +``` + +--- + +### Task 2: Data models + +**Files:** +- Create: `examples/email-automation/tests/test_models.py` +- Create: `examples/email-automation/src/email_workflow/models.py` + +**Step 1: Write failing tests** + +```python +"""Tests for email workflow data models.""" + +from email_workflow.models import Email, Recipient, Template + + +def test_template_has_required_fields(): + t = Template( + name="Test", + subject="Hello {name}", + body="Hi {name}, welcome to {product}.", + variables=["name", "product"], + ) + assert t.name == "Test" + assert t.variables == ["name", "product"] + + +def test_template_description(): + t = Template( + name="Test", + subject="S", + body="B", + variables=[], + description="A test template", + ) + assert t.description == "A test template" + + +def test_recipient_has_name_and_email(): + r = Recipient(name="Jan", email="jan@example.com") + assert r.name == "Jan" + assert r.email == "jan@example.com" + + +def test_email_has_all_fields(): + r = Recipient(name="Jan", email="jan@example.com") + e = Email( + recipient=r, + subject="Welcome", + body_html="

Hi Jan

", + body_text="Hi Jan", + template_name="Welcome Email", + ) + assert e.recipient.email == "jan@example.com" + assert e.subject == "Welcome" + assert "

" in e.body_html + assert e.template_name == "Welcome Email" +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd examples/email-automation && uv run pytest tests/test_models.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'email_workflow.models'` + +**Step 3: Write implementation** + +```python +"""Data models for the email workflow.""" + +from dataclasses import dataclass, field + + +@dataclass +class Template: + """An email template with variable placeholders.""" + + name: str + subject: str + body: str + variables: list[str] = field(default_factory=list) + description: str = "" + + +@dataclass +class Recipient: + """An email recipient.""" + + name: str + email: str + + +@dataclass +class Email: + """A rendered email ready for delivery.""" + + recipient: Recipient + subject: str + body_html: str + body_text: str + template_name: str = "" +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd examples/email-automation && uv run pytest tests/test_models.py -v` +Expected: 4 passed + +**Step 5: Commit** + +```bash +git add examples/email-automation/src/email_workflow/models.py examples/email-automation/tests/test_models.py +git commit -m "Add email workflow data models with tests" +``` + +--- + +### Task 3: Email templates + +**Files:** +- Create: `examples/email-automation/tests/test_templates.py` +- Create: `examples/email-automation/src/email_workflow/templates.py` + +**Step 1: Write failing tests** + +```python +"""Tests for email template presets.""" + +from email_workflow.models import Template +from email_workflow.templates import TEMPLATES, get_template + + +def test_has_five_templates(): + assert len(TEMPLATES) == 5 + + +def test_all_templates_are_template_instances(): + for t in TEMPLATES: + assert isinstance(t, Template) + + +def test_all_templates_have_variables(): + for t in TEMPLATES: + assert len(t.variables) > 0, f"{t.name} has no variables" + + +def test_all_templates_have_unique_names(): + names = [t.name for t in TEMPLATES] + assert len(names) == len(set(names)) + + +def test_all_templates_have_placeholders_matching_variables(): + for t in TEMPLATES: + for var in t.variables: + assert f"{{{var}}}" in t.body or f"{{{var}}}" in t.subject, ( + f"{t.name}: variable '{var}' not found in body or subject" + ) + + +def test_get_template_by_index(): + t = get_template(0) + assert t == TEMPLATES[0] + + +def test_get_template_out_of_range_returns_first(): + t = get_template(99) + assert t == TEMPLATES[0] +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd examples/email-automation && uv run pytest tests/test_templates.py -v` +Expected: FAIL — `ModuleNotFoundError` + +**Step 3: Write implementation** + +```python +"""Email template presets.""" + +from email_workflow.models import Template + +TEMPLATES = [ + Template( + name="Order Confirmation", + description="E-Commerce order receipt", + subject="Order #{order_id} confirmed", + body=( + "Hi {name},\n\n" + "Thank you for your order #{order_id}!\n\n" + "Items: {items}\n" + "Total: {total}\n\n" + "We'll notify you when your order ships.\n\n" + "Best regards,\nThe Team" + ), + variables=["name", "order_id", "items", "total"], + ), + Template( + name="Welcome Email", + description="New user onboarding", + subject="Welcome to {product}!", + body=( + "Hi {name},\n\n" + "Welcome to {product}! We're excited to have you on board.\n\n" + "Here's what you can do next:\n" + "1. Complete your profile\n" + "2. Explore the dashboard\n" + "3. Check out our getting started guide\n\n" + "If you have any questions, just reply to this email.\n\n" + "Cheers,\nThe {product} Team" + ), + variables=["name", "product"], + ), + Template( + name="Invoice Reminder", + description="Payment due notification", + subject="Reminder: Invoice due {due_date}", + body=( + "Hi {name},\n\n" + "This is a friendly reminder that your invoice for {amount} " + "is due on {due_date}.\n\n" + "If you've already paid, please disregard this message.\n\n" + "Best regards,\nAccounting Team" + ), + variables=["name", "amount", "due_date"], + ), + Template( + name="Event Invitation", + description="Event RSVP", + subject="You're invited: {event}", + body=( + "Hi {name},\n\n" + "You're invited to {event}!\n\n" + "Date: {date}\n" + "Location: {location}\n\n" + "We'd love to see you there. Please RSVP by replying to this email.\n\n" + "Looking forward to it,\nThe Events Team" + ), + variables=["name", "event", "date", "location"], + ), + Template( + name="Password Reset", + description="Security reset link", + subject="Reset your password", + body=( + "Hi {name},\n\n" + "We received a request to reset your password.\n\n" + "Click here to reset: {reset_link}\n\n" + "If you didn't request this, you can safely ignore this email. " + "The link expires in 24 hours.\n\n" + "Security Team" + ), + variables=["name", "reset_link"], + ), +] + + +def get_template(index: int) -> Template: + """Get a template by index, defaulting to first if out of range.""" + if 0 <= index < len(TEMPLATES): + return TEMPLATES[index] + return TEMPLATES[0] +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd examples/email-automation && uv run pytest tests/test_templates.py -v` +Expected: 7 passed + +**Step 5: Commit** + +```bash +git add examples/email-automation/src/email_workflow/templates.py examples/email-automation/tests/test_templates.py +git commit -m "Add 5 email template presets with tests" +``` + +--- + +### Task 4: Template renderer + +**Files:** +- Create: `examples/email-automation/tests/test_renderer.py` +- Create: `examples/email-automation/src/email_workflow/renderer.py` + +**Step 1: Write failing tests** + +```python +"""Tests for email template renderer.""" + +import pytest + +from email_workflow.models import Recipient, Template +from email_workflow.renderer import render_email + + +def _make_template() -> Template: + return Template( + name="Test", + subject="Hello {name}", + body="Hi {name}, welcome to {product}.", + variables=["name", "product"], + ) + + +def test_render_substitutes_variables(): + t = _make_template() + r = Recipient(name="Jan", email="jan@example.com") + email = render_email(t, r, {"name": "Jan", "product": "Acme"}) + assert email.subject == "Hello Jan" + assert "welcome to Acme" in email.body_text + + +def test_render_produces_html(): + t = _make_template() + r = Recipient(name="Jan", email="jan@example.com") + email = render_email(t, r, {"name": "Jan", "product": "Acme"}) + assert "" in email.body_html.lower() + assert "welcome to Acme" in email.body_html + + +def test_render_sets_recipient(): + t = _make_template() + r = Recipient(name="Jan", email="jan@example.com") + email = render_email(t, r, {"name": "Jan", "product": "Acme"}) + assert email.recipient.email == "jan@example.com" + + +def test_render_sets_template_name(): + t = _make_template() + r = Recipient(name="Jan", email="jan@example.com") + email = render_email(t, r, {"name": "Jan", "product": "Acme"}) + assert email.template_name == "Test" + + +def test_render_missing_variable_raises(): + t = _make_template() + r = Recipient(name="Jan", email="jan@example.com") + with pytest.raises(ValueError, match="Missing variables"): + render_email(t, r, {"name": "Jan"}) # missing 'product' + + +def test_render_html_escapes_newlines(): + t = Template(name="T", subject="S", body="Line 1\nLine 2\n\nLine 3", variables=[]) + r = Recipient(name="Jan", email="jan@example.com") + email = render_email(t, r, {}) + assert "
" in email.body_html or "

" in email.body_html +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd examples/email-automation && uv run pytest tests/test_renderer.py -v` +Expected: FAIL — `ModuleNotFoundError` + +**Step 3: Write implementation** + +```python +"""Template renderer: substitutes variables and generates HTML.""" + +from email_workflow.models import Email, Recipient, Template + +HTML_TEMPLATE = """\ + + + + + + + +

{subject}
+
{body_html}
+ + +""" + + +def render_email( + template: Template, + recipient: Recipient, + variables: dict[str, str], +) -> Email: + """Render a template with variables into a ready-to-send Email. + + Raises ValueError if required variables are missing. + """ + missing = [v for v in template.variables if v not in variables] + if missing: + raise ValueError(f"Missing variables: {', '.join(missing)}") + + subject = template.subject.format(**variables) + body_text = template.body.format(**variables) + + body_paragraphs = body_text.split("\n\n") + body_html_content = "".join(f"

{p.replace(chr(10), '
')}

" for p in body_paragraphs) + + body_html = HTML_TEMPLATE.format(subject=subject, body_html=body_html_content) + + return Email( + recipient=recipient, + subject=subject, + body_html=body_html, + body_text=body_text, + template_name=template.name, + ) +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd examples/email-automation && uv run pytest tests/test_renderer.py -v` +Expected: 6 passed + +**Step 5: Commit** + +```bash +git add examples/email-automation/src/email_workflow/renderer.py examples/email-automation/tests/test_renderer.py +git commit -m "Add template renderer with HTML generation" +``` + +--- + +### Task 5: Email delivery (file + SMTP) + +**Files:** +- Create: `examples/email-automation/tests/test_sender.py` +- Create: `examples/email-automation/src/email_workflow/sender.py` + +**Step 1: Write failing tests** + +```python +"""Tests for email delivery (file output and SMTP).""" + +from email_workflow.models import Email, Recipient +from email_workflow.sender import save_email, build_mime_message + + +def _make_email() -> Email: + return Email( + recipient=Recipient(name="Jan", email="jan@example.com"), + subject="Test Subject", + body_html="

Hello

", + body_text="Hello", + template_name="Welcome Email", + ) + + +def test_save_creates_html_file(tmp_path): + email = _make_email() + path = save_email(email, tmp_path) + assert path.exists() + assert path.suffix == ".html" + + +def test_save_file_contains_html(tmp_path): + email = _make_email() + path = save_email(email, tmp_path) + content = path.read_text() + assert "" in content.lower() + assert "Hello" in content + + +def test_save_filename_contains_template_and_recipient(tmp_path): + email = _make_email() + path = save_email(email, tmp_path) + assert "welcome-email" in path.name + assert "jan" in path.name + + +def test_build_mime_message_has_correct_headers(): + email = _make_email() + msg = build_mime_message(email, "sender@example.com") + assert msg["To"] == "jan@example.com" + assert msg["Subject"] == "Test Subject" + assert msg["From"] == "sender@example.com" + + +def test_build_mime_message_has_both_parts(): + email = _make_email() + msg = build_mime_message(email, "sender@example.com") + parts = list(msg.walk()) + content_types = [p.get_content_type() for p in parts] + assert "text/plain" in content_types + assert "text/html" in content_types +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd examples/email-automation && uv run pytest tests/test_sender.py -v` +Expected: FAIL — `ModuleNotFoundError` + +**Step 3: Write implementation** + +```python +"""Email delivery: save to file or send via SMTP.""" + +from datetime import datetime, timezone +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from pathlib import Path +import smtplib +import ssl + + +from email_workflow.models import Email + + +def save_email(email: Email, directory: Path) -> Path: + """Save a rendered email as an HTML file. + + Returns the path to the saved file. + """ + directory.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H%M%S") + slug = email.template_name.lower().replace(" ", "-") + recipient_slug = email.recipient.name.lower().replace(" ", "-") + path = directory / f"{timestamp}_{slug}_{recipient_slug}.html" + path.write_text(email.body_html) + return path + + +def build_mime_message(email: Email, sender_email: str) -> MIMEMultipart: + """Build a MIME message with both plain text and HTML parts.""" + msg = MIMEMultipart("alternative") + msg["Subject"] = email.subject + msg["From"] = sender_email + msg["To"] = email.recipient.email + + msg.attach(MIMEText(email.body_text, "plain")) + msg.attach(MIMEText(email.body_html, "html")) + return msg + + +def send_email(email: Email, smtp_config: dict) -> None: + """Send an email via SMTP. + + smtp_config keys: host, port, user, password, sender_email + """ + msg = build_mime_message(email, smtp_config["sender_email"]) + context = ssl.create_default_context() + + with smtplib.SMTP_SSL( + smtp_config["host"], + int(smtp_config["port"]), + context=context, + ) as server: + server.login(smtp_config["user"], smtp_config["password"]) + server.send_message(msg) +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd examples/email-automation && uv run pytest tests/test_sender.py -v` +Expected: 5 passed + +**Step 5: Commit** + +```bash +git add examples/email-automation/src/email_workflow/sender.py examples/email-automation/tests/test_sender.py +git commit -m "Add email delivery: file output and SMTP" +``` + +--- + +### Task 6: Display formatting + +**Files:** +- Create: `examples/email-automation/tests/test_display.py` +- Create: `examples/email-automation/src/email_workflow/display.py` + +**Step 1: Write failing tests** + +```python +"""Tests for terminal display formatting.""" + +from email_workflow.display import format_header, format_template_menu, format_preview +from email_workflow.models import Email, Recipient, Template + + +def test_format_header(): + result = format_header("Email Automation") + assert "Email Automation" in result + assert "╔" in result + + +def test_format_template_menu(): + templates = [ + Template(name="Welcome", description="Onboarding", subject="S", body="B", variables=[]), + Template(name="Invoice", description="Billing", subject="S", body="B", variables=[]), + ] + result = format_template_menu(templates) + assert "1." in result + assert "2." in result + assert "Welcome" in result + assert "Billing" in result + + +def test_format_preview(): + email = Email( + recipient=Recipient(name="Jan", email="jan@example.com"), + subject="Welcome!", + body_html="

Hi

", + body_text="Hi Jan, welcome.", + template_name="Welcome Email", + ) + result = format_preview(email) + assert "jan@example.com" in result + assert "Welcome!" in result + assert "Hi Jan" in result +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd examples/email-automation && uv run pytest tests/test_display.py -v` +Expected: FAIL — `ModuleNotFoundError` + +**Step 3: Write implementation** + +```python +"""Terminal display formatting for the email workflow.""" + +from email_workflow.models import Email, Template + + +def format_header(title: str) -> str: + """Format a boxed header.""" + width = 42 + lines = [ + "", + "╔" + "═" * width + "╗", + "║" + title.center(width) + "║", + "╚" + "═" * width + "╝", + "", + ] + return "\n".join(lines) + + +def format_template_menu(templates: list[Template]) -> str: + """Format the template selection menu.""" + lines = ["Choose a template:", ""] + for i, t in enumerate(templates, 1): + lines.append(f" {i}. {t.name:<25} {t.description}") + lines.append("") + return "\n".join(lines) + + +def format_preview(email: Email) -> str: + """Format an email preview for the terminal.""" + sep = "━" * 40 + lines = [ + "", + "Preview:", + sep, + f"Subject: {email.subject}", + f"To: {email.recipient.name} <{email.recipient.email}>", + sep, + email.body_text, + sep, + "", + ] + return "\n".join(lines) +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd examples/email-automation && uv run pytest tests/test_display.py -v` +Expected: 3 passed + +**Step 5: Commit** + +```bash +git add examples/email-automation/src/email_workflow/display.py examples/email-automation/tests/test_display.py +git commit -m "Add terminal display formatting" +``` + +--- + +### Task 7: CLI runner (run.py) + +**Files:** +- Create: `examples/email-automation/run.py` + +**Step 1: Write run.py** + +```python +#!/usr/bin/env python3 +"""Email Automation workflow runner. + +Pattern: trigger -> transform -> deliver + +Template-based email automation with interactive variable input, +HTML rendering, and optional SMTP delivery. + +Usage: + uv run python run.py # interactive template selection + uv run python run.py --template 2 # select template by number + uv run python run.py --send # send via SMTP instead of saving +""" + +import argparse +import os +import sys +from pathlib import Path + +from email_workflow.display import format_header, format_preview, format_template_menu +from email_workflow.models import Recipient +from email_workflow.renderer import render_email +from email_workflow.sender import save_email, send_email +from email_workflow.templates import TEMPLATES, get_template + +OUTPUT_DIR = Path(__file__).parent / "output" + + +def _load_dotenv(): + """Load .env file if it exists.""" + env_path = Path(__file__).parent / ".env" + if not env_path.exists(): + return + for line in env_path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + key, _, value = line.partition("=") + if key and value: + os.environ.setdefault(key.strip(), value.strip()) + + +def _select_template() -> int: + """Interactive template selection. Returns 0-based index.""" + print(format_header("Email Automation — Setup")) + print(format_template_menu(TEMPLATES)) + choice = input(f"Template (1-{len(TEMPLATES)}): ").strip() + try: + return int(choice) - 1 + except ValueError: + return 0 + + +def _collect_variables(template) -> dict[str, str]: + """Interactively collect template variables.""" + print(f"\n── {template.name} ──\n") + variables = {} + for var in template.variables: + try: + value = input(f" {var}: ").strip() + except (EOFError, KeyboardInterrupt): + print("\nAborted.") + sys.exit(0) + variables[var] = value or f"[{var}]" + return variables + + +def _collect_recipient() -> Recipient: + """Interactively collect recipient info.""" + try: + name = input("Recipient name: ").strip() or "Recipient" + email = input("Recipient email: ").strip() or "recipient@example.com" + except (EOFError, KeyboardInterrupt): + print("\nAborted.") + sys.exit(0) + return Recipient(name=name, email=email) + + +def _get_smtp_config() -> dict: + """Get SMTP config from environment variables.""" + required = ["SMTP_HOST", "SMTP_PORT", "SMTP_USER", "SMTP_PASSWORD", "SENDER_EMAIL"] + config = {} + for key in required: + value = os.environ.get(key) + if not value: + print(f"Error: {key} not set. Check your .env file.") + print(" cp .env.example .env # then add your SMTP settings") + sys.exit(1) + config[key.lower().replace("smtp_", "")] = value + config["sender_email"] = os.environ["SENDER_EMAIL"] + return config + + +def main(): + _load_dotenv() + + parser = argparse.ArgumentParser(description="Email Automation: trigger -> transform -> deliver") + parser.add_argument("--template", type=int, default=None, help="Template number (1-5)") + parser.add_argument("--send", action="store_true", help="Send via SMTP instead of saving to file") + args = parser.parse_args() + + # Step 1: Trigger — select template + if args.template is not None: + template = get_template(args.template - 1) + else: + template = get_template(_select_template()) + + # Collect recipient and variables + recipient = _collect_recipient() + variables = _collect_variables(template) + + # Step 2: Transform — render email + email = render_email(template, recipient, variables) + print(format_preview(email)) + + # Step 3: Deliver — save or send + if args.send: + smtp_config = _get_smtp_config() + send_email(email, smtp_config) + print(f" Email sent to {email.recipient.email}") + else: + path = save_email(email, OUTPUT_DIR) + print(f" Email saved to {path}") + + +if __name__ == "__main__": + main() +``` + +**Step 2: Verify all tests pass** + +Run: `cd examples/email-automation && uv run pytest -v` +Expected: All tests pass (models: 4, templates: 7, renderer: 6, sender: 5, display: 3 = 25 total) + +**Step 3: Commit** + +```bash +git add examples/email-automation/run.py +git commit -m "Add CLI runner with interactive email workflow" +``` + +--- + +### Task 8: Integration — update README and website + +**Files:** +- Modify: `README.md` +- Modify: `scripts/generate_site.py` + +**Step 1: Update README.md** + +In the "Runnable Examples" table, add the email-automation row: + +```markdown +| [Email Automation](examples/email-automation/) | `trigger -> transform -> deliver` | Template-based emails with 5 presets, HTML rendering, and optional SMTP delivery | +``` + +Update "More examples coming" line to remove "Email Automation". + +Update test count to reflect new total. + +Update Project Structure to include `email-automation/`. + +**Step 2: Update generate_site.py** + +Add `example` field to the "Email Automation" entry in WIZARD_DATA: + +```python +"example": { + "path": "examples/email-automation", + "label": "Runnable Example", + "desc": "~25 tests, 5 templates, HTML rendering, optional SMTP — ready to run", +}, +``` + +**Step 3: Regenerate site** + +Run: `uv run python scripts/generate_site.py` + +**Step 4: Run all tests** + +Run: `uv run pytest -v` (from root) +Run: `cd examples/email-automation && uv run pytest -v` + +**Step 5: Commit** + +```bash +git add README.md scripts/generate_site.py docs/index.html +git commit -m "Link Email Automation example from README and website" +``` + +--- + +### Task 9: Live test and push + +**Step 1: Test with piped input** + +```bash +cd examples/email-automation && printf 'Jan\njan@example.com\nWorkflow Patterns Pro\n' | uv run python run.py --template 2 +``` + +Verify: Welcome Email renders, HTML saved to output/. + +**Step 2: Push** + +```bash +git push +``` From 19156311f04a0a517b7c0e8c49e5476027a8ae0f Mon Sep 17 00:00:00 2001 From: Jan Rummel Date: Sun, 8 Mar 2026 22:19:55 +0100 Subject: [PATCH 03/10] Scaffold Email Automation example project Co-Authored-By: Claude Opus 4.6 --- examples/email-automation/.env.example | 9 +++ examples/email-automation/.gitignore | 5 ++ examples/email-automation/pyproject.toml | 19 +++++ .../src/email_workflow/__init__.py | 0 examples/email-automation/uv.lock | 79 +++++++++++++++++++ 5 files changed, 112 insertions(+) create mode 100644 examples/email-automation/.env.example create mode 100644 examples/email-automation/.gitignore create mode 100644 examples/email-automation/pyproject.toml create mode 100644 examples/email-automation/src/email_workflow/__init__.py create mode 100644 examples/email-automation/uv.lock diff --git a/examples/email-automation/.env.example b/examples/email-automation/.env.example new file mode 100644 index 0000000..737c9c8 --- /dev/null +++ b/examples/email-automation/.env.example @@ -0,0 +1,9 @@ +# Optional: only needed if you use --send to deliver via SMTP +# cp .env.example .env +# +# .env is in .gitignore — it will never be committed. +SMTP_HOST=smtp.gmail.com +SMTP_PORT=465 +SMTP_USER=your-email@gmail.com +SMTP_PASSWORD=your-app-password +SENDER_EMAIL=your-email@gmail.com diff --git a/examples/email-automation/.gitignore b/examples/email-automation/.gitignore new file mode 100644 index 0000000..95d6148 --- /dev/null +++ b/examples/email-automation/.gitignore @@ -0,0 +1,5 @@ +.venv/ +output/ +__pycache__/ +*.egg-info/ +.env diff --git a/examples/email-automation/pyproject.toml b/examples/email-automation/pyproject.toml new file mode 100644 index 0000000..62d93a1 --- /dev/null +++ b/examples/email-automation/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "email-automation" +version = "0.1.0" +description = "Email Automation workflow: trigger -> transform -> deliver" +requires-python = ">=3.12" +dependencies = [] + +[dependency-groups] +dev = ["pytest"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/email_workflow"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/examples/email-automation/src/email_workflow/__init__.py b/examples/email-automation/src/email_workflow/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/email-automation/uv.lock b/examples/email-automation/uv.lock new file mode 100644 index 0000000..1435d55 --- /dev/null +++ b/examples/email-automation/uv.lock @@ -0,0 +1,79 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "email-automation" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "pytest" }] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] From 2a45bb94e1daf6ddc047610134745ad04bac2c97 Mon Sep 17 00:00:00 2001 From: Jan Rummel Date: Sun, 8 Mar 2026 22:26:13 +0100 Subject: [PATCH 04/10] Add email workflow data models with tests Co-Authored-By: Claude Opus 4.6 --- .../src/email_workflow/models.py | 33 +++++++++++++ .../email-automation/tests/test_models.py | 46 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 examples/email-automation/src/email_workflow/models.py create mode 100644 examples/email-automation/tests/test_models.py diff --git a/examples/email-automation/src/email_workflow/models.py b/examples/email-automation/src/email_workflow/models.py new file mode 100644 index 0000000..c54b3c2 --- /dev/null +++ b/examples/email-automation/src/email_workflow/models.py @@ -0,0 +1,33 @@ +"""Data models for the email workflow.""" + +from dataclasses import dataclass, field + + +@dataclass +class Template: + """An email template with variable placeholders.""" + + name: str + subject: str + body: str + variables: list[str] = field(default_factory=list) + description: str = "" + + +@dataclass +class Recipient: + """An email recipient.""" + + name: str + email: str + + +@dataclass +class Email: + """A rendered email ready for delivery.""" + + recipient: Recipient + subject: str + body_html: str + body_text: str + template_name: str = "" diff --git a/examples/email-automation/tests/test_models.py b/examples/email-automation/tests/test_models.py new file mode 100644 index 0000000..7693485 --- /dev/null +++ b/examples/email-automation/tests/test_models.py @@ -0,0 +1,46 @@ +"""Tests for email workflow data models.""" + +from email_workflow.models import Email, Recipient, Template + + +def test_template_has_required_fields(): + t = Template( + name="Test", + subject="Hello {name}", + body="Hi {name}, welcome to {product}.", + variables=["name", "product"], + ) + assert t.name == "Test" + assert t.variables == ["name", "product"] + + +def test_template_description(): + t = Template( + name="Test", + subject="S", + body="B", + variables=[], + description="A test template", + ) + assert t.description == "A test template" + + +def test_recipient_has_name_and_email(): + r = Recipient(name="Jan", email="jan@example.com") + assert r.name == "Jan" + assert r.email == "jan@example.com" + + +def test_email_has_all_fields(): + r = Recipient(name="Jan", email="jan@example.com") + e = Email( + recipient=r, + subject="Welcome", + body_html="

Hi Jan

", + body_text="Hi Jan", + template_name="Welcome Email", + ) + assert e.recipient.email == "jan@example.com" + assert e.subject == "Welcome" + assert "

" in e.body_html + assert e.template_name == "Welcome Email" From edb73d68c8952b271cb36678cc16659b6c573c7a Mon Sep 17 00:00:00 2001 From: Jan Rummel Date: Sun, 8 Mar 2026 22:28:11 +0100 Subject: [PATCH 05/10] Add 5 email template presets with tests Co-Authored-By: Claude Opus 4.6 --- .../src/email_workflow/templates.py | 84 +++++++++++++++++++ .../email-automation/tests/test_templates.py | 41 +++++++++ 2 files changed, 125 insertions(+) create mode 100644 examples/email-automation/src/email_workflow/templates.py create mode 100644 examples/email-automation/tests/test_templates.py diff --git a/examples/email-automation/src/email_workflow/templates.py b/examples/email-automation/src/email_workflow/templates.py new file mode 100644 index 0000000..7221f2d --- /dev/null +++ b/examples/email-automation/src/email_workflow/templates.py @@ -0,0 +1,84 @@ +"""Email template presets.""" + +from email_workflow.models import Template + +TEMPLATES = [ + Template( + name="Order Confirmation", + description="E-Commerce order receipt", + subject="Order #{order_id} confirmed", + body=( + "Hi {name},\n\n" + "Thank you for your order #{order_id}!\n\n" + "Items: {items}\n" + "Total: {total}\n\n" + "We'll notify you when your order ships.\n\n" + "Best regards,\nThe Team" + ), + variables=["name", "order_id", "items", "total"], + ), + Template( + name="Welcome Email", + description="New user onboarding", + subject="Welcome to {product}!", + body=( + "Hi {name},\n\n" + "Welcome to {product}! We're excited to have you on board.\n\n" + "Here's what you can do next:\n" + "1. Complete your profile\n" + "2. Explore the dashboard\n" + "3. Check out our getting started guide\n\n" + "If you have any questions, just reply to this email.\n\n" + "Cheers,\nThe {product} Team" + ), + variables=["name", "product"], + ), + Template( + name="Invoice Reminder", + description="Payment due notification", + subject="Reminder: Invoice due {due_date}", + body=( + "Hi {name},\n\n" + "This is a friendly reminder that your invoice for {amount} " + "is due on {due_date}.\n\n" + "If you've already paid, please disregard this message.\n\n" + "Best regards,\nAccounting Team" + ), + variables=["name", "amount", "due_date"], + ), + Template( + name="Event Invitation", + description="Event RSVP", + subject="You're invited: {event}", + body=( + "Hi {name},\n\n" + "You're invited to {event}!\n\n" + "Date: {date}\n" + "Location: {location}\n\n" + "We'd love to see you there. Please RSVP by replying to this email.\n\n" + "Looking forward to it,\nThe Events Team" + ), + variables=["name", "event", "date", "location"], + ), + Template( + name="Password Reset", + description="Security reset link", + subject="Reset your password", + body=( + "Hi {name},\n\n" + "We received a request to reset your password.\n\n" + "Click here to reset: {reset_link}\n\n" + "If you didn't request this, you can safely ignore this email. " + "The link expires in 24 hours.\n\n" + "Security Team" + ), + variables=["name", "reset_link"], + ), +] + + +def get_template(index: int) -> Template: + """Get a template by index, defaulting to first if out of range.""" + if 0 <= index < len(TEMPLATES): + return TEMPLATES[index] + return TEMPLATES[0] diff --git a/examples/email-automation/tests/test_templates.py b/examples/email-automation/tests/test_templates.py new file mode 100644 index 0000000..ba751e2 --- /dev/null +++ b/examples/email-automation/tests/test_templates.py @@ -0,0 +1,41 @@ +"""Tests for email template presets.""" + +from email_workflow.models import Template +from email_workflow.templates import TEMPLATES, get_template + + +def test_has_five_templates(): + assert len(TEMPLATES) == 5 + + +def test_all_templates_are_template_instances(): + for t in TEMPLATES: + assert isinstance(t, Template) + + +def test_all_templates_have_variables(): + for t in TEMPLATES: + assert len(t.variables) > 0, f"{t.name} has no variables" + + +def test_all_templates_have_unique_names(): + names = [t.name for t in TEMPLATES] + assert len(names) == len(set(names)) + + +def test_all_templates_have_placeholders_matching_variables(): + for t in TEMPLATES: + for var in t.variables: + assert f"{{{var}}}" in t.body or f"{{{var}}}" in t.subject, ( + f"{t.name}: variable '{var}' not found in body or subject" + ) + + +def test_get_template_by_index(): + t = get_template(0) + assert t == TEMPLATES[0] + + +def test_get_template_out_of_range_returns_first(): + t = get_template(99) + assert t == TEMPLATES[0] From 2b8f73c52436a7482c70e22bb3b5db87e95d5b1c Mon Sep 17 00:00:00 2001 From: Jan Rummel Date: Sun, 8 Mar 2026 22:49:30 +0100 Subject: [PATCH 06/10] Add template renderer with HTML generation Co-Authored-By: Claude Opus 4.6 --- .../src/email_workflow/renderer.py | 53 +++++++++++++++++ .../email-automation/tests/test_renderer.py | 59 +++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 examples/email-automation/src/email_workflow/renderer.py create mode 100644 examples/email-automation/tests/test_renderer.py diff --git a/examples/email-automation/src/email_workflow/renderer.py b/examples/email-automation/src/email_workflow/renderer.py new file mode 100644 index 0000000..1557e5d --- /dev/null +++ b/examples/email-automation/src/email_workflow/renderer.py @@ -0,0 +1,53 @@ +"""Template renderer: substitutes variables and generates HTML.""" + +from email_workflow.models import Email, Recipient, Template + +HTML_TEMPLATE = """\ + + + + + + + +

{subject}
+
{body_html}
+ + +""" + + +def render_email( + template: Template, + recipient: Recipient, + variables: dict[str, str], +) -> Email: + """Render a template with variables into a ready-to-send Email. + + Raises ValueError if required variables are missing. + """ + missing = [v for v in template.variables if v not in variables] + if missing: + raise ValueError(f"Missing variables: {', '.join(missing)}") + + subject = template.subject.format(**variables) + body_text = template.body.format(**variables) + + body_paragraphs = body_text.split("\n\n") + body_html_content = "".join( + f"

{p.replace(chr(10), '
')}

" for p in body_paragraphs + ) + + body_html = HTML_TEMPLATE.format(subject=subject, body_html=body_html_content) + + return Email( + recipient=recipient, + subject=subject, + body_html=body_html, + body_text=body_text, + template_name=template.name, + ) diff --git a/examples/email-automation/tests/test_renderer.py b/examples/email-automation/tests/test_renderer.py new file mode 100644 index 0000000..20960f6 --- /dev/null +++ b/examples/email-automation/tests/test_renderer.py @@ -0,0 +1,59 @@ +"""Tests for email template renderer.""" + +import pytest + +from email_workflow.models import Recipient, Template +from email_workflow.renderer import render_email + + +def _make_template() -> Template: + return Template( + name="Test", + subject="Hello {name}", + body="Hi {name}, welcome to {product}.", + variables=["name", "product"], + ) + + +def test_render_substitutes_variables(): + t = _make_template() + r = Recipient(name="Jan", email="jan@example.com") + email = render_email(t, r, {"name": "Jan", "product": "Acme"}) + assert email.subject == "Hello Jan" + assert "welcome to Acme" in email.body_text + + +def test_render_produces_html(): + t = _make_template() + r = Recipient(name="Jan", email="jan@example.com") + email = render_email(t, r, {"name": "Jan", "product": "Acme"}) + assert "" in email.body_html.lower() + assert "welcome to Acme" in email.body_html + + +def test_render_sets_recipient(): + t = _make_template() + r = Recipient(name="Jan", email="jan@example.com") + email = render_email(t, r, {"name": "Jan", "product": "Acme"}) + assert email.recipient.email == "jan@example.com" + + +def test_render_sets_template_name(): + t = _make_template() + r = Recipient(name="Jan", email="jan@example.com") + email = render_email(t, r, {"name": "Jan", "product": "Acme"}) + assert email.template_name == "Test" + + +def test_render_missing_variable_raises(): + t = _make_template() + r = Recipient(name="Jan", email="jan@example.com") + with pytest.raises(ValueError, match="Missing variables"): + render_email(t, r, {"name": "Jan"}) # missing 'product' + + +def test_render_html_converts_newlines(): + t = Template(name="T", subject="S", body="Line 1\nLine 2\n\nLine 3", variables=[]) + r = Recipient(name="Jan", email="jan@example.com") + email = render_email(t, r, {}) + assert "
" in email.body_html or "

" in email.body_html From ea59065ddda1bf9c969b82593b5fa55ac999e82a Mon Sep 17 00:00:00 2001 From: Jan Rummel Date: Sun, 8 Mar 2026 22:51:15 +0100 Subject: [PATCH 07/10] Add email delivery: file output and SMTP Co-Authored-By: Claude Opus 4.6 --- .../src/email_workflow/sender.py | 53 +++++++++++++++++++ .../email-automation/tests/test_sender.py | 53 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 examples/email-automation/src/email_workflow/sender.py create mode 100644 examples/email-automation/tests/test_sender.py diff --git a/examples/email-automation/src/email_workflow/sender.py b/examples/email-automation/src/email_workflow/sender.py new file mode 100644 index 0000000..9cd3a94 --- /dev/null +++ b/examples/email-automation/src/email_workflow/sender.py @@ -0,0 +1,53 @@ +"""Email delivery: save to file or send via SMTP.""" + +import smtplib +import ssl +from datetime import datetime, timezone +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from pathlib import Path + +from email_workflow.models import Email + + +def save_email(email: Email, directory: Path) -> Path: + """Save a rendered email as an HTML file. + + Returns the path to the saved file. + """ + directory.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H%M%S") + slug = email.template_name.lower().replace(" ", "-") + recipient_slug = email.recipient.name.lower().replace(" ", "-") + path = directory / f"{timestamp}_{slug}_{recipient_slug}.html" + path.write_text(email.body_html) + return path + + +def build_mime_message(email: Email, sender_email: str) -> MIMEMultipart: + """Build a MIME message with both plain text and HTML parts.""" + msg = MIMEMultipart("alternative") + msg["Subject"] = email.subject + msg["From"] = sender_email + msg["To"] = email.recipient.email + + msg.attach(MIMEText(email.body_text, "plain")) + msg.attach(MIMEText(email.body_html, "html")) + return msg + + +def send_email(email: Email, smtp_config: dict) -> None: + """Send an email via SMTP. + + smtp_config keys: host, port, user, password, sender_email + """ + msg = build_mime_message(email, smtp_config["sender_email"]) + context = ssl.create_default_context() + + with smtplib.SMTP_SSL( + smtp_config["host"], + int(smtp_config["port"]), + context=context, + ) as server: + server.login(smtp_config["user"], smtp_config["password"]) + server.send_message(msg) diff --git a/examples/email-automation/tests/test_sender.py b/examples/email-automation/tests/test_sender.py new file mode 100644 index 0000000..4fe874b --- /dev/null +++ b/examples/email-automation/tests/test_sender.py @@ -0,0 +1,53 @@ +"""Tests for email delivery (file output and SMTP).""" + +from email_workflow.models import Email, Recipient +from email_workflow.sender import build_mime_message, save_email + + +def _make_email() -> Email: + return Email( + recipient=Recipient(name="Jan", email="jan@example.com"), + subject="Test Subject", + body_html="

Hello

", + body_text="Hello", + template_name="Welcome Email", + ) + + +def test_save_creates_html_file(tmp_path): + email = _make_email() + path = save_email(email, tmp_path) + assert path.exists() + assert path.suffix == ".html" + + +def test_save_file_contains_html(tmp_path): + email = _make_email() + path = save_email(email, tmp_path) + content = path.read_text() + assert "" in content.lower() + assert "Hello" in content + + +def test_save_filename_contains_template_and_recipient(tmp_path): + email = _make_email() + path = save_email(email, tmp_path) + assert "welcome-email" in path.name + assert "jan" in path.name + + +def test_build_mime_message_has_correct_headers(): + email = _make_email() + msg = build_mime_message(email, "sender@example.com") + assert msg["To"] == "jan@example.com" + assert msg["Subject"] == "Test Subject" + assert msg["From"] == "sender@example.com" + + +def test_build_mime_message_has_both_parts(): + email = _make_email() + msg = build_mime_message(email, "sender@example.com") + parts = list(msg.walk()) + content_types = [p.get_content_type() for p in parts] + assert "text/plain" in content_types + assert "text/html" in content_types From 5f43bac2a162c9695d0373c1168ff2bfbb4fe997 Mon Sep 17 00:00:00 2001 From: Jan Rummel Date: Sun, 8 Mar 2026 22:53:14 +0100 Subject: [PATCH 08/10] Add terminal display formatting Co-Authored-By: Claude Opus 4.6 --- .../src/email_workflow/display.py | 42 +++++++++++++++++++ .../email-automation/tests/test_display.py | 36 ++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 examples/email-automation/src/email_workflow/display.py create mode 100644 examples/email-automation/tests/test_display.py diff --git a/examples/email-automation/src/email_workflow/display.py b/examples/email-automation/src/email_workflow/display.py new file mode 100644 index 0000000..7a20cda --- /dev/null +++ b/examples/email-automation/src/email_workflow/display.py @@ -0,0 +1,42 @@ +"""Terminal display formatting for the email workflow.""" + +from email_workflow.models import Email, Template + + +def format_header(title: str) -> str: + """Format a boxed header.""" + width = 42 + lines = [ + "", + "╔" + "═" * width + "╗", + "║" + title.center(width) + "║", + "╚" + "═" * width + "╝", + "", + ] + return "\n".join(lines) + + +def format_template_menu(templates: list[Template]) -> str: + """Format the template selection menu.""" + lines = ["Choose a template:", ""] + for i, t in enumerate(templates, 1): + lines.append(f" {i}. {t.name:<25} {t.description}") + lines.append("") + return "\n".join(lines) + + +def format_preview(email: Email) -> str: + """Format an email preview for the terminal.""" + sep = "━" * 40 + lines = [ + "", + "Preview:", + sep, + f"Subject: {email.subject}", + f"To: {email.recipient.name} <{email.recipient.email}>", + sep, + email.body_text, + sep, + "", + ] + return "\n".join(lines) diff --git a/examples/email-automation/tests/test_display.py b/examples/email-automation/tests/test_display.py new file mode 100644 index 0000000..ae23a55 --- /dev/null +++ b/examples/email-automation/tests/test_display.py @@ -0,0 +1,36 @@ +"""Tests for terminal display formatting.""" + +from email_workflow.display import format_header, format_preview, format_template_menu +from email_workflow.models import Email, Recipient, Template + + +def test_format_header(): + result = format_header("Email Automation") + assert "Email Automation" in result + assert "╔" in result + + +def test_format_template_menu(): + templates = [ + Template(name="Welcome", description="Onboarding", subject="S", body="B", variables=[]), + Template(name="Invoice", description="Billing", subject="S", body="B", variables=[]), + ] + result = format_template_menu(templates) + assert "1." in result + assert "2." in result + assert "Welcome" in result + assert "Billing" in result + + +def test_format_preview(): + email = Email( + recipient=Recipient(name="Jan", email="jan@example.com"), + subject="Welcome!", + body_html="

Hi

", + body_text="Hi Jan, welcome.", + template_name="Welcome Email", + ) + result = format_preview(email) + assert "jan@example.com" in result + assert "Welcome!" in result + assert "Hi Jan" in result From 200966636edf3ab3b82f2cda6638ec6b5a396c47 Mon Sep 17 00:00:00 2001 From: Jan Rummel Date: Sun, 8 Mar 2026 22:57:50 +0100 Subject: [PATCH 09/10] Add CLI runner with interactive email workflow Co-Authored-By: Claude Opus 4.6 --- examples/email-automation/run.py | 127 +++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 examples/email-automation/run.py diff --git a/examples/email-automation/run.py b/examples/email-automation/run.py new file mode 100644 index 0000000..991559b --- /dev/null +++ b/examples/email-automation/run.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Email Automation workflow runner. + +Pattern: trigger -> transform -> deliver + +Template-based email automation with interactive variable input, +HTML rendering, and optional SMTP delivery. + +Usage: + uv run python run.py # interactive template selection + uv run python run.py --template 2 # select template by number + uv run python run.py --send # send via SMTP instead of saving +""" + +import argparse +import os +import sys +from pathlib import Path + +from email_workflow.display import format_header, format_preview, format_template_menu +from email_workflow.models import Recipient +from email_workflow.renderer import render_email +from email_workflow.sender import save_email, send_email +from email_workflow.templates import TEMPLATES, get_template + +OUTPUT_DIR = Path(__file__).parent / "output" + + +def _load_dotenv(): + """Load .env file if it exists.""" + env_path = Path(__file__).parent / ".env" + if not env_path.exists(): + return + for line in env_path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + key, _, value = line.partition("=") + if key and value: + os.environ.setdefault(key.strip(), value.strip()) + + +def _select_template() -> int: + """Interactive template selection. Returns 0-based index.""" + print(format_header("Email Automation — Setup")) + print(format_template_menu(TEMPLATES)) + choice = input(f"Template (1-{len(TEMPLATES)}): ").strip() + try: + return int(choice) - 1 + except ValueError: + return 0 + + +def _collect_recipient() -> Recipient: + """Interactively collect recipient info.""" + try: + name = input("Recipient name: ").strip() or "Recipient" + email = input("Recipient email: ").strip() or "recipient@example.com" + except (EOFError, KeyboardInterrupt): + print("\nAborted.") + sys.exit(0) + return Recipient(name=name, email=email) + + +def _collect_variables(template) -> dict[str, str]: + """Interactively collect template variables.""" + print(f"\n── {template.name} ──\n") + variables = {} + for var in template.variables: + try: + value = input(f" {var}: ").strip() + except (EOFError, KeyboardInterrupt): + print("\nAborted.") + sys.exit(0) + variables[var] = value or f"[{var}]" + return variables + + +def _get_smtp_config() -> dict: + """Get SMTP config from environment variables.""" + required = ["SMTP_HOST", "SMTP_PORT", "SMTP_USER", "SMTP_PASSWORD", "SENDER_EMAIL"] + config = {} + for key in required: + value = os.environ.get(key) + if not value: + print(f"Error: {key} not set. Check your .env file.") + print(" cp .env.example .env # then add your SMTP settings") + sys.exit(1) + config[key.lower().replace("smtp_", "")] = value + config["sender_email"] = os.environ["SENDER_EMAIL"] + return config + + +def main(): + _load_dotenv() + + parser = argparse.ArgumentParser(description="Email Automation: trigger -> transform -> deliver") + parser.add_argument("--template", type=int, default=None, help="Template number (1-5)") + parser.add_argument("--send", action="store_true", help="Send via SMTP instead of saving to file") + args = parser.parse_args() + + # Step 1: Trigger — select template + if args.template is not None: + template = get_template(args.template - 1) + else: + template = get_template(_select_template()) + + # Collect recipient and variables + recipient = _collect_recipient() + variables = _collect_variables(template) + + # Step 2: Transform — render email + email = render_email(template, recipient, variables) + print(format_preview(email)) + + # Step 3: Deliver — save or send + if args.send: + smtp_config = _get_smtp_config() + send_email(email, smtp_config) + print(f" Email sent to {email.recipient.email}") + else: + path = save_email(email, OUTPUT_DIR) + print(f" Email saved to {path}") + + +if __name__ == "__main__": + main() From fc7a28f32fc624f0c68ff6379a471425289e4ce4 Mon Sep 17 00:00:00 2001 From: Jan Rummel Date: Sun, 8 Mar 2026 22:58:59 +0100 Subject: [PATCH 10/10] Link Email Automation example from README and website Co-Authored-By: Claude Opus 4.6 --- README.md | 8 +++++--- docs/index.html | 6 +++++- scripts/generate_site.py | 5 +++++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6302941..953f4a8 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ Each example implements a real workflow pattern with current tools — not just |---------|---------|-------------| | [AI Content Creation](examples/ai-content-creation/) | `api -> ai -> transform -> deliver` | Fetches RSS feeds, summarizes with Claude, saves a markdown digest. Interactive selection from 60 curated feeds across 6 categories. | | [AI Chatbot](examples/ai-chatbot/) | `trigger -> ai -> data -> deliver` | Streaming terminal chat with 5 persona presets and conversation persistence | +| [Email Automation](examples/email-automation/) | `trigger -> transform -> deliver` | Template-based emails with 5 presets, HTML rendering, and optional SMTP delivery | ```bash cd examples/ai-content-creation @@ -171,12 +172,12 @@ uv run python run.py # interactive feed selection uv run python run.py --feeds https://hnrss.org/newest?points=100 # or custom ``` -More examples coming: Data Pipeline, Email Automation. +More examples coming: Data Pipeline, Social Media Automation, and more. ## Development ```bash -uv run pytest -v # 72 tests across 15 modules +uv run pytest -v # 97 tests across 20 modules uv run ruff check . # Lint ``` @@ -192,7 +193,8 @@ workflow-patterns/ │ └── mcp_server/server.py # 4 MCP tools for Claude Code integration ├── examples/ │ ├── ai-chatbot/ # Runnable example (22 tests, streaming API, 5 personas) -│ └── ai-content-creation/ # Runnable example (30 tests, Claude API, 60 curated feeds) +│ ├── ai-content-creation/ # Runnable example (30 tests, Claude API, 60 curated feeds) +│ └── email-automation/ # Runnable example (25 tests, 5 templates, HTML + SMTP) ├── scripts/ │ └── generate_site.py # Static site generator with interactive wizard ├── docs/ diff --git a/docs/index.html b/docs/index.html index b77b781..f03ddfd 100644 --- a/docs/index.html +++ b/docs/index.html @@ -821,7 +821,11 @@

Most-Used Tools

"count": 0, "pct": 0.0, "avg_nodes": 0.0, - "example": null + "example": { + "path": "examples/email-automation", + "label": "Runnable Example", + "desc": "25 tests, 5 templates, HTML rendering, optional SMTP \u2014 ready to run" + } }, { "name": "Document Processing", diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 4e591d9..c5a9a2f 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -433,6 +433,11 @@ def send_confirmation(name, email, event): s.send_message(msg)""", "run": "python email_handler.py # triggered by webhook or form", "learn": ["SMTP basics", "Email templating", "Webhook handling", "App passwords"], + "example": { + "path": "examples/email-automation", + "label": "Runnable Example", + "desc": "25 tests, 5 templates, HTML rendering, optional SMTP — ready to run", + }, }, "Document Processing": { "question": "I read documents and extract information from them",