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 @@
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 = """\ + + +
+ + + + +{p.replace(chr(10), '
')}
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 +``` 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/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() 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/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/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/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 = """\ + + + + + + + +{p.replace(chr(10), '
')}
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 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"
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 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 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] 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" }, +] 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",