Full implementation of the Howl email triage system: - Microsoft Graph API integration with MSAL auth (client-credentials and delegated modes) - Claude LLM classification via tool use for structured output - PostgreSQL database with customers, vendors, whitelist, and email_log tables - Alembic migration for full schema - APScheduler daemon with graceful shutdown - Typer CLI (run, dry-run, status commands) - Business rule classifier with overrides (whitelist protection, low-confidence fallback) - Action executor (move to folders, flag, escalate with webhook) - 35 passing unit tests - README, SETUP, and NEXT_STEPS documentation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
157 lines
4.9 KiB
Python
157 lines
4.9 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Generator
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from howl.config import Settings
|
|
from howl.db.queries import SenderMatch
|
|
from howl.graph.client import GraphClient, Message
|
|
from howl.llm.schemas import EmailClassification
|
|
|
|
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Settings fixture (no real credentials needed)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture
|
|
def settings() -> Settings:
|
|
return Settings(
|
|
azure_tenant_id="test-tenant",
|
|
azure_client_id="test-client-id",
|
|
azure_client_secret="test-secret",
|
|
graph_auth_mode="client_credentials",
|
|
graph_mailbox="test@example.com",
|
|
anthropic_api_key="test-anthropic-key",
|
|
database_url="postgresql+asyncpg://test:test@localhost:5432/howl_test",
|
|
dry_run=True,
|
|
log_level="WARNING",
|
|
log_format="text",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sample email messages
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture
|
|
def sample_emails() -> list[dict]:
|
|
with open(FIXTURES_DIR / "sample_emails.json") as f:
|
|
return json.load(f)
|
|
|
|
|
|
@pytest.fixture
|
|
def llm_responses() -> dict:
|
|
with open(FIXTURES_DIR / "llm_responses.json") as f:
|
|
return json.load(f)
|
|
|
|
|
|
def make_message(raw: dict) -> Message:
|
|
sender = raw.get("sender", {}).get("emailAddress", {})
|
|
received_str = raw.get("receivedDateTime")
|
|
received_at = datetime.fromisoformat(received_str.replace("Z", "+00:00")) if received_str else None
|
|
return Message(
|
|
id=raw["id"],
|
|
conversation_id=raw.get("conversationId"),
|
|
subject=raw.get("subject"),
|
|
sender_address=sender.get("address", ""),
|
|
sender_name=sender.get("name"),
|
|
received_at=received_at,
|
|
has_attachments=raw.get("hasAttachments", False),
|
|
importance=raw.get("importance", "normal"),
|
|
body_preview=raw.get("bodyPreview"),
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def customer_message(sample_emails) -> Message:
|
|
return make_message(next(e for e in sample_emails if e["id"] == "msg-001-customer-inquiry"))
|
|
|
|
|
|
@pytest.fixture
|
|
def spam_message(sample_emails) -> Message:
|
|
return make_message(next(e for e in sample_emails if e["id"] == "msg-004-spam"))
|
|
|
|
|
|
@pytest.fixture
|
|
def urgent_message(sample_emails) -> Message:
|
|
return make_message(next(e for e in sample_emails if e["id"] == "msg-007-urgent"))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mock Graph client
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture
|
|
def mock_graph(sample_emails) -> MagicMock:
|
|
graph = MagicMock(spec=GraphClient)
|
|
raw_messages = [make_message(e) for e in sample_emails]
|
|
graph.list_unread_messages.return_value = (raw_messages[:3], None)
|
|
graph.get_message_body.return_value = "This is the email body text."
|
|
graph.get_or_create_folder.return_value = str(uuid.uuid4())
|
|
graph.move_message.return_value = None
|
|
graph.flag_message.return_value = None
|
|
graph.mark_as_read.return_value = None
|
|
return graph
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mock LLM client
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture
|
|
def mock_llm_classification(llm_responses) -> EmailClassification:
|
|
return EmailClassification.model_validate(llm_responses["customer_inquiry"])
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_llm(mock_llm_classification) -> MagicMock:
|
|
from howl.llm.client import LLMClient
|
|
llm = MagicMock(spec=LLMClient)
|
|
llm.classify.return_value = (
|
|
mock_llm_classification,
|
|
{
|
|
"model": "claude-sonnet-4-6",
|
|
"stop_reason": "tool_use",
|
|
"usage": {"input_tokens": 350, "output_tokens": 80},
|
|
"tool_input": mock_llm_classification.model_dump(),
|
|
},
|
|
)
|
|
return llm
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sender matches
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture
|
|
def customer_match() -> SenderMatch:
|
|
return SenderMatch(
|
|
sender_type="customer",
|
|
entity_id=uuid.uuid4(),
|
|
entity_table="customers",
|
|
entity_name="Acme Corp",
|
|
notes="Key account - handle with priority",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def unknown_match() -> SenderMatch:
|
|
return SenderMatch(sender_type="unknown")
|
|
|
|
|
|
@pytest.fixture
|
|
def whitelist_match() -> SenderMatch:
|
|
return SenderMatch(
|
|
sender_type="whitelist",
|
|
entity_id=uuid.uuid4(),
|
|
entity_table="whitelist",
|
|
entity_name="Trusted partner domain",
|
|
)
|