Initial commit: Howl M365 email management daemon
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>
This commit is contained in:
commit
3bfda9e585
39 changed files with 3656 additions and 0 deletions
157
tests/conftest.py
Normal file
157
tests/conftest.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
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",
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue