Add web UI, sender profiles, purge rules, and infosec action
Extends the pipeline with infosec classification, sender profile tracking, and configurable purge rules. Adds a web dashboard for managing rules and monitoring email processing. Includes new migrations and seed script. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3bfda9e585
commit
ecc6681432
48 changed files with 4394 additions and 59 deletions
74
SCHEMA.md
Normal file
74
SCHEMA.md
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
# Howl Database Schema — Seed Data Guide
|
||||
|
||||
This file describes what data to insert and how. The daemon populates `email_log` automatically — only the three sections below need to be seeded.
|
||||
|
||||
---
|
||||
|
||||
## `customers` + `customer_emails`
|
||||
|
||||
Insert the `customers` row first, then insert one or more `customer_emails` rows using the returned UUID.
|
||||
|
||||
### `customers`
|
||||
| Column | Type | Required | Notes |
|
||||
|--------|------|----------|-------|
|
||||
| `name` | text | yes | Full name |
|
||||
| `company` | text | no | Company name |
|
||||
| `phone` | text | no | |
|
||||
| `notes` | text | no | Free-form notes |
|
||||
| `is_active` | bool | no | Defaults to `true` |
|
||||
|
||||
### `customer_emails`
|
||||
| Column | Type | Required | Notes |
|
||||
|--------|------|----------|-------|
|
||||
| `customer_id` | UUID | yes | FK → `customers.id` |
|
||||
| `email_address` | text | yes | Must be globally unique across this table |
|
||||
| `label` | text | no | e.g. `"work"`, `"billing"` |
|
||||
| `is_primary` | bool | no | Defaults to `false` — set `true` on the main address |
|
||||
|
||||
---
|
||||
|
||||
## `vendors` + `vendor_emails`
|
||||
|
||||
Same insert pattern as customers — parent row first, then email rows.
|
||||
|
||||
### `vendors`
|
||||
| Column | Type | Required | Notes |
|
||||
|--------|------|----------|-------|
|
||||
| `name` | text | yes | |
|
||||
| `company` | text | no | |
|
||||
| `service_category` | text | no | e.g. `"logistics"`, `"IT"`, `"legal"` |
|
||||
| `phone` | text | no | |
|
||||
| `notes` | text | no | |
|
||||
| `is_active` | bool | no | Defaults to `true` |
|
||||
|
||||
### `vendor_emails`
|
||||
| Column | Type | Required | Notes |
|
||||
|--------|------|----------|-------|
|
||||
| `vendor_id` | UUID | yes | FK → `vendors.id` |
|
||||
| `email_address` | text | yes | Must be globally unique across this table |
|
||||
| `label` | text | no | |
|
||||
| `is_primary` | bool | no | Defaults to `false` |
|
||||
|
||||
---
|
||||
|
||||
## `whitelist`
|
||||
|
||||
Standalone rows — no FK dependencies. At least one of `email_address` or `domain` must be set per row.
|
||||
|
||||
| Column | Type | Required | Notes |
|
||||
|--------|------|----------|-------|
|
||||
| `email_address` | text | one of these | Exact address match e.g. `"alice@example.com"` |
|
||||
| `domain` | text | one of these | Entire domain match e.g. `"partnerco.com"` |
|
||||
| `description` | text | no | Why this entry is whitelisted |
|
||||
| `added_by` | text | no | Who added it |
|
||||
| `expires_at` | timestamptz | no | Leave null for permanent entries |
|
||||
| `is_active` | bool | no | Defaults to `true` |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **`email_log`** — do not seed this table. The daemon writes to it automatically as it processes emails.
|
||||
- Email addresses are unique within their respective table (`customer_emails`, `vendor_emails`). There is no DB-level constraint preventing the same address appearing in both, but it should not happen logically.
|
||||
- All `id` columns are UUID and auto-generated — do not supply them unless you have a specific reason to.
|
||||
- `DATABASE_URL` is in `.env` — use the `postgresql+asyncpg://` form for async code, or `postgresql+psycopg://` for sync/migration code.
|
||||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
|||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
|
|
@ -16,14 +16,18 @@ class Settings(BaseSettings):
|
|||
graph_auth_mode: Literal["client_credentials", "delegated"] = "client_credentials"
|
||||
msal_token_cache_path: str = ".msal_cache.bin"
|
||||
graph_mailbox: str
|
||||
graph_mailboxes: str = "" # comma-separated list of additional mailboxes for the scanner
|
||||
graph_poll_interval_seconds: int = 300
|
||||
graph_batch_size: int = 50
|
||||
graph_max_body_chars: int = 4000
|
||||
|
||||
# Anthropic
|
||||
anthropic_api_key: str
|
||||
# LLM
|
||||
llm_provider: Literal["anthropic", "ollama"] = "anthropic"
|
||||
anthropic_api_key: str = ""
|
||||
anthropic_model: str = "claude-sonnet-4-6"
|
||||
anthropic_max_tokens: int = 1024
|
||||
ollama_base_url: str = "http://localhost:11434"
|
||||
ollama_model: str = "qwen2.5:14b"
|
||||
llm_confidence_threshold: float = 0.60
|
||||
|
||||
# PostgreSQL
|
||||
|
|
@ -42,6 +46,12 @@ class Settings(BaseSettings):
|
|||
folder_review: str = "Needs Review"
|
||||
folder_spam: str = "Junk Email"
|
||||
folder_escalate: str = "Escalate"
|
||||
folder_infosec: str = "Infosec"
|
||||
|
||||
|
||||
# Web UI
|
||||
web_host: str = "127.0.0.1"
|
||||
web_port: int = 8080
|
||||
|
||||
# Logging
|
||||
log_level: str = "INFO"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from sqlalchemy import (
|
|||
CheckConstraint,
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
Numeric,
|
||||
|
|
@ -59,6 +60,7 @@ class CustomerEmail(Base):
|
|||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
customer_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("customers.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
email_address: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
|
@ -99,7 +101,11 @@ class VendorEmail(Base):
|
|||
__table_args__ = (UniqueConstraint("email_address", name="uq_vendor_email"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
vendor_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||
vendor_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("vendors.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
email_address: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
label: Mapped[Optional[str]] = mapped_column(Text)
|
||||
is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
|
@ -133,9 +139,22 @@ class Whitelist(Base):
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Email Log
|
||||
# Purge Rules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PurgeRule(Base):
|
||||
__tablename__ = "purge_rules"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email_address: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||
display_name: Mapped[Optional[str]] = mapped_column(Text)
|
||||
older_than_days: Mapped[int] = mapped_column(Integer, nullable=False, default=30)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
notes: Mapped[Optional[str]] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
SENDER_TYPE_ENUM = Enum(
|
||||
"customer", "vendor", "whitelist", "unknown", name="sender_type", create_type=False
|
||||
)
|
||||
|
|
@ -158,6 +177,33 @@ PROCESSING_STATUS_ENUM = Enum(
|
|||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sender Profiles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SenderProfile(Base):
|
||||
__tablename__ = "sender_profiles"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email_address: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||
display_name: Mapped[Optional[str]] = mapped_column(Text)
|
||||
sender_type: Mapped[str] = mapped_column(SENDER_TYPE_ENUM, nullable=False, default="unknown")
|
||||
email_types: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||
processing_instructions: Mapped[Optional[str]] = mapped_column(Text)
|
||||
notes: Mapped[Optional[str]] = mapped_column(Text)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Email Log
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class EmailLog(Base):
|
||||
__tablename__ = "email_log"
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import structlog
|
|||
from sqlalchemy import select, text, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from howl.db.models import EmailLog
|
||||
from howl.db.models import EmailLog, SenderProfile
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
|
@ -164,3 +164,16 @@ async def get_recent_logs(session: AsyncSession, limit: int = 20) -> list[EmailL
|
|||
select(EmailLog).order_by(EmailLog.created_at.desc()).limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def lookup_sender_profile(
|
||||
session: AsyncSession, email_address: str
|
||||
) -> Optional[SenderProfile]:
|
||||
"""Return the active sender profile for an address, or None."""
|
||||
from sqlalchemy import func as _func
|
||||
result = await session.execute(
|
||||
select(SenderProfile)
|
||||
.where(_func.lower(SenderProfile.email_address) == email_address.lower())
|
||||
.where(SenderProfile.is_active == True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
|
|
|||
|
|
@ -89,13 +89,17 @@ class GraphClient:
|
|||
folder: str = "Inbox",
|
||||
top: int = 50,
|
||||
skip_token: Optional[str] = None,
|
||||
received_after: Optional[datetime] = None,
|
||||
) -> tuple[list[Message], Optional[str]]:
|
||||
"""
|
||||
Return a batch of unread messages and the next skip token (or None if done).
|
||||
"""
|
||||
url = f"{GRAPH_BASE}/users/{mailbox}/mailFolders/{folder}/messages"
|
||||
filter_parts = ["isRead eq false"]
|
||||
if received_after is not None:
|
||||
filter_parts.append(f"receivedDateTime ge {received_after.strftime('%Y-%m-%dT%H:%M:%SZ')}")
|
||||
params: dict = {
|
||||
"$filter": "isRead eq false",
|
||||
"$filter": " and ".join(filter_parts),
|
||||
"$top": str(top),
|
||||
"$select": (
|
||||
"id,conversationId,subject,sender,receivedDateTime,"
|
||||
|
|
@ -197,6 +201,119 @@ class GraphClient:
|
|||
log.info("folder_created", mailbox=mailbox, folder_name=folder_name)
|
||||
return data["id"]
|
||||
|
||||
@retry(
|
||||
retry=retry_if_exception(_is_retryable),
|
||||
stop=stop_after_attempt(4),
|
||||
wait=wait_exponential(multiplier=1, min=2, max=30),
|
||||
reraise=True,
|
||||
)
|
||||
def delete_message(self, mailbox: str, message_id: str) -> None:
|
||||
url = f"{GRAPH_BASE}/users/{mailbox}/messages/{message_id}"
|
||||
response = self._http.delete(url, headers=self._headers())
|
||||
response.raise_for_status()
|
||||
|
||||
def iter_messages_in_range(
|
||||
self,
|
||||
mailbox: str,
|
||||
received_after: datetime,
|
||||
top: int = 100,
|
||||
):
|
||||
"""
|
||||
Yield Message objects received on or after `received_after`, most-recent first.
|
||||
Follows @odata.nextLink directly to handle both $skip and $skipToken pagination.
|
||||
Stops as soon as a page contains a message older than the cutoff.
|
||||
"""
|
||||
url = f"{GRAPH_BASE}/users/{mailbox}/messages"
|
||||
params: dict = {
|
||||
"$top": str(top),
|
||||
"$select": "id,subject,sender,receivedDateTime,hasAttachments,importance",
|
||||
"$orderby": "receivedDateTime desc",
|
||||
}
|
||||
|
||||
while url:
|
||||
if params:
|
||||
data = self._get(url, params)
|
||||
params = {} # subsequent requests use nextLink which already has params
|
||||
else:
|
||||
data = self._get(url)
|
||||
|
||||
raw = data.get("value", [])
|
||||
done = False
|
||||
for r in raw:
|
||||
msg = _parse_message(r)
|
||||
if msg.received_at is not None and msg.received_at < received_after:
|
||||
done = True
|
||||
break
|
||||
yield msg
|
||||
|
||||
if done:
|
||||
break
|
||||
url = data.get("@odata.nextLink", "")
|
||||
|
||||
@retry(
|
||||
retry=retry_if_exception(_is_retryable),
|
||||
stop=stop_after_attempt(4),
|
||||
wait=wait_exponential(multiplier=1, min=2, max=30),
|
||||
reraise=True,
|
||||
)
|
||||
def get_messages_from_sender(
|
||||
self,
|
||||
mailbox: str,
|
||||
sender_address: str,
|
||||
limit: int = 5,
|
||||
) -> list[Message]:
|
||||
"""Fetch recent messages from a specific sender, most recent first."""
|
||||
safe_addr = sender_address.replace("'", "''")
|
||||
url = f"{GRAPH_BASE}/users/{mailbox}/mailFolders/AllItems/messages"
|
||||
params: dict = {
|
||||
"$filter": f"from/emailAddress/address eq '{safe_addr}'",
|
||||
"$top": str(limit),
|
||||
"$select": "id,conversationId,subject,sender,receivedDateTime,hasAttachments,importance",
|
||||
}
|
||||
data = self._get(url, params)
|
||||
# Sort by received date descending (Graph doesn't guarantee order with $filter alone)
|
||||
messages = [_parse_message(m) for m in data.get("value", [])]
|
||||
messages.sort(key=lambda m: m.received_at or datetime.min, reverse=True)
|
||||
return messages
|
||||
|
||||
def collect_purgeable_messages(
|
||||
self,
|
||||
mailbox: str,
|
||||
rules: list[tuple[str, datetime]],
|
||||
) -> list["Message"]:
|
||||
"""
|
||||
Single-pass full mailbox scan. Returns all messages whose sender matches
|
||||
one of the (address, received_before) rule pairs.
|
||||
Much more efficient than one scan per sender.
|
||||
"""
|
||||
# Build a lookup: addr_lower -> received_before cutoff
|
||||
rule_map: dict[str, datetime] = {addr.lower(): cutoff for addr, cutoff in rules}
|
||||
|
||||
to_delete: list[Message] = []
|
||||
url = f"{GRAPH_BASE}/users/{mailbox}/messages"
|
||||
params: dict = {
|
||||
"$top": "100",
|
||||
"$select": "id,subject,sender,receivedDateTime",
|
||||
"$orderby": "receivedDateTime desc",
|
||||
}
|
||||
|
||||
while url:
|
||||
if params:
|
||||
data = self._get(url, params)
|
||||
params = {}
|
||||
else:
|
||||
data = self._get(url)
|
||||
|
||||
for r in data.get("value", []):
|
||||
msg = _parse_message(r)
|
||||
cutoff = rule_map.get(msg.sender_address.lower())
|
||||
if cutoff and msg.received_at and msg.received_at < cutoff:
|
||||
to_delete.append(msg)
|
||||
|
||||
url = data.get("@odata.nextLink", "")
|
||||
|
||||
return to_delete
|
||||
|
||||
def close(self) -> None:
|
||||
self._http.close()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,23 +1,182 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from anthropic import Anthropic
|
||||
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
||||
|
||||
from howl.config import Settings
|
||||
from howl.db.queries import SenderMatch
|
||||
from howl.graph.client import Message
|
||||
from howl.llm.prompts import SYSTEM_PROMPT, build_prompt
|
||||
from howl.llm.schemas import CLASSIFY_EMAIL_TOOL, EmailClassification
|
||||
from howl.llm.prompts import PROFILE_RECOMMEND_SYSTEM_PROMPT, SYSTEM_PROMPT, build_profile_recommend_prompt, build_prompt
|
||||
from howl.llm.schemas import CLASSIFY_EMAIL_TOOL, RECOMMEND_PROFILE_TOOL, EmailClassification, ProfileRecommendation
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
from howl.db.models import SenderProfile
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class LLMClient:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Anthropic-format tool → OpenAI-format tool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _to_openai_tool(tool: dict) -> dict:
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool["name"],
|
||||
"description": tool.get("description", ""),
|
||||
"parameters": tool["input_schema"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider backends
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _AnthropicBackend:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
from anthropic import Anthropic
|
||||
|
||||
self._client = Anthropic(api_key=settings.anthropic_api_key)
|
||||
self._model = settings.anthropic_model
|
||||
self._max_tokens = settings.anthropic_max_tokens
|
||||
|
||||
def call(
|
||||
self,
|
||||
system: str,
|
||||
user_prompt: str,
|
||||
tool: dict,
|
||||
tool_name: str,
|
||||
) -> tuple[dict, dict]:
|
||||
"""Returns (tool_input_dict, raw_response_dict)."""
|
||||
response = self._client.messages.create(
|
||||
model=self._model,
|
||||
max_tokens=self._max_tokens,
|
||||
system=system,
|
||||
tools=[tool],
|
||||
tool_choice={"type": "tool", "name": tool_name},
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
)
|
||||
|
||||
tool_use_block = next(
|
||||
(block for block in response.content if block.type == "tool_use"),
|
||||
None,
|
||||
)
|
||||
if tool_use_block is None:
|
||||
raise ValueError(f"LLM did not call the {tool_name} tool")
|
||||
|
||||
raw_response = {
|
||||
"model": response.model,
|
||||
"stop_reason": response.stop_reason,
|
||||
"usage": {
|
||||
"input_tokens": response.usage.input_tokens,
|
||||
"output_tokens": response.usage.output_tokens,
|
||||
},
|
||||
"tool_input": tool_use_block.input,
|
||||
}
|
||||
return tool_use_block.input, raw_response
|
||||
|
||||
|
||||
class _OllamaBackend:
|
||||
"""Uses Ollama's native /api/chat with JSON mode instead of tool use,
|
||||
which is more reliable across quantised models."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._base_url = settings.ollama_base_url.rstrip("/")
|
||||
self._model = settings.ollama_model
|
||||
self._http = httpx.Client(timeout=180)
|
||||
|
||||
@staticmethod
|
||||
def _schema_instruction(tool: dict) -> str:
|
||||
"""Convert a tool definition into a JSON-mode prompt suffix."""
|
||||
schema = tool["input_schema"]
|
||||
props = schema.get("properties", {})
|
||||
required = schema.get("required", [])
|
||||
|
||||
lines = [
|
||||
"",
|
||||
"Respond with a JSON object containing exactly these fields:",
|
||||
]
|
||||
for name, prop in props.items():
|
||||
req = " (required)" if name in required else " (optional)"
|
||||
desc = prop.get("description", "")
|
||||
ptype = prop.get("type", "")
|
||||
enum = prop.get("enum")
|
||||
items_enum = prop.get("items", {}).get("enum") if prop.get("type") == "array" else None
|
||||
|
||||
constraint = ""
|
||||
if enum:
|
||||
constraint = f", one of: {', '.join(enum)}"
|
||||
elif items_enum:
|
||||
constraint = f", array of values from: {', '.join(items_enum)}"
|
||||
|
||||
lines.append(f"- {name} ({ptype}{constraint}){req}: {desc}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def call(
|
||||
self,
|
||||
system: str,
|
||||
user_prompt: str,
|
||||
tool: dict,
|
||||
tool_name: str,
|
||||
) -> tuple[dict, dict]:
|
||||
"""Returns (tool_input_dict, raw_response_dict)."""
|
||||
schema_suffix = self._schema_instruction(tool)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": self._model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system + schema_suffix},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
"format": "json",
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
resp = self._http.post(f"{self._base_url}/api/chat", json=payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
content = data.get("message", {}).get("content", "")
|
||||
try:
|
||||
tool_input = json.loads(content)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
raise ValueError(
|
||||
f"Ollama returned invalid JSON for {tool_name}. "
|
||||
f"Response: {content[:500]}"
|
||||
)
|
||||
|
||||
raw_response = {
|
||||
"model": data.get("model", self._model),
|
||||
"stop_reason": data.get("done_reason", ""),
|
||||
"usage": {
|
||||
"input_tokens": data.get("prompt_eval_count", 0),
|
||||
"output_tokens": data.get("eval_count", 0),
|
||||
},
|
||||
"tool_input": tool_input,
|
||||
}
|
||||
return tool_input, raw_response
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public LLMClient
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LLMClient:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._provider = settings.llm_provider
|
||||
if self._provider == "ollama":
|
||||
self._backend = _OllamaBackend(settings)
|
||||
else:
|
||||
self._backend = _AnthropicBackend(settings)
|
||||
self._max_body_chars = settings.graph_max_body_chars
|
||||
|
||||
@retry(
|
||||
|
|
@ -31,52 +190,72 @@ class LLMClient:
|
|||
message: Message,
|
||||
body: str,
|
||||
sender_match: SenderMatch,
|
||||
sender_profile: Optional["SenderProfile"] = None,
|
||||
) -> tuple[EmailClassification, dict]:
|
||||
"""
|
||||
Send the email to Claude for classification via tool use.
|
||||
Send the email for classification via tool use.
|
||||
|
||||
Returns:
|
||||
(EmailClassification, raw_response_dict)
|
||||
"""
|
||||
user_prompt = build_prompt(message, body, sender_match, self._max_body_chars)
|
||||
user_prompt = build_prompt(message, body, sender_match, self._max_body_chars, sender_profile)
|
||||
|
||||
response = self._client.messages.create(
|
||||
model=self._model,
|
||||
max_tokens=self._max_tokens,
|
||||
tool_input, raw_response = self._backend.call(
|
||||
system=SYSTEM_PROMPT,
|
||||
tools=[CLASSIFY_EMAIL_TOOL],
|
||||
tool_choice={"type": "tool", "name": "classify_email"},
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
user_prompt=user_prompt,
|
||||
tool=CLASSIFY_EMAIL_TOOL,
|
||||
tool_name="classify_email",
|
||||
)
|
||||
|
||||
# Extract the tool use block
|
||||
tool_use_block = next(
|
||||
(block for block in response.content if block.type == "tool_use"),
|
||||
None,
|
||||
)
|
||||
|
||||
if tool_use_block is None:
|
||||
raise ValueError("Claude did not call the classify_email tool")
|
||||
|
||||
raw_input = tool_use_block.input
|
||||
classification = EmailClassification.model_validate(raw_input)
|
||||
|
||||
raw_response = {
|
||||
"model": response.model,
|
||||
"stop_reason": response.stop_reason,
|
||||
"usage": {
|
||||
"input_tokens": response.usage.input_tokens,
|
||||
"output_tokens": response.usage.output_tokens,
|
||||
},
|
||||
"tool_input": raw_input,
|
||||
}
|
||||
classification = EmailClassification.model_validate(tool_input)
|
||||
|
||||
log.debug(
|
||||
"email_classified",
|
||||
classification=classification.classification,
|
||||
action=classification.action,
|
||||
confidence=classification.confidence,
|
||||
model=response.model,
|
||||
model=raw_response.get("model"),
|
||||
)
|
||||
|
||||
return classification, raw_response
|
||||
|
||||
@retry(
|
||||
retry=retry_if_exception_type(Exception),
|
||||
stop=stop_after_attempt(3),
|
||||
wait=wait_exponential(multiplier=1, min=2, max=15),
|
||||
reraise=True,
|
||||
)
|
||||
def recommend_profile(
|
||||
self,
|
||||
sender_address: str,
|
||||
sender_name: str,
|
||||
sample_emails: list[tuple[str, str, "Optional[datetime]", bool]],
|
||||
) -> tuple[ProfileRecommendation, dict]:
|
||||
"""
|
||||
Analyse sample emails from a sender and recommend profile settings.
|
||||
|
||||
Returns:
|
||||
(ProfileRecommendation, raw_response_dict)
|
||||
"""
|
||||
user_prompt = build_profile_recommend_prompt(
|
||||
sender_address, sender_name, sample_emails, self._max_body_chars,
|
||||
)
|
||||
|
||||
tool_input, raw_response = self._backend.call(
|
||||
system=PROFILE_RECOMMEND_SYSTEM_PROMPT,
|
||||
user_prompt=user_prompt,
|
||||
tool=RECOMMEND_PROFILE_TOOL,
|
||||
tool_name="recommend_sender_profile",
|
||||
)
|
||||
|
||||
recommendation = ProfileRecommendation.model_validate(tool_input)
|
||||
|
||||
log.debug(
|
||||
"profile_recommended",
|
||||
sender=sender_address,
|
||||
sender_type=recommendation.sender_type,
|
||||
email_types=recommendation.email_types,
|
||||
model=raw_response.get("model"),
|
||||
)
|
||||
|
||||
return recommendation, raw_response
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from howl.db.queries import SenderMatch
|
||||
from howl.graph.client import Message
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from howl.db.models import SenderProfile
|
||||
|
||||
SYSTEM_PROMPT = """\
|
||||
You are an email triage assistant. Your job is to classify incoming emails and \
|
||||
recommend the appropriate action for each one.
|
||||
|
|
@ -20,7 +26,10 @@ Classification guidelines:
|
|||
- customer_inquiry: A customer asking a question, requesting support, or following up
|
||||
- vendor_invoice: An invoice, bill, or payment request from a vendor
|
||||
- vendor_notification: Order confirmations, shipping notifications, account updates from vendors
|
||||
- newsletter: Marketing emails, product announcements, digests (even from known contacts)
|
||||
- vendor_marketing: Promotional or marketing email from a known vendor (product announcements, offers, campaigns)
|
||||
- vendor_event: Event invitations, webinars, trade shows, or conference announcements from a known vendor
|
||||
- infosec_advisory: Security advisories, vulnerability disclosures, threat intelligence, or cybersecurity alerts from any sender
|
||||
- newsletter: Marketing emails, product announcements, digests from unknown or non-vendor senders
|
||||
- spam: Unsolicited promotional or bulk email from unknown senders
|
||||
- phishing: Emails attempting to deceive the recipient (fake invoices, credential requests, etc.)
|
||||
- support_request: A request for help or technical support
|
||||
|
|
@ -28,14 +37,15 @@ Classification guidelines:
|
|||
- unknown: Cannot determine the nature of the email
|
||||
|
||||
Action guidelines:
|
||||
- move_customer: Use for customer emails that are inquiries, requests, or correspondence
|
||||
- move_vendor: Use for vendor invoices, notifications, and correspondence
|
||||
- inbox_keep: Use for customer emails (inquiries, correspondence) and routine vendor emails (invoices, notifications)
|
||||
- move_infosec: Use for infosec_advisory emails — security alerts, vulnerability disclosures, threat intelligence
|
||||
- move_vendor: Use for vendor_marketing emails
|
||||
- move_review: Use for vendor_event emails, or when uncertain and human judgement is needed
|
||||
- move_whitelist: Use for trusted senders that are neither customers nor vendors
|
||||
- flag_follow_up: Use when the email needs a response but is not urgent
|
||||
- move_spam: Use only for high-confidence spam or newsletters from unknown senders
|
||||
- move_review: Use when uncertain or when the email needs human judgement
|
||||
- inbox_keep: Keep in inbox (use sparingly — prefer a more specific action)
|
||||
- escalate: Use for urgent, time-critical, or potentially problematic emails
|
||||
- move_customer: Do not use — customer emails stay in the inbox
|
||||
|
||||
Be conservative: when uncertain, recommend move_review and set requires_human_review=true \
|
||||
rather than taking an irreversible action. Never recommend moving email from a known \
|
||||
|
|
@ -48,6 +58,7 @@ def build_prompt(
|
|||
body: str,
|
||||
sender_match: SenderMatch,
|
||||
max_body_chars: int = 4000,
|
||||
sender_profile: Optional["SenderProfile"] = None,
|
||||
) -> str:
|
||||
"""Build the user-turn prompt string for a single email."""
|
||||
lines: list[str] = []
|
||||
|
|
@ -60,6 +71,12 @@ def build_prompt(
|
|||
if sender_match.notes:
|
||||
lines.append(f"Notes: {sender_match.notes}")
|
||||
|
||||
if sender_profile:
|
||||
if sender_profile.email_types:
|
||||
lines.append(f"Known Email Types: {', '.join(sender_profile.email_types)}")
|
||||
if sender_profile.processing_instructions:
|
||||
lines.append(f"Processing Instructions: {sender_profile.processing_instructions}")
|
||||
|
||||
lines.append(f"Subject: {message.subject or '(no subject)'}")
|
||||
lines.append(f"Received: {message.received_at.isoformat() if message.received_at else 'unknown'}")
|
||||
lines.append(f"Has Attachments: {'yes' if message.has_attachments else 'no'}")
|
||||
|
|
@ -75,3 +92,50 @@ def build_prompt(
|
|||
lines.append("---")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
PROFILE_RECOMMEND_SYSTEM_PROMPT = """\
|
||||
You are analysing sample emails from a single sender to recommend sender profile \
|
||||
settings for an email triage system.
|
||||
|
||||
Based on patterns in the subject lines, body text, and metadata, recommend:
|
||||
1. **sender_type**: Is this sender a customer, vendor, whitelisted/trusted contact, or unknown?
|
||||
2. **email_types**: What kinds of emails does this sender typically send? Choose from: \
|
||||
invoice, statement, notification, shipping, marketing, newsletter, report, support, event, other.
|
||||
3. **processing_instructions**: Write clear, specific instructions for how the AI triage \
|
||||
system should handle emails from this sender. Describe what the sender typically sends, \
|
||||
notable patterns (e.g. attachments, recurring schedule, key identifiers), and the \
|
||||
recommended action.
|
||||
|
||||
Be specific and practical in your processing instructions — they will be injected into \
|
||||
every future email classification prompt for this sender.\
|
||||
"""
|
||||
|
||||
|
||||
def build_profile_recommend_prompt(
|
||||
sender_address: str,
|
||||
sender_name: str,
|
||||
sample_emails: list[tuple[str, str, Optional[datetime], bool]],
|
||||
max_body_chars: int = 1500,
|
||||
) -> str:
|
||||
"""Build the user prompt for profile recommendation.
|
||||
|
||||
sample_emails: list of (subject, body, received_at, has_attachments)
|
||||
"""
|
||||
lines: list[str] = []
|
||||
lines.append(f"Sender: {sender_name or '(no name)'} <{sender_address}>")
|
||||
lines.append(f"Sample count: {len(sample_emails)}")
|
||||
lines.append("")
|
||||
|
||||
for i, (subject, body, received_at, has_attachments) in enumerate(sample_emails, 1):
|
||||
lines.append(f"--- Email {i} ---")
|
||||
lines.append(f"Subject: {subject or '(no subject)'}")
|
||||
if received_at:
|
||||
lines.append(f"Received: {received_at.isoformat()}")
|
||||
lines.append(f"Has Attachments: {'yes' if has_attachments else 'no'}")
|
||||
truncated = len(body) > max_body_chars
|
||||
lines.append(f"Body{' (truncated)' if truncated else ''}:")
|
||||
lines.append(body[:max_body_chars])
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ EmailActionLiteral = Literal[
|
|||
"move_customer",
|
||||
"move_vendor",
|
||||
"move_whitelist",
|
||||
"move_infosec",
|
||||
"move_spam",
|
||||
"move_review",
|
||||
"escalate",
|
||||
|
|
@ -20,6 +21,9 @@ ClassificationLiteral = Literal[
|
|||
"customer_inquiry",
|
||||
"vendor_invoice",
|
||||
"vendor_notification",
|
||||
"vendor_marketing",
|
||||
"vendor_event",
|
||||
"infosec_advisory",
|
||||
"newsletter",
|
||||
"spam",
|
||||
"phishing",
|
||||
|
|
@ -44,6 +48,13 @@ class EmailClassification(BaseModel):
|
|||
return round(v, 3)
|
||||
|
||||
|
||||
class ProfileRecommendation(BaseModel):
|
||||
sender_type: Literal["customer", "vendor", "whitelist", "unknown"]
|
||||
email_types: list[str] = Field(default_factory=list)
|
||||
processing_instructions: str
|
||||
reasoning: str
|
||||
|
||||
|
||||
# Tool definition passed to Claude
|
||||
CLASSIFY_EMAIL_TOOL = {
|
||||
"name": "classify_email",
|
||||
|
|
@ -60,6 +71,9 @@ CLASSIFY_EMAIL_TOOL = {
|
|||
"customer_inquiry",
|
||||
"vendor_invoice",
|
||||
"vendor_notification",
|
||||
"vendor_marketing",
|
||||
"vendor_event",
|
||||
"infosec_advisory",
|
||||
"newsletter",
|
||||
"spam",
|
||||
"phishing",
|
||||
|
|
@ -83,6 +97,7 @@ CLASSIFY_EMAIL_TOOL = {
|
|||
"move_customer",
|
||||
"move_vendor",
|
||||
"move_whitelist",
|
||||
"move_infosec",
|
||||
"move_spam",
|
||||
"move_review",
|
||||
"escalate",
|
||||
|
|
@ -120,3 +135,54 @@ CLASSIFY_EMAIL_TOOL = {
|
|||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
RECOMMEND_PROFILE_TOOL = {
|
||||
"name": "recommend_sender_profile",
|
||||
"description": (
|
||||
"Based on sample emails from a sender, recommend sender profile settings "
|
||||
"including sender type, typical email types, and processing instructions."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sender_type": {
|
||||
"type": "string",
|
||||
"enum": ["customer", "vendor", "whitelist", "unknown"],
|
||||
"description": "The type of sender based on the email patterns observed.",
|
||||
},
|
||||
"email_types": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"invoice",
|
||||
"statement",
|
||||
"notification",
|
||||
"shipping",
|
||||
"marketing",
|
||||
"newsletter",
|
||||
"report",
|
||||
"support",
|
||||
"event",
|
||||
"other",
|
||||
],
|
||||
},
|
||||
"description": "The types of emails this sender typically sends.",
|
||||
},
|
||||
"processing_instructions": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Specific instructions for how the email triage system should handle "
|
||||
"emails from this sender. Describe what the sender typically sends, "
|
||||
"key patterns to look for, and recommended handling."
|
||||
),
|
||||
},
|
||||
"reasoning": {
|
||||
"type": "string",
|
||||
"description": "Brief explanation of why you chose these settings based on the sample emails.",
|
||||
},
|
||||
},
|
||||
"required": ["sender_type", "email_types", "processing_instructions", "reasoning"],
|
||||
},
|
||||
}
|
||||
|
|
|
|||
330
howl/main.py
330
howl/main.py
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
|
|
@ -72,6 +73,305 @@ def dry_run(
|
|||
console.print("[green]Dry run complete. Check the email_log table for results.[/green]")
|
||||
|
||||
|
||||
@app.command(name="run-once")
|
||||
def run_once(
|
||||
mailbox: str = typer.Option("lorentz@wulfconsulting.com", "--mailbox", "-m", help="Mailbox to process"),
|
||||
days: int = typer.Option(10, "--days", "-d", help="Only process emails from the last N days"),
|
||||
dry: bool = typer.Option(False, "--dry-run", help="Analyse without taking any actions"),
|
||||
) -> None:
|
||||
"""
|
||||
Process all unread emails from the last N days in a single pass, then exit.
|
||||
"""
|
||||
import os
|
||||
os.environ["GRAPH_MAILBOX"] = mailbox
|
||||
if dry:
|
||||
os.environ["DRY_RUN"] = "true"
|
||||
|
||||
settings, graph, processor = _bootstrap()
|
||||
|
||||
since = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
console.print(f"[cyan]Processing unread emails in {mailbox} since {since.strftime('%Y-%m-%d')}[/cyan]")
|
||||
if settings.dry_run:
|
||||
console.print("[yellow]DRY RUN mode — no email actions will be taken[/yellow]")
|
||||
|
||||
async def _run():
|
||||
processed = 0
|
||||
skip_token = None
|
||||
while True:
|
||||
messages, skip_token = graph.list_unread_messages(
|
||||
mailbox, top=settings.graph_batch_size, skip_token=skip_token, received_after=since
|
||||
)
|
||||
for message in messages:
|
||||
await processor.process_message(message)
|
||||
processed += 1
|
||||
if not skip_token:
|
||||
break
|
||||
console.print(f"[green]Done — processed {processed} message(s).[/green]")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
@app.command()
|
||||
def purge(
|
||||
mailbox: str = typer.Option("", "--mailbox", "-m", help="Mailbox to purge (defaults to GRAPH_MAILBOX)"),
|
||||
) -> None:
|
||||
"""
|
||||
Delete emails from senders in the purge_rules table older than their configured threshold.
|
||||
Manage rules with: purge-add, purge-remove, purge-list.
|
||||
"""
|
||||
settings, graph, _ = _bootstrap()
|
||||
target_mailbox = mailbox or settings.graph_mailbox
|
||||
|
||||
async def _run():
|
||||
from howl.db import engine as db_engine
|
||||
from howl.db.models import PurgeRule
|
||||
from sqlalchemy import select
|
||||
|
||||
async with db_engine.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(PurgeRule).where(PurgeRule.is_active == True)
|
||||
)
|
||||
rules = list(result.scalars().all())
|
||||
|
||||
if not rules:
|
||||
console.print("[yellow]No active purge rules. Use 'purge-add' to add senders.[/yellow]")
|
||||
return
|
||||
|
||||
console.print(f"[cyan]Scanning {target_mailbox} for messages matching {len(rules)} rule(s)...[/cyan]")
|
||||
rule_pairs = [
|
||||
(rule.email_address, datetime.now(timezone.utc) - timedelta(days=rule.older_than_days))
|
||||
for rule in rules
|
||||
]
|
||||
to_delete = graph.collect_purgeable_messages(target_mailbox, rule_pairs)
|
||||
console.print(f"[dim]Found {len(to_delete)} message(s) to delete.[/dim]")
|
||||
|
||||
# Count per sender for the summary
|
||||
from collections import Counter
|
||||
counts: Counter = Counter()
|
||||
for msg in to_delete:
|
||||
counts[msg.sender_address.lower()] += 1
|
||||
graph.delete_message(target_mailbox, msg.id)
|
||||
|
||||
rule_by_addr = {r.email_address: r for r in rules}
|
||||
for addr, n in counts.most_common():
|
||||
rule = rule_by_addr.get(addr)
|
||||
label = (rule.display_name or addr) if rule else addr
|
||||
console.print(f" {label} <{addr}>: [red]{n} deleted[/red]")
|
||||
|
||||
if not counts:
|
||||
console.print("[green]Nothing to delete — all clean.[/green]")
|
||||
else:
|
||||
console.print(f"[green]Done — {sum(counts.values())} message(s) deleted total.[/green]")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
@app.command(name="purge-list")
|
||||
def purge_list() -> None:
|
||||
"""Show all purge rules."""
|
||||
settings, _, _ = _bootstrap()
|
||||
|
||||
async def _run():
|
||||
from howl.db import engine as db_engine
|
||||
from howl.db.models import PurgeRule
|
||||
from sqlalchemy import select
|
||||
from rich.table import Table
|
||||
|
||||
async with db_engine.async_session() as session:
|
||||
result = await session.execute(select(PurgeRule).order_by(PurgeRule.created_at))
|
||||
rules = list(result.scalars().all())
|
||||
|
||||
table = Table(title="Purge Rules")
|
||||
table.add_column("Sender", width=35)
|
||||
table.add_column("Display Name", width=25)
|
||||
table.add_column("Delete After", width=12)
|
||||
table.add_column("Active", width=6)
|
||||
table.add_column("Notes", width=30)
|
||||
for r in rules:
|
||||
table.add_row(
|
||||
r.email_address,
|
||||
r.display_name or "-",
|
||||
f"{r.older_than_days}d",
|
||||
"[green]yes[/green]" if r.is_active else "[dim]no[/dim]",
|
||||
r.notes or "-",
|
||||
)
|
||||
console.print(table)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
@app.command(name="purge-add")
|
||||
def purge_add(
|
||||
email: str = typer.Argument(..., help="Sender email address"),
|
||||
name: str = typer.Option("", "--name", "-n", help="Display name"),
|
||||
days: int = typer.Option(30, "--days", "-d", help="Delete emails older than N days"),
|
||||
notes: str = typer.Option("", "--notes", help="Optional notes"),
|
||||
) -> None:
|
||||
"""Add a sender to the purge rules."""
|
||||
settings, _, _ = _bootstrap()
|
||||
|
||||
async def _run():
|
||||
from howl.db import engine as db_engine
|
||||
from howl.db.models import PurgeRule
|
||||
|
||||
async with db_engine.async_session() as session:
|
||||
rule = PurgeRule(
|
||||
email_address=email.lower().strip(),
|
||||
display_name=name or None,
|
||||
older_than_days=days,
|
||||
notes=notes or None,
|
||||
)
|
||||
session.add(rule)
|
||||
console.print(f"[green]Added purge rule for {email} (>{days}d)[/green]")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
@app.command(name="purge-analyze")
|
||||
def purge_analyze(
|
||||
mailbox: str = typer.Option("lorentz@wulfconsulting.com", "--mailbox", "-m"),
|
||||
days: int = typer.Option(120, "--days", "-d", help="Look-back window in days"),
|
||||
min_count: int = typer.Option(2, "--min", help="Minimum email count to surface a sender"),
|
||||
) -> None:
|
||||
"""
|
||||
Scan the last N days of mail, rank high-volume/automated senders, and
|
||||
walk through them one at a time for purge-rule approval.
|
||||
"""
|
||||
import re
|
||||
from collections import defaultdict
|
||||
|
||||
settings, graph, _ = _bootstrap()
|
||||
since = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
|
||||
# --- fetch all messages in range ---
|
||||
console.print(f"[cyan]Scanning {mailbox} for the last {days} days...[/cyan]")
|
||||
sender_counts: dict[str, int] = defaultdict(int)
|
||||
sender_names: dict[str, str] = {}
|
||||
sender_subjects: dict[str, list[str]] = defaultdict(list)
|
||||
|
||||
total = 0
|
||||
for msg in graph.iter_messages_in_range(mailbox, since):
|
||||
addr = msg.sender_address.lower()
|
||||
sender_counts[addr] += 1
|
||||
if addr not in sender_names and msg.sender_name:
|
||||
sender_names[addr] = msg.sender_name
|
||||
if msg.subject and len(sender_subjects[addr]) < 3:
|
||||
sender_subjects[addr].append(msg.subject)
|
||||
total += 1
|
||||
if total % 100 == 0:
|
||||
console.print(f"[dim] ...{total} messages scanned[/dim]")
|
||||
|
||||
console.print(f"[dim]Scanned {total} messages from {len(sender_counts)} unique senders.[/dim]\n")
|
||||
|
||||
# --- load exclusions: already purged + known customers/vendors ---
|
||||
async def _load_exclusions() -> set[str]:
|
||||
from howl.db import engine as db_engine
|
||||
from howl.db.models import CustomerEmail, PurgeRule, VendorEmail
|
||||
from sqlalchemy import select
|
||||
|
||||
excluded: set[str] = set()
|
||||
async with db_engine.async_session() as session:
|
||||
for model in (PurgeRule, CustomerEmail, VendorEmail):
|
||||
result = await session.execute(select(model.email_address))
|
||||
for (addr,) in result:
|
||||
excluded.add(addr.lower())
|
||||
return excluded
|
||||
|
||||
excluded = asyncio.run(_load_exclusions())
|
||||
|
||||
# --- score and rank candidates ---
|
||||
_AUTO_PATTERNS = re.compile(
|
||||
r"(no.?reply|noreply|alert|notification|notification|mailer|postmaster|"
|
||||
r"donotreply|do-not-reply|bounce|automated|info@|newsletter|digest|"
|
||||
r"updates@|support@|billing@|invoice|statement|confirm|accounts@|"
|
||||
r"lockbox|daemon|robot|system|admin@)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
def score(addr: str, count: int) -> int:
|
||||
s = count * 10
|
||||
if _AUTO_PATTERNS.search(addr):
|
||||
s += 50
|
||||
return s
|
||||
|
||||
candidates = [
|
||||
(addr, count)
|
||||
for addr, count in sender_counts.items()
|
||||
if addr not in excluded and count >= min_count
|
||||
]
|
||||
candidates.sort(key=lambda x: score(x[0], x[1]), reverse=True)
|
||||
|
||||
if not candidates:
|
||||
console.print("[green]No new candidates found.[/green]")
|
||||
return
|
||||
|
||||
console.print(f"Found [bold]{len(candidates)}[/bold] candidates. Reviewing top senders — y/n/q to quit.\n")
|
||||
|
||||
# Collect approvals synchronously, insert all at the end in one async pass
|
||||
approved: list[tuple[str, Optional[str]]] = []
|
||||
|
||||
for addr, count in candidates:
|
||||
name = sender_names.get(addr, "")
|
||||
subjects = sender_subjects.get(addr, [])
|
||||
console.print(f"[bold]{name or addr}[/bold] [dim]<{addr}>[/dim]")
|
||||
console.print(f" {count} emails in last {days} days")
|
||||
for s in subjects:
|
||||
console.print(f" [dim]· {s[:80]}[/dim]")
|
||||
|
||||
answer = typer.prompt(" Add to purge list? [y/n/q]", default="n").strip().lower()
|
||||
if answer == "q":
|
||||
console.print("[dim]Stopped.[/dim]")
|
||||
break
|
||||
elif answer == "y":
|
||||
approved.append((addr, name or None))
|
||||
console.print(f" [green]Queued.[/green]")
|
||||
console.print()
|
||||
|
||||
if approved:
|
||||
async def _insert_approved() -> None:
|
||||
from howl.config import get_settings as _gs
|
||||
from howl.db.engine import init_engine as _init
|
||||
from howl.db.models import PurgeRule
|
||||
from howl.db import engine as db_engine
|
||||
|
||||
_init(_gs())
|
||||
async with db_engine.async_session() as session:
|
||||
for addr, name in approved:
|
||||
session.add(PurgeRule(
|
||||
email_address=addr,
|
||||
display_name=name,
|
||||
older_than_days=30,
|
||||
))
|
||||
|
||||
asyncio.run(_insert_approved())
|
||||
console.print(f"[green]Added {len(approved)} rule(s). Run 'purge' to action them.[/green]")
|
||||
else:
|
||||
console.print("[dim]Nothing added.[/dim]")
|
||||
|
||||
|
||||
@app.command(name="purge-remove")
|
||||
def purge_remove(
|
||||
email: str = typer.Argument(..., help="Sender email address to remove"),
|
||||
) -> None:
|
||||
"""Deactivate a purge rule (does not delete the row)."""
|
||||
settings, _, _ = _bootstrap()
|
||||
|
||||
async def _run():
|
||||
from howl.db import engine as db_engine
|
||||
from howl.db.models import PurgeRule
|
||||
from sqlalchemy import select, update
|
||||
|
||||
async with db_engine.async_session() as session:
|
||||
await session.execute(
|
||||
update(PurgeRule)
|
||||
.where(PurgeRule.email_address == email.lower().strip())
|
||||
.values(is_active=False)
|
||||
)
|
||||
console.print(f"[yellow]Deactivated purge rule for {email}[/yellow]")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
@app.command()
|
||||
def status(
|
||||
limit: int = typer.Option(20, "--limit", "-n", help="Number of recent log entries to show"),
|
||||
|
|
@ -120,5 +420,35 @@ def status(
|
|||
asyncio.run(_run())
|
||||
|
||||
|
||||
@app.command()
|
||||
def web(
|
||||
host: Optional[str] = typer.Option(None, "--host", help="Bind host (default: WEB_HOST from config)"),
|
||||
port: Optional[int] = typer.Option(None, "--port", help="Bind port (default: WEB_PORT from config)"),
|
||||
reload: bool = typer.Option(False, "--reload", help="Enable auto-reload (dev mode)"),
|
||||
) -> None:
|
||||
"""Start the Howl web UI."""
|
||||
from howl.config import get_settings
|
||||
|
||||
import uvicorn
|
||||
|
||||
settings = get_settings()
|
||||
bind_host = host or settings.web_host
|
||||
bind_port = port or settings.web_port
|
||||
|
||||
console.print(f"[cyan]Starting Howl web UI on http://{bind_host}:{bind_port}[/cyan]")
|
||||
|
||||
if reload:
|
||||
uvicorn.run(
|
||||
"howl.web.app:create_app",
|
||||
factory=True,
|
||||
host=bind_host,
|
||||
port=bind_port,
|
||||
reload=True,
|
||||
)
|
||||
else:
|
||||
from howl.web.app import create_app
|
||||
uvicorn.run(create_app(), host=bind_host, port=bind_port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
|
|
|
|||
|
|
@ -46,6 +46,10 @@ class ActionExecutor:
|
|||
folder_id = self._graph.get_or_create_folder(mailbox, self._settings.folder_spam)
|
||||
self._graph.move_message(mailbox, message_id, folder_id)
|
||||
|
||||
elif final_action == "move_infosec":
|
||||
folder_id = self._graph.get_or_create_folder(mailbox, self._settings.folder_infosec)
|
||||
self._graph.move_message(mailbox, message_id, folder_id)
|
||||
|
||||
elif final_action == "move_review":
|
||||
folder_id = self._graph.get_or_create_folder(mailbox, self._settings.folder_review)
|
||||
self._graph.move_message(mailbox, message_id, folder_id)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,78 @@ def decide(
|
|||
final_action = llm_action
|
||||
overridden = False
|
||||
|
||||
# Rule 1: Whitelist senders are never moved to spam/junk.
|
||||
# Rule 1a: Vendor invoices are always flagged for follow-up.
|
||||
if classification.classification == "vendor_invoice" and final_action not in (
|
||||
"escalate",
|
||||
"move_review",
|
||||
):
|
||||
if final_action != "flag_follow_up":
|
||||
final_action = "flag_follow_up"
|
||||
overridden = True
|
||||
log.info(
|
||||
"classifier_override",
|
||||
rule="vendor_invoice_flag_follow_up",
|
||||
original_action=llm_action,
|
||||
final_action=final_action,
|
||||
)
|
||||
|
||||
# Rule 1b: Infosec advisories always go to the Infosec folder.
|
||||
elif classification.classification == "infosec_advisory":
|
||||
if final_action != "move_infosec":
|
||||
final_action = "move_infosec"
|
||||
overridden = True
|
||||
log.info(
|
||||
"classifier_override",
|
||||
rule="infosec_advisory_to_folder",
|
||||
original_action=llm_action,
|
||||
final_action=final_action,
|
||||
)
|
||||
|
||||
# Rule 1c: Vendor marketing always goes to the vendor folder.
|
||||
elif classification.classification == "vendor_marketing":
|
||||
if final_action != "move_vendor":
|
||||
final_action = "move_vendor"
|
||||
overridden = True
|
||||
log.info(
|
||||
"classifier_override",
|
||||
rule="vendor_marketing_to_folder",
|
||||
original_action=llm_action,
|
||||
final_action=final_action,
|
||||
)
|
||||
|
||||
# Rule 1d: Vendor events always go to review.
|
||||
elif classification.classification == "vendor_event":
|
||||
if final_action != "move_review":
|
||||
final_action = "move_review"
|
||||
overridden = True
|
||||
log.info(
|
||||
"classifier_override",
|
||||
rule="vendor_event_to_review",
|
||||
original_action=llm_action,
|
||||
final_action=final_action,
|
||||
)
|
||||
|
||||
# Rule 1e: All other customer and vendor emails stay in the inbox.
|
||||
elif sender_match.sender_type == "customer" and final_action == "move_customer":
|
||||
final_action = "inbox_keep"
|
||||
overridden = True
|
||||
log.info(
|
||||
"classifier_override",
|
||||
rule="customer_stays_in_inbox",
|
||||
original_action=llm_action,
|
||||
final_action=final_action,
|
||||
)
|
||||
elif sender_match.sender_type == "vendor" and final_action == "move_vendor":
|
||||
final_action = "inbox_keep"
|
||||
overridden = True
|
||||
log.info(
|
||||
"classifier_override",
|
||||
rule="vendor_stays_in_inbox",
|
||||
original_action=llm_action,
|
||||
final_action=final_action,
|
||||
)
|
||||
|
||||
# Rule 2: Whitelist senders are never moved to spam/junk.
|
||||
if sender_match.sender_type == "whitelist" and llm_action in ("move_spam",):
|
||||
final_action = "inbox_keep"
|
||||
overridden = True
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
|
||||
from howl.config import Settings
|
||||
|
|
@ -60,19 +61,35 @@ class EmailProcessor:
|
|||
# DB sender lookup
|
||||
async with db_engine.async_session() as session:
|
||||
sender_match = await queries.lookup_sender(session, message.sender_address)
|
||||
sender_profile = await queries.lookup_sender_profile(session, message.sender_address)
|
||||
|
||||
log.info(
|
||||
"sender_matched",
|
||||
sender_type=sender_match.sender_type,
|
||||
entity=sender_match.entity_name,
|
||||
has_profile=sender_profile is not None,
|
||||
message_id=message.id,
|
||||
)
|
||||
|
||||
# Fetch full email body
|
||||
try:
|
||||
body = self._graph.get_message_body(self._settings.graph_mailbox, message.id)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code == 404:
|
||||
log.warning("message_not_found_skipping", message_id=message.id)
|
||||
async with db_engine.async_session() as session:
|
||||
await queries.update_email_log(
|
||||
session,
|
||||
log_entry.id,
|
||||
status="skipped",
|
||||
error_message="message_not_found",
|
||||
processing_completed_at=datetime.now(timezone.utc),
|
||||
)
|
||||
return
|
||||
raise
|
||||
|
||||
# LLM classification
|
||||
classification, raw_response = self._llm.classify(message, body, sender_match)
|
||||
classification, raw_response = self._llm.classify(message, body, sender_match, sender_profile)
|
||||
|
||||
# Business rule override
|
||||
final_action, overridden = classifier.decide(
|
||||
|
|
@ -102,8 +119,10 @@ class EmailProcessor:
|
|||
# Execute action
|
||||
self._executor.execute(log_entry, final_action)
|
||||
|
||||
# Mark as read (no-op in dry_run — action executor already skipped)
|
||||
if not self._settings.dry_run:
|
||||
# Mark as read only when the message wasn't moved — after a move the
|
||||
# shorthand /messages/{id} URL returns 404 on the original mailbox path.
|
||||
_moves = {"move_customer", "move_vendor", "move_spam", "move_review", "move_infosec", "escalate"}
|
||||
if not self._settings.dry_run and final_action not in _moves:
|
||||
self._graph.mark_as_read(self._settings.graph_mailbox, message.id)
|
||||
|
||||
# Mark log as completed
|
||||
|
|
@ -136,7 +155,7 @@ class EmailProcessor:
|
|||
retry_count=log_entry.retry_count + 1,
|
||||
)
|
||||
|
||||
async def poll_once(self) -> int:
|
||||
async def poll_once(self, received_after: datetime | None = None) -> int:
|
||||
"""
|
||||
Fetch one batch of unread messages and process each one.
|
||||
Returns the number of messages processed.
|
||||
|
|
@ -148,7 +167,7 @@ class EmailProcessor:
|
|||
|
||||
while True:
|
||||
messages, skip_token = self._graph.list_unread_messages(
|
||||
mailbox, top=batch_size, skip_token=skip_token
|
||||
mailbox, top=batch_size, skip_token=skip_token, received_after=received_after
|
||||
)
|
||||
for message in messages:
|
||||
await self.process_message(message)
|
||||
|
|
@ -190,8 +209,10 @@ class EmailProcessor:
|
|||
"""Re-run pipeline for a failed message, updating existing log row."""
|
||||
try:
|
||||
sender_match_result = None
|
||||
sender_profile = None
|
||||
async with db_engine.async_session() as session:
|
||||
sender_match_result = await queries.lookup_sender(session, message.sender_address)
|
||||
sender_profile = await queries.lookup_sender_profile(session, message.sender_address)
|
||||
await queries.update_email_log(
|
||||
session, log_entry.id,
|
||||
status="processing",
|
||||
|
|
@ -199,11 +220,12 @@ class EmailProcessor:
|
|||
)
|
||||
|
||||
body = self._graph.get_message_body(self._settings.graph_mailbox, message.id)
|
||||
classification, raw_response = self._llm.classify(message, body, sender_match_result)
|
||||
classification, raw_response = self._llm.classify(message, body, sender_match_result, sender_profile)
|
||||
final_action, overridden = classifier.decide(sender_match_result, classification, self._settings)
|
||||
self._executor.execute(log_entry, final_action)
|
||||
|
||||
if not self._settings.dry_run:
|
||||
_moves = {"move_customer", "move_vendor", "move_spam", "move_review", "move_infosec", "escalate"}
|
||||
if not self._settings.dry_run and final_action not in _moves:
|
||||
self._graph.mark_as_read(self._settings.graph_mailbox, message.id)
|
||||
|
||||
async with db_engine.async_session() as session:
|
||||
|
|
|
|||
0
howl/web/__init__.py
Normal file
0
howl/web/__init__.py
Normal file
40
howl/web/app.py
Normal file
40
howl/web/app.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from howl.web.deps import templates # noqa: F401 — registers template dir
|
||||
from howl.web.routes import analysis, dashboard, fetch, log, purge, runner, senders
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(app: FastAPI):
|
||||
from howl.config import get_settings
|
||||
from howl.db.engine import dispose_engine, init_engine
|
||||
|
||||
settings = get_settings()
|
||||
init_engine(settings)
|
||||
yield
|
||||
await dispose_engine()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title="Howl", lifespan=_lifespan)
|
||||
|
||||
STATIC_DIR.mkdir(exist_ok=True)
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(fetch.router)
|
||||
app.include_router(purge.router)
|
||||
app.include_router(log.router)
|
||||
app.include_router(analysis.router)
|
||||
app.include_router(runner.router)
|
||||
app.include_router(senders.router)
|
||||
|
||||
return app
|
||||
78
howl/web/deps.py
Normal file
78
howl/web/deps.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from howl.db.engine import async_session
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||
|
||||
# Add tojson filter for use in templates (e.g. raw LLM response display)
|
||||
templates.env.filters["tojson"] = lambda v, indent=None: json.dumps(v, indent=indent, default=str)
|
||||
|
||||
# Human-readable labels for display
|
||||
ACTION_LABELS: dict[str, str] = {
|
||||
"inbox_keep": "Keep in Inbox",
|
||||
"flag_follow_up": "Flagged",
|
||||
"move_customer": "→ Customers",
|
||||
"move_vendor": "→ Vendors",
|
||||
"move_whitelist": "→ Whitelist",
|
||||
"move_infosec": "→ Infosec",
|
||||
"move_spam": "→ Spam",
|
||||
"move_review": "→ Needs Review",
|
||||
"escalate": "Escalated",
|
||||
}
|
||||
|
||||
CLASSIFICATION_LABELS: dict[str, str] = {
|
||||
"customer_inquiry": "Customer Inquiry",
|
||||
"vendor_invoice": "Vendor Invoice",
|
||||
"vendor_notification": "Vendor Notification",
|
||||
"vendor_marketing": "Vendor Marketing",
|
||||
"vendor_event": "Vendor Event",
|
||||
"infosec_advisory": "Infosec Advisory",
|
||||
"newsletter": "Newsletter",
|
||||
"spam": "Spam",
|
||||
"phishing": "Phishing",
|
||||
"support_request": "Support Request",
|
||||
"urgent_action_required": "Urgent Action",
|
||||
"unknown": "Unknown",
|
||||
}
|
||||
|
||||
SENDER_TYPE_LABELS: dict[str, str] = {
|
||||
"customer": "Customer",
|
||||
"vendor": "Vendor",
|
||||
"whitelist": "Whitelist",
|
||||
"unknown": "Unknown",
|
||||
}
|
||||
|
||||
STATUS_LABELS: dict[str, str] = {
|
||||
"pending": "Pending",
|
||||
"processing": "Processing",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed",
|
||||
"skipped": "Skipped",
|
||||
}
|
||||
|
||||
|
||||
EMAIL_TYPE_OPTIONS: list[tuple[str, str]] = [
|
||||
("invoice", "Invoice / Bill"),
|
||||
("statement", "Account Statement"),
|
||||
("notification", "Notification / Confirmation"),
|
||||
("shipping", "Shipping Update"),
|
||||
("marketing", "Marketing / Promotional"),
|
||||
("newsletter", "Newsletter / Digest"),
|
||||
("report", "Report / Export"),
|
||||
("support", "Support Communication"),
|
||||
("event", "Event / Webinar Invite"),
|
||||
("other", "Other"),
|
||||
]
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
0
howl/web/routes/__init__.py
Normal file
0
howl/web/routes/__init__.py
Normal file
472
howl/web/routes/analysis.py
Normal file
472
howl/web/routes/analysis.py
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from howl.db.models import CustomerEmail, EmailLog, PurgeRule, SenderProfile, VendorEmail, Whitelist
|
||||
from howl.web.deps import (
|
||||
CLASSIFICATION_LABELS,
|
||||
EMAIL_TYPE_OPTIONS,
|
||||
SENDER_TYPE_LABELS,
|
||||
get_db,
|
||||
templates,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/analysis")
|
||||
|
||||
PRESETS = {"7": 7, "14": 14, "30": 30, "60": 60, "90": 90, "180": 180, "365": 365}
|
||||
_SENDER_TYPES = ["customer", "vendor", "whitelist", "unknown"]
|
||||
|
||||
|
||||
def _available_mailboxes() -> list[str]:
|
||||
"""Return the list of mailboxes available for scanning."""
|
||||
from howl.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
if settings.graph_mailboxes:
|
||||
return [m.strip() for m in settings.graph_mailboxes.split(",") if m.strip()]
|
||||
return [settings.graph_mailbox]
|
||||
|
||||
|
||||
def _resolve_lookback(days: Optional[str], custom_days: Optional[str]) -> tuple[int, str]:
|
||||
custom_days_int = int(custom_days) if custom_days and custom_days.strip().isdigit() else None
|
||||
if days == "custom" and custom_days_int:
|
||||
return max(1, min(custom_days_int, 3650)), "custom"
|
||||
if days in PRESETS:
|
||||
return PRESETS[days], days
|
||||
return 180, "180"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _enrich_senders(
|
||||
raw_senders: list[dict],
|
||||
db: AsyncSession,
|
||||
) -> list[dict]:
|
||||
"""Cross-reference sender data with DB tables for type, purge, and profile status."""
|
||||
all_addresses = [s["address"].lower() for s in raw_senders]
|
||||
|
||||
customer_result = await db.execute(
|
||||
select(CustomerEmail.email_address).where(
|
||||
func.lower(CustomerEmail.email_address).in_(all_addresses)
|
||||
)
|
||||
)
|
||||
customer_addrs = {r[0].lower() for r in customer_result}
|
||||
|
||||
vendor_result = await db.execute(
|
||||
select(VendorEmail.email_address).where(
|
||||
func.lower(VendorEmail.email_address).in_(all_addresses)
|
||||
)
|
||||
)
|
||||
vendor_addrs = {r[0].lower() for r in vendor_result}
|
||||
|
||||
whitelist_result = await db.execute(
|
||||
select(Whitelist.email_address).where(
|
||||
Whitelist.email_address.isnot(None),
|
||||
func.lower(Whitelist.email_address).in_(all_addresses),
|
||||
Whitelist.is_active == True,
|
||||
)
|
||||
)
|
||||
whitelist_addrs = {r[0].lower() for r in whitelist_result}
|
||||
|
||||
purge_result = await db.execute(
|
||||
select(PurgeRule.email_address).where(PurgeRule.is_active == True)
|
||||
)
|
||||
purge_addrs = {r[0].lower() for r in purge_result}
|
||||
|
||||
profile_result = await db.execute(
|
||||
select(SenderProfile.email_address).where(SenderProfile.is_active == True)
|
||||
)
|
||||
profile_addrs = {r[0].lower() for r in profile_result}
|
||||
|
||||
def _sender_type(addr: str) -> str:
|
||||
a = addr.lower()
|
||||
if a in customer_addrs:
|
||||
return "customer"
|
||||
if a in vendor_addrs:
|
||||
return "vendor"
|
||||
if a in whitelist_addrs:
|
||||
return "whitelist"
|
||||
return "unknown"
|
||||
|
||||
return [
|
||||
{
|
||||
"address": s["address"],
|
||||
"name": s["name"],
|
||||
"email_count": s["count"],
|
||||
"first_seen": s["first_seen"],
|
||||
"last_seen": s["last_seen"],
|
||||
"sender_type": _sender_type(s["address"]),
|
||||
"sender_type_label": SENDER_TYPE_LABELS.get(_sender_type(s["address"]), "Unknown"),
|
||||
"has_purge_rule": s["address"].lower() in purge_addrs,
|
||||
"has_profile": s["address"].lower() in profile_addrs,
|
||||
}
|
||||
for s in raw_senders
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page shell — loads instantly, live scan triggered by htmx
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def analysis_page(
|
||||
request: Request,
|
||||
days: Optional[str] = Query(None),
|
||||
custom_days: Optional[str] = Query(None),
|
||||
tab: str = Query("analysis"),
|
||||
mailbox: Optional[str] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# Default period: 180d for analysis, 30d for scanner
|
||||
if days is None:
|
||||
days = "30" if tab == "scanner" else "180"
|
||||
lookback, days_param = _resolve_lookback(days, custom_days)
|
||||
since = datetime.now(timezone.utc) - timedelta(days=lookback)
|
||||
|
||||
mailboxes = _available_mailboxes()
|
||||
selected_mailbox = mailbox if mailbox in mailboxes else mailboxes[0]
|
||||
|
||||
# Chart data only needed for the analysis tab
|
||||
classification_data: list[dict] = []
|
||||
daily_rows: list = []
|
||||
|
||||
if tab == "analysis":
|
||||
cls_result = await db.execute(
|
||||
select(EmailLog.llm_classification, func.count().label("n"))
|
||||
.where(EmailLog.llm_classification.isnot(None), EmailLog.received_at >= since)
|
||||
.group_by(EmailLog.llm_classification)
|
||||
.order_by(func.count().desc())
|
||||
)
|
||||
classification_data = [
|
||||
{
|
||||
"label": CLASSIFICATION_LABELS.get(r.llm_classification, r.llm_classification),
|
||||
"count": r.n,
|
||||
}
|
||||
for r in cls_result
|
||||
]
|
||||
|
||||
daily_result = await db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT DATE(received_at AT TIME ZONE 'UTC') AS day, COUNT(*) AS n
|
||||
FROM email_log
|
||||
WHERE received_at >= :since
|
||||
GROUP BY day ORDER BY day
|
||||
"""
|
||||
),
|
||||
{"since": since},
|
||||
)
|
||||
daily_rows = daily_result.all()
|
||||
|
||||
resp = templates.TemplateResponse(
|
||||
request,
|
||||
"analysis.html",
|
||||
{
|
||||
"active_page": "analysis",
|
||||
"active_tab": tab,
|
||||
"lookback_days": lookback,
|
||||
"lookback_param": days_param,
|
||||
"custom_days": custom_days,
|
||||
"since": since,
|
||||
"classification_labels_json": json.dumps([d["label"] for d in classification_data]),
|
||||
"classification_counts_json": json.dumps([d["count"] for d in classification_data]),
|
||||
"daily_labels_json": json.dumps([str(r.day) for r in daily_rows]),
|
||||
"daily_counts_json": json.dumps([r.n for r in daily_rows]),
|
||||
"mailboxes": mailboxes,
|
||||
"selected_mailbox": selected_mailbox,
|
||||
},
|
||||
)
|
||||
resp.headers["Cache-Control"] = "no-store"
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Live scan — queries Graph API directly, returns table fragment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/live", response_class=HTMLResponse)
|
||||
async def analysis_live(
|
||||
request: Request,
|
||||
days: Optional[str] = Query("180"),
|
||||
custom_days: Optional[str] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
lookback, _ = _resolve_lookback(days, custom_days)
|
||||
since = datetime.now(timezone.utc) - timedelta(days=lookback)
|
||||
|
||||
try:
|
||||
raw_senders = await asyncio.to_thread(_scan_mailbox, since)
|
||||
except Exception as exc:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"analysis_live_error.html",
|
||||
{"error": str(exc)},
|
||||
)
|
||||
|
||||
if not raw_senders:
|
||||
return HTMLResponse(
|
||||
'<tr><td colspan="6" class="text-center text-base-content/50 py-10">'
|
||||
"No messages found in this period.</td></tr>"
|
||||
)
|
||||
|
||||
senders = await _enrich_senders(raw_senders, db)
|
||||
|
||||
resp = templates.TemplateResponse(
|
||||
request,
|
||||
"analysis_live_rows.html",
|
||||
{"senders": senders},
|
||||
)
|
||||
resp.headers["Cache-Control"] = "no-store"
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scanner — individual messages in descending chronological order
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/scanner", response_class=HTMLResponse)
|
||||
async def scanner_live(
|
||||
request: Request,
|
||||
days: Optional[str] = Query("30"),
|
||||
custom_days: Optional[str] = Query(None),
|
||||
mailbox: Optional[str] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
lookback, _ = _resolve_lookback(days, custom_days)
|
||||
since = datetime.now(timezone.utc) - timedelta(days=lookback)
|
||||
|
||||
mailboxes = _available_mailboxes()
|
||||
selected_mailbox = mailbox if mailbox in mailboxes else mailboxes[0]
|
||||
|
||||
try:
|
||||
raw_messages = await asyncio.to_thread(_scan_mailbox_messages, since, selected_mailbox)
|
||||
except Exception as exc:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"analysis_live_error.html",
|
||||
{"error": str(exc)},
|
||||
)
|
||||
|
||||
if not raw_messages:
|
||||
return HTMLResponse(
|
||||
'<tr><td colspan="5" class="text-center text-base-content/50 py-10">'
|
||||
"No messages found in this period.</td></tr>"
|
||||
)
|
||||
|
||||
# Enrich with sender profile/type info
|
||||
all_addresses = list({m["sender_address"].lower() for m in raw_messages})
|
||||
|
||||
customer_result = await db.execute(
|
||||
select(CustomerEmail.email_address).where(
|
||||
func.lower(CustomerEmail.email_address).in_(all_addresses)
|
||||
)
|
||||
)
|
||||
customer_addrs = {r[0].lower() for r in customer_result}
|
||||
|
||||
vendor_result = await db.execute(
|
||||
select(VendorEmail.email_address).where(
|
||||
func.lower(VendorEmail.email_address).in_(all_addresses)
|
||||
)
|
||||
)
|
||||
vendor_addrs = {r[0].lower() for r in vendor_result}
|
||||
|
||||
whitelist_result = await db.execute(
|
||||
select(Whitelist.email_address).where(
|
||||
Whitelist.email_address.isnot(None),
|
||||
func.lower(Whitelist.email_address).in_(all_addresses),
|
||||
Whitelist.is_active == True,
|
||||
)
|
||||
)
|
||||
whitelist_addrs = {r[0].lower() for r in whitelist_result}
|
||||
|
||||
profile_result = await db.execute(
|
||||
select(SenderProfile.email_address).where(SenderProfile.is_active == True)
|
||||
)
|
||||
profile_addrs = {r[0].lower() for r in profile_result}
|
||||
|
||||
def _sender_type(addr: str) -> str:
|
||||
a = addr.lower()
|
||||
if a in customer_addrs:
|
||||
return "customer"
|
||||
if a in vendor_addrs:
|
||||
return "vendor"
|
||||
if a in whitelist_addrs:
|
||||
return "whitelist"
|
||||
return "unknown"
|
||||
|
||||
messages = [
|
||||
{
|
||||
**m,
|
||||
"sender_type": _sender_type(m["sender_address"]),
|
||||
"sender_type_label": SENDER_TYPE_LABELS.get(_sender_type(m["sender_address"]), "Unknown"),
|
||||
"has_profile": m["sender_address"].lower() in profile_addrs,
|
||||
}
|
||||
for m in raw_messages
|
||||
]
|
||||
|
||||
resp = templates.TemplateResponse(
|
||||
request,
|
||||
"analysis_scanner_rows.html",
|
||||
{"messages": messages, "mailbox": selected_mailbox},
|
||||
)
|
||||
resp.headers["Cache-Control"] = "no-store"
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLM profile recommendation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/recommend-profile", response_class=HTMLResponse)
|
||||
async def recommend_profile(
|
||||
request: Request,
|
||||
email: str = Query(...),
|
||||
name: str = Query(""),
|
||||
mailbox: Optional[str] = Query(None),
|
||||
):
|
||||
"""Fetch sample emails for a sender and ask Claude to recommend profile settings."""
|
||||
sender_types = _SENDER_TYPES
|
||||
form_ctx = {
|
||||
"profile": None,
|
||||
"prefill_email": email,
|
||||
"prefill_name": name,
|
||||
"sender_types": sender_types,
|
||||
"sender_type_labels": SENDER_TYPE_LABELS,
|
||||
"email_type_options": EMAIL_TYPE_OPTIONS,
|
||||
"prefill_sender_type": None,
|
||||
"prefill_email_types": None,
|
||||
"prefill_processing_instructions": None,
|
||||
"prefill_reasoning": None,
|
||||
}
|
||||
|
||||
try:
|
||||
recommendation = await asyncio.to_thread(_fetch_and_recommend, email, name, mailbox)
|
||||
except Exception as exc:
|
||||
form_ctx["prefill_error"] = f"AI recommendation failed: {exc}. Fill in the profile manually."
|
||||
return templates.TemplateResponse(request, "sender_profile_form.html", form_ctx)
|
||||
|
||||
if recommendation is None:
|
||||
form_ctx["prefill_error"] = "No emails found for this sender. Fill in the profile manually."
|
||||
return templates.TemplateResponse(request, "sender_profile_form.html", form_ctx)
|
||||
|
||||
form_ctx.update({
|
||||
"prefill_sender_type": recommendation.sender_type,
|
||||
"prefill_email_types": recommendation.email_types,
|
||||
"prefill_processing_instructions": recommendation.processing_instructions,
|
||||
"prefill_reasoning": recommendation.reasoning,
|
||||
})
|
||||
return templates.TemplateResponse(request, "sender_profile_form.html", form_ctx)
|
||||
|
||||
|
||||
def _fetch_and_recommend(email: str, name: str, mailbox: str | None = None):
|
||||
"""Synchronous helper: fetch sample emails from Graph, send to LLM."""
|
||||
from howl.config import get_settings
|
||||
from howl.graph.auth import build_token_provider
|
||||
from howl.graph.client import GraphClient
|
||||
from howl.llm.client import LLMClient
|
||||
|
||||
settings = get_settings()
|
||||
token_provider = build_token_provider(settings)
|
||||
graph = GraphClient(token_provider)
|
||||
target = mailbox or settings.graph_mailbox
|
||||
|
||||
try:
|
||||
messages = graph.get_messages_from_sender(target, email, limit=5)
|
||||
if not messages:
|
||||
return None
|
||||
|
||||
sample_emails = []
|
||||
for msg in messages:
|
||||
body = graph.get_message_body(target, msg.id)
|
||||
sample_emails.append((msg.subject or "", body, msg.received_at, msg.has_attachments))
|
||||
|
||||
llm = LLMClient(settings)
|
||||
recommendation, _ = llm.recommend_profile(email, name, sample_emails)
|
||||
return recommendation
|
||||
finally:
|
||||
graph.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Synchronous Graph scan (runs in thread via asyncio.to_thread)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _scan_mailbox(since: datetime) -> list[dict]:
|
||||
from howl.config import get_settings
|
||||
from howl.graph.auth import build_token_provider
|
||||
from howl.graph.client import GraphClient
|
||||
|
||||
settings = get_settings()
|
||||
token_provider = build_token_provider(settings)
|
||||
graph = GraphClient(token_provider)
|
||||
|
||||
counts: dict[str, int] = defaultdict(int)
|
||||
names: dict[str, str] = {}
|
||||
first_seen: dict[str, datetime] = {}
|
||||
last_seen: dict[str, datetime] = {}
|
||||
|
||||
try:
|
||||
for msg in graph.iter_messages_in_range(settings.graph_mailbox, since):
|
||||
addr = msg.sender_address.lower()
|
||||
counts[addr] += 1
|
||||
if addr not in names and msg.sender_name:
|
||||
names[addr] = msg.sender_name
|
||||
if msg.received_at:
|
||||
if addr not in first_seen or msg.received_at < first_seen[addr]:
|
||||
first_seen[addr] = msg.received_at
|
||||
if addr not in last_seen or msg.received_at > last_seen[addr]:
|
||||
last_seen[addr] = msg.received_at
|
||||
finally:
|
||||
graph.close()
|
||||
|
||||
return sorted(
|
||||
[
|
||||
{
|
||||
"address": addr,
|
||||
"name": names.get(addr, ""),
|
||||
"count": count,
|
||||
"first_seen": first_seen.get(addr),
|
||||
"last_seen": last_seen.get(addr),
|
||||
}
|
||||
for addr, count in counts.items()
|
||||
],
|
||||
key=lambda x: -x["count"],
|
||||
)
|
||||
|
||||
|
||||
def _scan_mailbox_messages(since: datetime, mailbox: str | None = None) -> list[dict]:
|
||||
"""Return individual messages (most recent first) from Graph API."""
|
||||
from howl.config import get_settings
|
||||
from howl.graph.auth import build_token_provider
|
||||
from howl.graph.client import GraphClient
|
||||
|
||||
settings = get_settings()
|
||||
token_provider = build_token_provider(settings)
|
||||
graph = GraphClient(token_provider)
|
||||
target = mailbox or settings.graph_mailbox
|
||||
|
||||
messages: list[dict] = []
|
||||
try:
|
||||
for msg in graph.iter_messages_in_range(target, since):
|
||||
messages.append({
|
||||
"sender_address": msg.sender_address,
|
||||
"sender_name": msg.sender_name or "",
|
||||
"subject": msg.subject or "(no subject)",
|
||||
"received_at": msg.received_at,
|
||||
"has_attachments": msg.has_attachments,
|
||||
})
|
||||
finally:
|
||||
graph.close()
|
||||
|
||||
return messages
|
||||
92
howl/web/routes/dashboard.py
Normal file
92
howl/web/routes/dashboard.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from howl.db.models import EmailLog
|
||||
from howl.web.deps import ACTION_LABELS, get_db, templates
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_start = now - timedelta(days=7)
|
||||
|
||||
# Total counts
|
||||
total_all = await db.scalar(select(func.count()).select_from(EmailLog)) or 0
|
||||
total_today = (
|
||||
await db.scalar(
|
||||
select(func.count()).select_from(EmailLog).where(EmailLog.received_at >= today_start)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
total_week = (
|
||||
await db.scalar(
|
||||
select(func.count()).select_from(EmailLog).where(EmailLog.received_at >= week_start)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
# Failed count
|
||||
failed_count = (
|
||||
await db.scalar(
|
||||
select(func.count()).select_from(EmailLog).where(EmailLog.status == "failed")
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
# Average confidence
|
||||
avg_conf = await db.scalar(
|
||||
select(func.avg(EmailLog.llm_confidence)).where(EmailLog.llm_confidence.isnot(None))
|
||||
)
|
||||
avg_conf = round(float(avg_conf) * 100, 1) if avg_conf else None
|
||||
|
||||
# Action breakdown
|
||||
action_result = await db.execute(
|
||||
select(EmailLog.final_action, func.count().label("n"))
|
||||
.where(EmailLog.final_action.isnot(None))
|
||||
.group_by(EmailLog.final_action)
|
||||
.order_by(func.count().desc())
|
||||
)
|
||||
action_counts = [
|
||||
{"action": row.final_action, "label": ACTION_LABELS.get(row.final_action, row.final_action), "count": row.n}
|
||||
for row in action_result
|
||||
]
|
||||
|
||||
# Override rate
|
||||
override_count = (
|
||||
await db.scalar(
|
||||
select(func.count()).select_from(EmailLog).where(EmailLog.action_overridden == True)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
# Recent 10 log entries
|
||||
recent_result = await db.execute(
|
||||
select(EmailLog).order_by(EmailLog.received_at.desc().nullslast()).limit(10)
|
||||
)
|
||||
recent = list(recent_result.scalars())
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"dashboard.html",
|
||||
{
|
||||
"active_page": "dashboard",
|
||||
"total_all": total_all,
|
||||
"total_today": total_today,
|
||||
"total_week": total_week,
|
||||
"failed_count": failed_count,
|
||||
"avg_conf": avg_conf,
|
||||
"action_counts": action_counts,
|
||||
"override_count": override_count,
|
||||
"recent": recent,
|
||||
"action_labels": ACTION_LABELS,
|
||||
},
|
||||
)
|
||||
47
howl/web/routes/fetch.py
Normal file
47
howl/web/routes/fetch.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from howl.web.deps import templates
|
||||
|
||||
router = APIRouter(prefix="/fetch")
|
||||
|
||||
|
||||
@router.post("", response_class=HTMLResponse)
|
||||
async def fetch_mail(request: Request):
|
||||
since = datetime.now(timezone.utc) - timedelta(days=14)
|
||||
graph = None
|
||||
try:
|
||||
processor, graph = _build_processor()
|
||||
processed = await processor.poll_once(received_after=since)
|
||||
except Exception as exc:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"fetch_result.html",
|
||||
{"error": str(exc), "processed": 0},
|
||||
)
|
||||
finally:
|
||||
if graph is not None:
|
||||
graph.close()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"fetch_result.html",
|
||||
{"error": None, "processed": processed},
|
||||
)
|
||||
|
||||
|
||||
def _build_processor():
|
||||
from howl.config import get_settings
|
||||
from howl.graph.auth import build_token_provider
|
||||
from howl.graph.client import GraphClient
|
||||
from howl.llm.client import LLMClient
|
||||
from howl.pipeline.processor import EmailProcessor
|
||||
|
||||
settings = get_settings()
|
||||
token_provider = build_token_provider(settings)
|
||||
graph = GraphClient(token_provider)
|
||||
llm = LLMClient(settings)
|
||||
return EmailProcessor(graph, llm, settings), graph
|
||||
114
howl/web/routes/log.py
Normal file
114
howl/web/routes/log.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from howl.db.models import EmailLog
|
||||
from howl.db.queries import lookup_sender_profile
|
||||
from howl.web.deps import (
|
||||
ACTION_LABELS,
|
||||
CLASSIFICATION_LABELS,
|
||||
SENDER_TYPE_LABELS,
|
||||
STATUS_LABELS,
|
||||
get_db,
|
||||
templates,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/log")
|
||||
|
||||
PER_PAGE = 20
|
||||
|
||||
STATUSES = ["pending", "processing", "completed", "failed", "skipped"]
|
||||
SENDER_TYPES = ["customer", "vendor", "whitelist", "unknown"]
|
||||
CLASSIFICATIONS = list(CLASSIFICATION_LABELS.keys())
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def log_list(
|
||||
request: Request,
|
||||
page: int = Query(1, ge=1),
|
||||
status: Optional[str] = Query(None),
|
||||
sender_type: Optional[str] = Query(None),
|
||||
classification: Optional[str] = Query(None),
|
||||
q: Optional[str] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(EmailLog)
|
||||
|
||||
if status and status in STATUSES:
|
||||
stmt = stmt.where(EmailLog.status == status)
|
||||
if sender_type and sender_type in SENDER_TYPES:
|
||||
stmt = stmt.where(EmailLog.sender_type == sender_type)
|
||||
if classification and classification in CLASSIFICATIONS:
|
||||
stmt = stmt.where(EmailLog.llm_classification == classification)
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
stmt = stmt.where(
|
||||
EmailLog.sender_address.ilike(like) | EmailLog.subject.ilike(like)
|
||||
)
|
||||
|
||||
from sqlalchemy import func, select as _select
|
||||
|
||||
# Total count for pagination
|
||||
count_stmt = _select(func.count()).select_from(stmt.subquery())
|
||||
total = await db.scalar(count_stmt) or 0
|
||||
|
||||
stmt = stmt.order_by(EmailLog.received_at.desc().nullslast())
|
||||
stmt = stmt.offset((page - 1) * PER_PAGE).limit(PER_PAGE)
|
||||
result = await db.execute(stmt)
|
||||
entries = list(result.scalars())
|
||||
|
||||
total_pages = max(1, (total + PER_PAGE - 1) // PER_PAGE)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"log.html",
|
||||
{
|
||||
"active_page": "log",
|
||||
"entries": entries,
|
||||
"page": page,
|
||||
"total": total,
|
||||
"total_pages": total_pages,
|
||||
"per_page": PER_PAGE,
|
||||
"filter_status": status or "",
|
||||
"filter_sender_type": sender_type or "",
|
||||
"filter_classification": classification or "",
|
||||
"filter_q": q or "",
|
||||
"statuses": STATUSES,
|
||||
"sender_types": SENDER_TYPES,
|
||||
"classifications": CLASSIFICATIONS,
|
||||
"action_labels": ACTION_LABELS,
|
||||
"classification_labels": CLASSIFICATION_LABELS,
|
||||
"sender_type_labels": SENDER_TYPE_LABELS,
|
||||
"status_labels": STATUS_LABELS,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{entry_id}", response_class=HTMLResponse)
|
||||
async def log_detail(request: Request, entry_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
from howl.web.deps import EMAIL_TYPE_OPTIONS
|
||||
entry = await db.get(EmailLog, entry_id)
|
||||
if not entry:
|
||||
return HTMLResponse("Not found", status_code=404)
|
||||
sender_profile = await lookup_sender_profile(db, entry.sender_address)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"log_detail.html",
|
||||
{
|
||||
"active_page": "log",
|
||||
"entry": entry,
|
||||
"profile": sender_profile,
|
||||
"email": entry.sender_address,
|
||||
"action_labels": ACTION_LABELS,
|
||||
"classification_labels": CLASSIFICATION_LABELS,
|
||||
"sender_type_labels": SENDER_TYPE_LABELS,
|
||||
"status_labels": STATUS_LABELS,
|
||||
"email_type_options": EMAIL_TYPE_OPTIONS,
|
||||
},
|
||||
)
|
||||
110
howl/web/routes/purge.py
Normal file
110
howl/web/routes/purge.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from howl.db.models import PurgeRule
|
||||
from howl.web.deps import get_db, templates
|
||||
|
||||
router = APIRouter(prefix="/purge")
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def purge_list(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(PurgeRule).order_by(PurgeRule.created_at.desc()))
|
||||
rules = list(result.scalars())
|
||||
return templates.TemplateResponse(
|
||||
request, "purge.html", {"active_page": "purge", "rules": rules}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/new", response_class=HTMLResponse)
|
||||
async def purge_new_form(request: Request):
|
||||
"""Return an empty add-rule form row fragment."""
|
||||
return templates.TemplateResponse(request, "purge_form_row.html", {"rule": None})
|
||||
|
||||
|
||||
@router.post("", response_class=HTMLResponse)
|
||||
async def purge_create(
|
||||
request: Request,
|
||||
email_address: Annotated[str, Form()],
|
||||
display_name: Annotated[str, Form()] = "",
|
||||
older_than_days: Annotated[int, Form()] = 30,
|
||||
notes: Annotated[str, Form()] = "",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# Check for duplicate
|
||||
existing = await db.scalar(
|
||||
select(PurgeRule).where(PurgeRule.email_address == email_address.lower().strip())
|
||||
)
|
||||
if existing:
|
||||
# Re-activate if it was deactivated
|
||||
existing.is_active = True
|
||||
existing.display_name = display_name.strip() or None
|
||||
existing.older_than_days = older_than_days
|
||||
existing.notes = notes.strip() or None
|
||||
rule = existing
|
||||
else:
|
||||
rule = PurgeRule(
|
||||
email_address=email_address.lower().strip(),
|
||||
display_name=display_name.strip() or None,
|
||||
older_than_days=older_than_days,
|
||||
notes=notes.strip() or None,
|
||||
)
|
||||
db.add(rule)
|
||||
await db.flush()
|
||||
return templates.TemplateResponse(request, "purge_rule_row.html", {"rule": rule})
|
||||
|
||||
|
||||
@router.get("/{rule_id}/edit", response_class=HTMLResponse)
|
||||
async def purge_edit_form(request: Request, rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
rule = await db.get(PurgeRule, rule_id)
|
||||
if not rule:
|
||||
return HTMLResponse("", status_code=404)
|
||||
return templates.TemplateResponse(request, "purge_form_row.html", {"rule": rule})
|
||||
|
||||
|
||||
@router.get("/{rule_id}/row", response_class=HTMLResponse)
|
||||
async def purge_row(request: Request, rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
"""Return the read-only row HTML for a rule (used by cancel button)."""
|
||||
rule = await db.get(PurgeRule, rule_id)
|
||||
if not rule:
|
||||
return HTMLResponse("", status_code=404)
|
||||
return templates.TemplateResponse(request, "purge_rule_row.html", {"rule": rule})
|
||||
|
||||
|
||||
@router.put("/{rule_id}", response_class=HTMLResponse)
|
||||
async def purge_update(
|
||||
request: Request,
|
||||
rule_id: uuid.UUID,
|
||||
email_address: Annotated[str, Form()],
|
||||
display_name: Annotated[str, Form()] = "",
|
||||
older_than_days: Annotated[int, Form()] = 30,
|
||||
notes: Annotated[str, Form()] = "",
|
||||
is_active: Annotated[Optional[str], Form()] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
rule = await db.get(PurgeRule, rule_id)
|
||||
if not rule:
|
||||
return HTMLResponse("", status_code=404)
|
||||
rule.email_address = email_address.lower().strip()
|
||||
rule.display_name = display_name.strip() or None
|
||||
rule.older_than_days = older_than_days
|
||||
rule.notes = notes.strip() or None
|
||||
rule.is_active = is_active == "on"
|
||||
await db.flush()
|
||||
return templates.TemplateResponse(request, "purge_rule_row.html", {"rule": rule})
|
||||
|
||||
|
||||
@router.delete("/{rule_id}", response_class=HTMLResponse)
|
||||
async def purge_delete(rule_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
await db.execute(
|
||||
update(PurgeRule).where(PurgeRule.id == rule_id).values(is_active=False)
|
||||
)
|
||||
# Return empty row so htmx swaps it out
|
||||
return HTMLResponse('<tr id="purge-row-{}" class="hidden"></tr>'.format(rule_id))
|
||||
140
howl/web/routes/runner.py
Normal file
140
howl/web/routes/runner.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import Counter
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from howl.db.models import PurgeRule
|
||||
from howl.web.deps import get_db, templates
|
||||
|
||||
router = APIRouter(prefix="/runner")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def runner_page(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(PurgeRule).where(PurgeRule.is_active == True).order_by(PurgeRule.email_address)
|
||||
)
|
||||
active_rules = list(result.scalars())
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"runner.html",
|
||||
{"active_page": "runner", "active_rules": active_rules},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preview / Execute
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/go", response_class=HTMLResponse)
|
||||
async def runner_go(
|
||||
request: Request,
|
||||
mode: Annotated[str, Form()], # "preview" | "execute"
|
||||
source: Annotated[str, Form()], # "table" | "custom"
|
||||
custom_address: Annotated[str, Form()] = "",
|
||||
custom_days: Annotated[int, Form()] = 30,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# Build the list of (email_address, cutoff_datetime) rules to apply
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if source == "table":
|
||||
result = await db.execute(
|
||||
select(PurgeRule).where(PurgeRule.is_active == True)
|
||||
)
|
||||
rules = [
|
||||
(r.email_address, now - timedelta(days=r.older_than_days))
|
||||
for r in result.scalars()
|
||||
]
|
||||
rule_labels = {
|
||||
r.email_address: f"{r.display_name or r.email_address} (>{r.older_than_days}d)"
|
||||
for r in (await db.execute(select(PurgeRule).where(PurgeRule.is_active == True))).scalars()
|
||||
}
|
||||
else:
|
||||
addr = custom_address.strip().lower()
|
||||
if not addr:
|
||||
return templates.TemplateResponse(
|
||||
request, "runner_results.html",
|
||||
{"error": "Email address is required for custom mode.", "mode": mode, "rows": [], "total": 0},
|
||||
)
|
||||
rules = [(addr, now - timedelta(days=custom_days))]
|
||||
rule_labels = {addr: f"{addr} (>{custom_days}d)"}
|
||||
|
||||
if not rules:
|
||||
return templates.TemplateResponse(
|
||||
request, "runner_results.html",
|
||||
{"error": "No active purge rules found. Add rules on the Purge Rules page first.", "mode": mode, "rows": [], "total": 0},
|
||||
)
|
||||
|
||||
dry_run = (mode == "preview")
|
||||
|
||||
try:
|
||||
rows, total = await asyncio.to_thread(_run_purge, rules, rule_labels, dry_run)
|
||||
except Exception as exc:
|
||||
return templates.TemplateResponse(
|
||||
request, "runner_results.html",
|
||||
{"error": str(exc), "mode": mode, "rows": [], "total": 0},
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request, "runner_results.html",
|
||||
{"error": None, "mode": mode, "rows": rows, "total": total, "dry_run": dry_run},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Synchronous Graph work (runs in thread)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _run_purge(
|
||||
rules: list[tuple[str, datetime]],
|
||||
rule_labels: dict[str, str],
|
||||
dry_run: bool,
|
||||
) -> tuple[list[dict], int]:
|
||||
from howl.config import get_settings
|
||||
from howl.graph.auth import build_token_provider
|
||||
from howl.graph.client import GraphClient
|
||||
|
||||
settings = get_settings()
|
||||
token_provider = build_token_provider(settings)
|
||||
graph = GraphClient(token_provider)
|
||||
|
||||
try:
|
||||
messages = graph.collect_purgeable_messages(settings.graph_mailbox, rules)
|
||||
|
||||
counts: Counter[str] = Counter()
|
||||
subjects: dict[str, list[str]] = {}
|
||||
|
||||
for msg in messages:
|
||||
addr = msg.sender_address.lower()
|
||||
counts[addr] += 1
|
||||
if addr not in subjects:
|
||||
subjects[addr] = []
|
||||
if len(subjects[addr]) < 3 and msg.subject:
|
||||
subjects[addr].append(msg.subject)
|
||||
if not dry_run:
|
||||
graph.delete_message(settings.graph_mailbox, msg.id)
|
||||
|
||||
rows = [
|
||||
{
|
||||
"address": addr,
|
||||
"label": rule_labels.get(addr, addr),
|
||||
"count": count,
|
||||
"subjects": subjects.get(addr, []),
|
||||
}
|
||||
for addr, count in counts.most_common()
|
||||
]
|
||||
return rows, sum(counts.values())
|
||||
finally:
|
||||
graph.close()
|
||||
215
howl/web/routes/senders.py
Normal file
215
howl/web/routes/senders.py
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from howl.db.models import SenderProfile
|
||||
from howl.web.deps import EMAIL_TYPE_OPTIONS, SENDER_TYPE_LABELS, get_db, templates
|
||||
|
||||
router = APIRouter(prefix="/senders")
|
||||
|
||||
_SENDER_TYPES = list(SENDER_TYPE_LABELS.keys())
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def senders_list(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(SenderProfile)
|
||||
.where(SenderProfile.is_active == True)
|
||||
.order_by(SenderProfile.updated_at.desc())
|
||||
)
|
||||
profiles = list(result.scalars())
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"senders.html",
|
||||
{
|
||||
"active_page": "senders",
|
||||
"profiles": profiles,
|
||||
"sender_type_labels": SENDER_TYPE_LABELS,
|
||||
"email_type_options": EMAIL_TYPE_OPTIONS,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/list-partial", response_class=HTMLResponse)
|
||||
async def senders_list_partial(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
"""Htmx partial: just the profile cards, used to refresh after save."""
|
||||
result = await db.execute(
|
||||
select(SenderProfile)
|
||||
.where(SenderProfile.is_active == True)
|
||||
.order_by(SenderProfile.updated_at.desc())
|
||||
)
|
||||
profiles = list(result.scalars())
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"senders_list_partial.html",
|
||||
{
|
||||
"profiles": profiles,
|
||||
"sender_type_labels": SENDER_TYPE_LABELS,
|
||||
"email_type_options": EMAIL_TYPE_OPTIONS,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/form", response_class=HTMLResponse)
|
||||
async def sender_form(
|
||||
request: Request,
|
||||
email: str = Query(""),
|
||||
name: str = Query(""),
|
||||
profile_id: Optional[uuid.UUID] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return the modal form fragment. Pre-fill from existing profile if profile_id given."""
|
||||
profile = None
|
||||
if profile_id:
|
||||
profile = await db.get(SenderProfile, profile_id)
|
||||
elif email:
|
||||
from sqlalchemy import func
|
||||
result = await db.execute(
|
||||
select(SenderProfile)
|
||||
.where(func.lower(SenderProfile.email_address) == email.lower())
|
||||
.where(SenderProfile.is_active == True)
|
||||
)
|
||||
profile = result.scalar_one_or_none()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"sender_profile_form.html",
|
||||
{
|
||||
"profile": profile,
|
||||
"prefill_email": email if not profile else "",
|
||||
"prefill_name": name if not profile else "",
|
||||
"sender_types": _SENDER_TYPES,
|
||||
"sender_type_labels": SENDER_TYPE_LABELS,
|
||||
"email_type_options": EMAIL_TYPE_OPTIONS,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_class=HTMLResponse)
|
||||
async def sender_create(
|
||||
request: Request,
|
||||
email_address: Annotated[str, Form()],
|
||||
display_name: Annotated[str, Form()] = "",
|
||||
sender_type: Annotated[str, Form()] = "unknown",
|
||||
email_types: Annotated[list[str], Form()] = [],
|
||||
processing_instructions: Annotated[str, Form()] = "",
|
||||
notes: Annotated[str, Form()] = "",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from sqlalchemy import func
|
||||
|
||||
addr = email_address.lower().strip()
|
||||
# Upsert: if a profile already exists for this address, update it
|
||||
existing = await db.scalar(
|
||||
select(SenderProfile)
|
||||
.where(func.lower(SenderProfile.email_address) == addr)
|
||||
)
|
||||
if existing:
|
||||
existing.display_name = display_name.strip() or None
|
||||
existing.sender_type = sender_type if sender_type in _SENDER_TYPES else "unknown"
|
||||
existing.email_types = email_types
|
||||
existing.processing_instructions = processing_instructions.strip() or None
|
||||
existing.notes = notes.strip() or None
|
||||
existing.is_active = True
|
||||
profile = existing
|
||||
else:
|
||||
profile = SenderProfile(
|
||||
email_address=addr,
|
||||
display_name=display_name.strip() or None,
|
||||
sender_type=sender_type if sender_type in _SENDER_TYPES else "unknown",
|
||||
email_types=email_types,
|
||||
processing_instructions=processing_instructions.strip() or None,
|
||||
notes=notes.strip() or None,
|
||||
)
|
||||
db.add(profile)
|
||||
await db.flush()
|
||||
|
||||
response = templates.TemplateResponse(
|
||||
request,
|
||||
"sender_profile_saved.html",
|
||||
{"profile": profile, "sender_type_labels": SENDER_TYPE_LABELS},
|
||||
)
|
||||
response.headers["HX-Trigger"] = "profileSaved"
|
||||
return response
|
||||
|
||||
|
||||
@router.put("/{profile_id}", response_class=HTMLResponse)
|
||||
async def sender_update(
|
||||
request: Request,
|
||||
profile_id: uuid.UUID,
|
||||
email_address: Annotated[str, Form()],
|
||||
display_name: Annotated[str, Form()] = "",
|
||||
sender_type: Annotated[str, Form()] = "unknown",
|
||||
email_types: Annotated[list[str], Form()] = [],
|
||||
processing_instructions: Annotated[str, Form()] = "",
|
||||
notes: Annotated[str, Form()] = "",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
profile = await db.get(SenderProfile, profile_id)
|
||||
if not profile:
|
||||
return HTMLResponse("Not found", status_code=404)
|
||||
|
||||
profile.email_address = email_address.lower().strip()
|
||||
profile.display_name = display_name.strip() or None
|
||||
profile.sender_type = sender_type if sender_type in _SENDER_TYPES else "unknown"
|
||||
profile.email_types = email_types
|
||||
profile.processing_instructions = processing_instructions.strip() or None
|
||||
profile.notes = notes.strip() or None
|
||||
await db.flush()
|
||||
|
||||
response = templates.TemplateResponse(
|
||||
request,
|
||||
"sender_profile_saved.html",
|
||||
{"profile": profile, "sender_type_labels": SENDER_TYPE_LABELS},
|
||||
)
|
||||
response.headers["HX-Trigger"] = "profileSaved"
|
||||
return response
|
||||
|
||||
|
||||
@router.delete("/{profile_id}", response_class=HTMLResponse)
|
||||
async def sender_delete(
|
||||
profile_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
profile = await db.get(SenderProfile, profile_id)
|
||||
if profile:
|
||||
profile.is_active = False
|
||||
await db.flush()
|
||||
response = HTMLResponse("")
|
||||
response.headers["HX-Trigger"] = "profileSaved"
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/profile-card", response_class=HTMLResponse)
|
||||
async def sender_profile_card(
|
||||
request: Request,
|
||||
email: str = Query(""),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Partial: the profile info card for a given email address (used on log detail page)."""
|
||||
profile = None
|
||||
if email:
|
||||
from sqlalchemy import func
|
||||
result = await db.execute(
|
||||
select(SenderProfile)
|
||||
.where(func.lower(SenderProfile.email_address) == email.lower())
|
||||
.where(SenderProfile.is_active == True)
|
||||
)
|
||||
profile = result.scalar_one_or_none()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"sender_profile_card.html",
|
||||
{
|
||||
"profile": profile,
|
||||
"email": email,
|
||||
"sender_type_labels": SENDER_TYPE_LABELS,
|
||||
"email_type_options": EMAIL_TYPE_OPTIONS,
|
||||
},
|
||||
)
|
||||
330
howl/web/templates/analysis.html
Normal file
330
howl/web/templates/analysis.html
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Analysis{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{% if active_tab == 'analysis' %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto space-y-6">
|
||||
|
||||
<h1 class="text-2xl font-bold">Mailbox Analysis</h1>
|
||||
|
||||
{% set period_qs = "days=" ~ lookback_param ~ ("&custom_days=" ~ (custom_days or '')) if lookback_param == 'custom' else "days=" ~ lookback_param %}
|
||||
|
||||
<!-- Tabs -->
|
||||
<div role="tablist" class="tabs tabs-bordered">
|
||||
<a role="tab" class="tab {% if active_tab == 'analysis' %}tab-active{% endif %}"
|
||||
href="/analysis?tab=analysis">
|
||||
Analysis
|
||||
</a>
|
||||
<a role="tab" class="tab {% if active_tab == 'scanner' %}tab-active{% endif %}"
|
||||
href="/analysis?tab=scanner">
|
||||
Mailbox Scanner
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if active_tab == 'analysis' %}
|
||||
<!-- =============== ANALYSIS TAB =============== -->
|
||||
|
||||
<!-- Live mailbox scan — htmx loads this async -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body pb-2">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="card-title text-base">Live Sender Analysis</h2>
|
||||
<p class="text-sm text-base-content/50">Scanned directly from the mailbox via Graph API.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<form method="get" action="/analysis" class="flex items-center gap-2">
|
||||
<input type="hidden" name="tab" value="analysis" />
|
||||
<div class="join">
|
||||
{% for label, val in [('30d','30'),('60d','60'),('90d','90'),('6m','180'),('1y','365')] %}
|
||||
<button type="submit" name="days" value="{{ val }}"
|
||||
class="join-item btn btn-xs {% if lookback_param == val %}btn-primary{% else %}btn-ghost border border-base-300{% endif %}">
|
||||
{{ label }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</form>
|
||||
<button class="btn btn-ghost btn-sm"
|
||||
hx-get="/analysis/live?{{ period_qs }}"
|
||||
hx-target="#live-tbody"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#live-spinner">
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm" id="senders-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="cursor-pointer select-none" data-col="0" data-type="str">Sender <span class="sort-icon text-base-content/30">↕</span></th>
|
||||
<th class="cursor-pointer select-none" data-col="1" data-type="str">Type <span class="sort-icon text-base-content/30">↕</span></th>
|
||||
<th class="cursor-pointer select-none text-right" data-col="2" data-type="num">Count <span class="sort-icon">↓</span></th>
|
||||
<th class="cursor-pointer select-none" data-col="3" data-type="date">First Seen <span class="sort-icon text-base-content/30">↕</span></th>
|
||||
<th class="cursor-pointer select-none" data-col="4" data-type="date">Last Seen <span class="sort-icon text-base-content/30">↕</span></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="live-tbody"
|
||||
hx-get="/analysis/live?{{ period_qs }}"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#live-spinner">
|
||||
<tr id="live-spinner">
|
||||
<td colspan="6" class="text-center py-10">
|
||||
<span class="loading loading-spinner loading-md text-primary"></span>
|
||||
<span class="ml-2 text-base-content/50 text-sm">Scanning mailbox…</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processed-email charts (from email_log) -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">Daily Volume — Processed Emails</h2>
|
||||
<p class="text-xs text-base-content/40 -mt-1">Emails processed by Howl in this period</p>
|
||||
<div class="h-48">
|
||||
<canvas id="dailyChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">Classification Breakdown</h2>
|
||||
<p class="text-xs text-base-content/40 -mt-1">LLM classifications from processed emails</p>
|
||||
<div class="h-48">
|
||||
<canvas id="classChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% elif active_tab == 'scanner' %}
|
||||
<!-- =============== SCANNER TAB =============== -->
|
||||
|
||||
{% set scanner_qs = period_qs ~ "&mailbox=" ~ (selected_mailbox | urlencode) %}
|
||||
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body pb-2">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="card-title text-base">Mailbox Scanner</h2>
|
||||
<p class="text-sm text-base-content/50">Scan the mailbox and create sender profiles. Use AI to auto-recommend profile settings.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{% if mailboxes | length > 1 %}
|
||||
<select id="mailbox-select" class="select select-bordered select-sm font-mono text-xs"
|
||||
onchange="switchMailbox(this.value)">
|
||||
{% for mb in mailboxes %}
|
||||
<option value="{{ mb }}" {% if mb == selected_mailbox %}selected{% endif %}>{{ mb }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
<form method="get" action="/analysis" class="flex items-center gap-2">
|
||||
<input type="hidden" name="tab" value="scanner" />
|
||||
<input type="hidden" name="mailbox" value="{{ selected_mailbox }}" />
|
||||
<div class="join">
|
||||
{% for label, val in [('7d','7'),('14d','14'),('30d','30'),('60d','60'),('90d','90')] %}
|
||||
<button type="submit" name="days" value="{{ val }}"
|
||||
class="join-item btn btn-xs {% if lookback_param == val %}btn-primary{% else %}btn-ghost border border-base-300{% endif %}">
|
||||
{{ label }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</form>
|
||||
<button class="btn btn-ghost btn-sm"
|
||||
hx-get="/analysis/scanner?{{ scanner_qs }}"
|
||||
hx-target="#scanner-tbody"
|
||||
hx-swap="innerHTML">
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm" id="scanner-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Received</th>
|
||||
<th>Sender</th>
|
||||
<th>Subject</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="scanner-tbody"
|
||||
hx-get="/analysis/scanner?{{ scanner_qs }}"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<tr>
|
||||
<td colspan="4" class="text-center py-10">
|
||||
<span class="loading loading-spinner loading-md text-primary"></span>
|
||||
<span class="ml-2 text-base-content/50 text-sm">Scanning mailbox…</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
{% if active_tab == 'analysis' %}
|
||||
// Sortable table for analysis tab
|
||||
var table = document.getElementById('senders-table');
|
||||
var sortCol = 2, sortDir = 'desc';
|
||||
|
||||
function cellValue(row, col, type) {
|
||||
var cell = row.cells[col];
|
||||
if (!cell) return '';
|
||||
var text = cell.innerText.trim();
|
||||
if (type === 'num') return parseInt(text.replace(/\D/g, '')) || 0;
|
||||
if (type === 'date') return text === '\u2014' ? '' : text;
|
||||
return text.toLowerCase();
|
||||
}
|
||||
|
||||
function sortTable(col, type) {
|
||||
var tbody = document.getElementById('live-tbody');
|
||||
var rows = Array.from(tbody.rows);
|
||||
if (rows.length <= 1 && rows[0] && rows[0].cells.length === 1) return;
|
||||
|
||||
if (sortCol === col) {
|
||||
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortCol = col;
|
||||
sortDir = type === 'num' || type === 'date' ? 'desc' : 'asc';
|
||||
}
|
||||
|
||||
rows.sort(function (a, b) {
|
||||
var av = cellValue(a, col, type);
|
||||
var bv = cellValue(b, col, type);
|
||||
if (av < bv) return sortDir === 'asc' ? -1 : 1;
|
||||
if (av > bv) return sortDir === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
rows.forEach(function (r) { tbody.appendChild(r); });
|
||||
|
||||
Array.from(table.tHead.rows[0].cells).forEach(function (th, i) {
|
||||
var icon = th.querySelector('.sort-icon');
|
||||
if (!icon) return;
|
||||
if (i === sortCol) {
|
||||
icon.textContent = sortDir === 'asc' ? '\u2191' : '\u2193';
|
||||
icon.classList.remove('text-base-content/30');
|
||||
} else {
|
||||
icon.textContent = '\u2195';
|
||||
icon.classList.add('text-base-content/30');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.body.addEventListener('htmx:beforeRequest', function (e) {
|
||||
if (e.detail.target && e.detail.target.id === 'live-tbody') {
|
||||
e.detail.target.innerHTML =
|
||||
'<tr><td colspan="6" class="text-center py-10">' +
|
||||
'<span class="loading loading-spinner loading-md text-primary"></span>' +
|
||||
'<span class="ml-2 text-base-content/50 text-sm">Scanning mailbox\u2026</span>' +
|
||||
'</td></tr>';
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterSwap', function (e) {
|
||||
if (e.detail.target && e.detail.target.id === 'live-tbody') {
|
||||
sortCol = 2; sortDir = 'desc';
|
||||
}
|
||||
});
|
||||
|
||||
if (table && table.tHead) {
|
||||
Array.from(table.tHead.rows[0].cells).forEach(function (th) {
|
||||
if (th.dataset.col === undefined) return;
|
||||
th.addEventListener('click', function () { sortTable(+th.dataset.col, th.dataset.type); });
|
||||
});
|
||||
}
|
||||
|
||||
// Charts
|
||||
var palette = [
|
||||
'#6366f1','#8b5cf6','#ec4899','#f59e0b','#10b981',
|
||||
'#3b82f6','#ef4444','#14b8a6','#f97316','#84cc16','#06b6d4','#a855f7'
|
||||
];
|
||||
|
||||
var dailyLabels = {{ daily_labels_json | safe }};
|
||||
var dailyCounts = {{ daily_counts_json | safe }};
|
||||
var classLabels = {{ classification_labels_json | safe }};
|
||||
var classCounts = {{ classification_counts_json | safe }};
|
||||
|
||||
if (dailyLabels.length) {
|
||||
new Chart(document.getElementById('dailyChart'), {
|
||||
type: 'bar',
|
||||
data: { labels: dailyLabels, datasets: [{ label: 'Emails', data: dailyCounts, backgroundColor: '#6366f1', borderRadius: 3 }] },
|
||||
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } },
|
||||
scales: { x: { grid: { display: false }, ticks: { maxTicksLimit: 10, font: { size: 10 } } }, y: { beginAtZero: true, ticks: { font: { size: 10 } } } } }
|
||||
});
|
||||
} else {
|
||||
document.getElementById('dailyChart').parentElement.innerHTML += '<p class="text-sm text-base-content/50">No processed-email data for this period.</p>';
|
||||
}
|
||||
|
||||
if (classLabels.length) {
|
||||
new Chart(document.getElementById('classChart'), {
|
||||
type: 'doughnut',
|
||||
data: { labels: classLabels, datasets: [{ data: classCounts, backgroundColor: palette.slice(0, classLabels.length), borderWidth: 1 }] },
|
||||
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { font: { size: 10 }, boxWidth: 12 } } } }
|
||||
});
|
||||
} else {
|
||||
document.getElementById('classChart').parentElement.innerHTML += '<p class="text-sm text-base-content/50">No classification data for this period.</p>';
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
{% if active_tab == 'scanner' %}
|
||||
// Switch mailbox: reload the page preserving period
|
||||
window.switchMailbox = function (mb) {
|
||||
var url = '/analysis?tab=scanner&{{ period_qs }}&mailbox=' + encodeURIComponent(mb);
|
||||
window.location.href = url;
|
||||
};
|
||||
|
||||
// Scanner: show spinner on refresh
|
||||
document.body.addEventListener('htmx:beforeRequest', function (e) {
|
||||
if (e.detail.target && e.detail.target.id === 'scanner-tbody') {
|
||||
e.detail.target.innerHTML =
|
||||
'<tr><td colspan="4" class="text-center py-10">' +
|
||||
'<span class="loading loading-spinner loading-md text-primary"></span>' +
|
||||
'<span class="ml-2 text-base-content/50 text-sm">Scanning mailbox\u2026</span>' +
|
||||
'</td></tr>';
|
||||
}
|
||||
});
|
||||
|
||||
// Convert UTC timestamps to local timezone after rows load
|
||||
function convertLocalTimes(root) {
|
||||
(root || document).querySelectorAll('time.local-time').forEach(function (el) {
|
||||
var d = new Date(el.getAttribute('datetime'));
|
||||
if (isNaN(d)) return;
|
||||
var mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
var dd = String(d.getDate()).padStart(2, '0');
|
||||
var hh = String(d.getHours()).padStart(2, '0');
|
||||
var min = String(d.getMinutes()).padStart(2, '0');
|
||||
el.textContent = mm + '/' + dd + ' ' + hh + ':' + min;
|
||||
});
|
||||
}
|
||||
document.body.addEventListener('htmx:afterSwap', function (e) {
|
||||
if (e.detail.target && e.detail.target.id === 'scanner-tbody') {
|
||||
convertLocalTimes(e.detail.target);
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
6
howl/web/templates/analysis_live_error.html
Normal file
6
howl/web/templates/analysis_live_error.html
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<tr>
|
||||
<td colspan="6" class="py-6 text-center">
|
||||
<div class="text-error font-medium mb-1">Graph API scan failed</div>
|
||||
<div class="text-sm text-base-content/60 font-mono">{{ error }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
51
howl/web/templates/analysis_live_rows.html
Normal file
51
howl/web/templates/analysis_live_rows.html
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
{% for s in senders %}
|
||||
<tr id="analysis-row-{{ loop.index }}">
|
||||
<td>
|
||||
<div class="font-mono text-sm">{{ s.address }}</div>
|
||||
{% if s.name %}<div class="text-xs text-base-content/50">{{ s.name }}</div>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-sm {% if s.sender_type == 'customer' %}badge-info{% elif s.sender_type == 'vendor' %}badge-warning{% elif s.sender_type == 'whitelist' %}badge-success{% else %}badge-ghost{% endif %}">
|
||||
{{ s.sender_type_label }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-right tabular-nums font-semibold">{{ s.email_count }}</td>
|
||||
<td class="text-xs text-base-content/50 whitespace-nowrap">
|
||||
{{ s.first_seen.strftime('%Y-%m-%d') if s.first_seen else '—' }}
|
||||
</td>
|
||||
<td class="text-xs text-base-content/50 whitespace-nowrap">
|
||||
{{ s.last_seen.strftime('%Y-%m-%d') if s.last_seen else '—' }}
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-1 flex-wrap">
|
||||
{% if s.has_profile %}
|
||||
<button class="btn btn-xs btn-outline btn-accent"
|
||||
hx-get="/senders/form?email={{ s.address | urlencode }}{% if s.name %}&name={{ s.name | urlencode }}{% endif %}"
|
||||
hx-target="#profile-modal-body"
|
||||
hx-on::after-request="document.getElementById('profile-modal').showModal()">
|
||||
Profiled ✎
|
||||
</button>
|
||||
{% else %}
|
||||
<button class="btn btn-xs btn-outline"
|
||||
hx-get="/senders/form?email={{ s.address | urlencode }}{% if s.name %}&name={{ s.name | urlencode }}{% endif %}"
|
||||
hx-target="#profile-modal-body"
|
||||
hx-on::after-request="document.getElementById('profile-modal').showModal()">
|
||||
+ Profile
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if s.has_purge_rule %}
|
||||
<span class="badge badge-sm badge-error">purged</span>
|
||||
{% else %}
|
||||
<button class="btn btn-xs btn-outline btn-error"
|
||||
hx-post="/purge"
|
||||
hx-vals='{"email_address": "{{ s.address }}", "display_name": "{{ s.name | replace('"', '') }}", "older_than_days": "30"}'
|
||||
hx-target="#analysis-row-{{ loop.index }}"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Add purge rule for {{ s.address }}?">
|
||||
+ Purge
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
60
howl/web/templates/analysis_scanner_rows.html
Normal file
60
howl/web/templates/analysis_scanner_rows.html
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
{% for m in messages %}
|
||||
<tr>
|
||||
<td class="text-xs text-base-content/60 whitespace-nowrap">
|
||||
{% if m.received_at %}
|
||||
<time datetime="{{ m.received_at.strftime('%Y-%m-%dT%H:%M:%SZ') }}" class="local-time">
|
||||
{{ m.received_at.strftime('%m/%d %H:%M') }}
|
||||
</time>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<div>
|
||||
<div class="font-mono text-sm max-w-[200px] truncate">{{ m.sender_address }}</div>
|
||||
{% if m.sender_name %}<div class="text-xs text-base-content/50 max-w-[200px] truncate">{{ m.sender_name }}</div>{% endif %}
|
||||
</div>
|
||||
<span class="badge badge-sm {% if m.sender_type == 'customer' %}badge-info{% elif m.sender_type == 'vendor' %}badge-warning{% elif m.sender_type == 'whitelist' %}badge-success{% else %}badge-ghost{% endif %}">
|
||||
{{ m.sender_type_label }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-sm max-w-[300px] truncate">
|
||||
{{ m.subject }}
|
||||
{% if m.has_attachments %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 inline text-base-content/40 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
||||
</svg>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-1 flex-wrap">
|
||||
{% if m.has_profile %}
|
||||
<button class="btn btn-xs btn-outline btn-accent"
|
||||
hx-get="/senders/form?email={{ m.sender_address | urlencode }}{% if m.sender_name %}&name={{ m.sender_name | urlencode }}{% endif %}"
|
||||
hx-target="#profile-modal-body"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::after-request="document.getElementById('profile-modal').showModal()">
|
||||
Edit Profile
|
||||
</button>
|
||||
{% else %}
|
||||
<button class="btn btn-xs btn-secondary btn-outline"
|
||||
hx-post="/analysis/recommend-profile?email={{ m.sender_address | urlencode }}{% if m.sender_name %}&name={{ m.sender_name | urlencode }}{% endif %}&mailbox={{ mailbox | urlencode }}"
|
||||
hx-target="#profile-modal-body"
|
||||
hx-swap="innerHTML"
|
||||
hx-disabled-elt="this"
|
||||
hx-on::after-request="document.getElementById('profile-modal').showModal()">
|
||||
<span class="loading loading-spinner loading-xs htmx-indicator"></span>
|
||||
AI Profile
|
||||
</button>
|
||||
<button class="btn btn-xs btn-outline"
|
||||
hx-get="/senders/form?email={{ m.sender_address | urlencode }}{% if m.sender_name %}&name={{ m.sender_name | urlencode }}{% endif %}"
|
||||
hx-target="#profile-modal-body"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::after-request="document.getElementById('profile-modal').showModal()">
|
||||
Manual
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
128
howl/web/templates/base.html
Normal file
128
howl/web/templates/base.html
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="corporate">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{% block title %}Howl{% endblock %} — Howl</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.4/dist/htmx.min.js"></script>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body class="bg-base-200 min-h-screen">
|
||||
|
||||
<!-- Drawer layout -->
|
||||
<div class="drawer lg:drawer-open">
|
||||
<input id="main-drawer" type="checkbox" class="drawer-toggle" />
|
||||
|
||||
<!-- Page content -->
|
||||
<div class="drawer-content flex flex-col">
|
||||
|
||||
<!-- Mobile top bar -->
|
||||
<div class="navbar bg-base-100 shadow-sm lg:hidden">
|
||||
<label for="main-drawer" class="btn btn-ghost drawer-button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</label>
|
||||
<span class="text-lg font-bold ml-2">Howl</span>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 p-6">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
</div><!-- /drawer-content -->
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="drawer-side z-20">
|
||||
<label for="main-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||
<aside class="bg-base-100 min-h-full w-56 flex flex-col shadow-lg">
|
||||
|
||||
<!-- Logo -->
|
||||
<div class="px-6 py-5 border-b border-base-200">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span class="text-xl font-bold tracking-tight">Howl</span>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/50 mt-1">M365 Email Daemon</p>
|
||||
</div>
|
||||
|
||||
<!-- Nav links -->
|
||||
<nav class="flex-1 px-3 py-4">
|
||||
<ul class="menu menu-sm gap-1 w-full p-0">
|
||||
<li>
|
||||
<a href="/" class="{% if active_page == 'dashboard' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/purge" class="{% if active_page == 'purge' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Purge Rules
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/log" class="{% if active_page == 'log' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
Email Log
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/runner" class="{% if active_page == 'runner' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Run Purge
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/analysis" class="{% if active_page == 'analysis' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
Analysis
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/senders" class="{% if active_page == 'senders' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Sender Profiles
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-4 py-3 border-t border-base-200 text-xs text-base-content/40">
|
||||
howl daemon
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
</div><!-- /drawer-side -->
|
||||
</div><!-- /drawer -->
|
||||
|
||||
<!-- Shared sender profile modal -->
|
||||
<dialog id="profile-modal" class="modal">
|
||||
<div class="modal-box max-w-lg">
|
||||
<div id="profile-modal-body">
|
||||
<!-- Loaded via htmx -->
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
164
howl/web/templates/dashboard.html
Normal file
164
howl/web/templates/dashboard.html
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-6xl mx-auto space-y-6">
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold">Dashboard</h1>
|
||||
<div class="flex items-center gap-3">
|
||||
<div id="fetch-result"></div>
|
||||
<button class="btn btn-primary btn-sm"
|
||||
hx-post="/fetch"
|
||||
hx-target="#fetch-result"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#fetch-spinner"
|
||||
hx-disabled-elt="this">
|
||||
<span id="fetch-spinner" class="loading loading-spinner loading-xs htmx-indicator"></span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Fetch Mail
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stat cards -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
|
||||
<div class="stat bg-base-100 rounded-box shadow-sm">
|
||||
<div class="stat-title">Emails Today</div>
|
||||
<div class="stat-value text-primary">{{ total_today }}</div>
|
||||
<div class="stat-desc">{{ total_week }} this week</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-100 rounded-box shadow-sm">
|
||||
<div class="stat-title">Total Processed</div>
|
||||
<div class="stat-value">{{ total_all }}</div>
|
||||
<div class="stat-desc">all time</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-100 rounded-box shadow-sm">
|
||||
<div class="stat-title">Failures</div>
|
||||
<div class="stat-value {% if failed_count > 0 %}text-error{% endif %}">{{ failed_count }}</div>
|
||||
<div class="stat-desc">processing errors</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-100 rounded-box shadow-sm">
|
||||
<div class="stat-title">Avg Confidence</div>
|
||||
<div class="stat-value {% if avg_conf and avg_conf < 70 %}text-warning{% elif avg_conf %}text-success{% endif %}">
|
||||
{% if avg_conf %}{{ avg_conf }}%{% else %}—{% endif %}
|
||||
</div>
|
||||
<div class="stat-desc">LLM classification</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Action breakdown + override count -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
|
||||
<div class="lg:col-span-2 card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">Action Breakdown</h2>
|
||||
{% if action_counts %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Action</th>
|
||||
<th class="text-right">Count</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% set max_count = action_counts[0].count if action_counts else 1 %}
|
||||
{% for item in action_counts %}
|
||||
<tr>
|
||||
<td class="font-medium">{{ item.label }}</td>
|
||||
<td class="text-right tabular-nums">{{ item.count }}</td>
|
||||
<td class="w-40">
|
||||
<div class="bg-base-200 rounded-full h-2">
|
||||
<div class="bg-primary rounded-full h-2" style="width: {{ (item.count / max_count * 100)|int }}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-base-content/50 text-sm">No data yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">Rule Overrides</h2>
|
||||
<div class="text-4xl font-bold {% if override_count > 0 %}text-warning{% endif %}">
|
||||
{{ override_count }}
|
||||
</div>
|
||||
<p class="text-sm text-base-content/60 mt-1">
|
||||
Times a business rule changed the LLM's recommended action.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Recent activity -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="card-title text-base">Recent Activity</h2>
|
||||
<a href="/log" class="btn btn-ghost btn-xs">View all →</a>
|
||||
</div>
|
||||
{% if recent %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Received</th>
|
||||
<th>Sender</th>
|
||||
<th>Subject</th>
|
||||
<th>Type</th>
|
||||
<th>Action</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in recent %}
|
||||
<tr class="hover cursor-pointer" onclick="window.location='/log/{{ e.id }}'">
|
||||
<td class="text-xs text-base-content/60 whitespace-nowrap">
|
||||
{{ e.received_at.strftime('%m/%d %H:%M') if e.received_at else '—' }}
|
||||
</td>
|
||||
<td class="text-xs max-w-[160px] truncate">{{ e.sender_address }}</td>
|
||||
<td class="text-xs max-w-[200px] truncate">{{ e.subject or '—' }}</td>
|
||||
<td>
|
||||
<span class="badge badge-xs badge-ghost">{{ e.sender_type }}</span>
|
||||
</td>
|
||||
<td class="text-xs">{{ action_labels.get(e.final_action, e.final_action) }}</td>
|
||||
<td>
|
||||
{% if e.status == 'completed' %}
|
||||
<span class="badge badge-xs badge-success">done</span>
|
||||
{% elif e.status == 'failed' %}
|
||||
<span class="badge badge-xs badge-error">failed</span>
|
||||
{% elif e.status == 'processing' %}
|
||||
<span class="badge badge-xs badge-warning">processing</span>
|
||||
{% else %}
|
||||
<span class="badge badge-xs">{{ e.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-base-content/50 text-sm">No emails processed yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
22
howl/web/templates/fetch_result.html
Normal file
22
howl/web/templates/fetch_result.html
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{% if error %}
|
||||
<div class="alert alert-error py-2 text-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Fetch failed: {{ error }}
|
||||
</div>
|
||||
{% elif processed == 0 %}
|
||||
<div class="alert alert-info py-2 text-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
No new messages.
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-success py-2 text-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Processed {{ processed }} message{{ 's' if processed != 1 else '' }}.
|
||||
</div>
|
||||
{% endif %}
|
||||
145
howl/web/templates/log.html
Normal file
145
howl/web/templates/log.html
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Email Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto space-y-4">
|
||||
|
||||
<h1 class="text-2xl font-bold">Email Log</h1>
|
||||
|
||||
<!-- Filters -->
|
||||
<form method="get" action="/log" class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body py-3 px-4">
|
||||
<div class="flex flex-wrap gap-3 items-end">
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label py-0.5"><span class="label-text text-xs">Search</span></label>
|
||||
<input name="q" type="text" class="input input-bordered input-sm w-48"
|
||||
placeholder="sender or subject" value="{{ filter_q }}" />
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label py-0.5"><span class="label-text text-xs">Status</span></label>
|
||||
<select name="status" class="select select-bordered select-sm">
|
||||
<option value="">All statuses</option>
|
||||
{% for s in statuses %}
|
||||
<option value="{{ s }}" {% if filter_status == s %}selected{% endif %}>{{ status_labels[s] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label py-0.5"><span class="label-text text-xs">Sender Type</span></label>
|
||||
<select name="sender_type" class="select select-bordered select-sm">
|
||||
<option value="">All types</option>
|
||||
{% for t in sender_types %}
|
||||
<option value="{{ t }}" {% if filter_sender_type == t %}selected{% endif %}>{{ sender_type_labels[t] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label py-0.5"><span class="label-text text-xs">Classification</span></label>
|
||||
<select name="classification" class="select select-bordered select-sm">
|
||||
<option value="">All classes</option>
|
||||
{% for c in classifications %}
|
||||
<option value="{{ c }}" {% if filter_classification == c %}selected{% endif %}>{{ classification_labels[c] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
{% if filter_status or filter_sender_type or filter_classification or filter_q %}
|
||||
<a href="/log" class="btn btn-ghost btn-sm">Clear</a>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Results -->
|
||||
<div class="card bg-base-100 shadow-sm overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Received</th>
|
||||
<th>Sender</th>
|
||||
<th>Subject</th>
|
||||
<th>Type</th>
|
||||
<th>Classification</th>
|
||||
<th>Conf</th>
|
||||
<th>Action</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in entries %}
|
||||
<tr class="hover cursor-pointer" onclick="window.location='/log/{{ e.id }}'">
|
||||
<td class="text-xs text-base-content/60 whitespace-nowrap">
|
||||
{{ e.received_at.strftime('%Y-%m-%d %H:%M') if e.received_at else '—' }}
|
||||
</td>
|
||||
<td class="text-xs max-w-[160px]">
|
||||
<div class="truncate">{{ e.sender_address }}</div>
|
||||
{% if e.sender_name %}<div class="text-base-content/40 truncate">{{ e.sender_name }}</div>{% endif %}
|
||||
</td>
|
||||
<td class="text-xs max-w-[220px] truncate">{{ e.subject or '—' }}</td>
|
||||
<td>
|
||||
<span class="badge badge-xs {% if e.sender_type == 'customer' %}badge-info{% elif e.sender_type == 'vendor' %}badge-warning{% elif e.sender_type == 'whitelist' %}badge-success{% else %}badge-ghost{% endif %}">
|
||||
{{ e.sender_type }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-xs">{{ classification_labels.get(e.llm_classification, e.llm_classification or '—') }}</td>
|
||||
<td class="text-xs tabular-nums">
|
||||
{% if e.llm_confidence is not none %}
|
||||
<span class="{% if e.llm_confidence < 0.6 %}text-error{% elif e.llm_confidence < 0.8 %}text-warning{% else %}text-success{% endif %}">
|
||||
{{ (e.llm_confidence * 100)|int }}%
|
||||
</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="text-xs whitespace-nowrap">
|
||||
{{ action_labels.get(e.final_action, e.final_action) }}
|
||||
{% if e.action_overridden %}<span class="badge badge-xs badge-warning ml-1" title="Rule override">⚡</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if e.status == 'completed' %}<span class="badge badge-xs badge-success">done</span>
|
||||
{% elif e.status == 'failed' %}<span class="badge badge-xs badge-error">failed</span>
|
||||
{% elif e.status == 'processing' %}<span class="badge badge-xs badge-warning">processing</span>
|
||||
{% elif e.status == 'skipped' %}<span class="badge badge-xs badge-ghost">skipped</span>
|
||||
{% else %}<span class="badge badge-xs">{{ e.status }}</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center text-base-content/50 py-10">No entries match your filters.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-base-content/50">
|
||||
Showing {{ ((page - 1) * per_page) + 1 }}–{{ [page * per_page, total] | min }} of {{ total }}
|
||||
</span>
|
||||
<div class="join">
|
||||
{% if page > 1 %}
|
||||
<a href="?page={{ page - 1 }}&status={{ filter_status }}&sender_type={{ filter_sender_type }}&classification={{ filter_classification }}&q={{ filter_q }}"
|
||||
class="join-item btn btn-sm">«</a>
|
||||
{% endif %}
|
||||
{% for p in range([1, page - 2] | max, [total_pages + 1, page + 3] | min) %}
|
||||
<a href="?page={{ p }}&status={{ filter_status }}&sender_type={{ filter_sender_type }}&classification={{ filter_classification }}&q={{ filter_q }}"
|
||||
class="join-item btn btn-sm {% if p == page %}btn-active{% endif %}">{{ p }}</a>
|
||||
{% endfor %}
|
||||
{% if page < total_pages %}
|
||||
<a href="?page={{ page + 1 }}&status={{ filter_status }}&sender_type={{ filter_sender_type }}&classification={{ filter_classification }}&q={{ filter_q }}"
|
||||
class="join-item btn btn-sm">»</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-xs text-base-content/40">{{ total }} result{{ 's' if total != 1 else '' }}</p>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
201
howl/web/templates/log_detail.html
Normal file
201
howl/web/templates/log_detail.html
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Email Detail{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto space-y-5">
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/log" class="btn btn-ghost btn-sm">← Back</a>
|
||||
<h1 class="text-xl font-bold truncate">{{ entry.subject or '(no subject)' }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Status banner -->
|
||||
<div class="alert {% if entry.status == 'completed' %}alert-success{% elif entry.status == 'failed' %}alert-error{% elif entry.status == 'processing' %}alert-warning{% else %}alert-info{% endif %} py-2">
|
||||
<span class="font-medium">{{ status_labels.get(entry.status, entry.status) }}</span>
|
||||
{% if entry.error_message %}
|
||||
<span class="text-sm">— {{ entry.error_message }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
|
||||
<!-- Envelope -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base mb-2">Envelope</h2>
|
||||
<dl class="space-y-2 text-sm">
|
||||
<div class="flex gap-2">
|
||||
<dt class="font-medium w-28 shrink-0 text-base-content/60">From</dt>
|
||||
<dd class="break-all">
|
||||
{{ entry.sender_name or '' }}
|
||||
{% if entry.sender_name %}<{{ entry.sender_address }}>{% else %}{{ entry.sender_address }}{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<dt class="font-medium w-28 shrink-0 text-base-content/60">Mailbox</dt>
|
||||
<dd>{{ entry.mailbox }}</dd>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<dt class="font-medium w-28 shrink-0 text-base-content/60">Received</dt>
|
||||
<dd>{{ entry.received_at.strftime('%Y-%m-%d %H:%M:%S UTC') if entry.received_at else '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<dt class="font-medium w-28 shrink-0 text-base-content/60">Attachments</dt>
|
||||
<dd>{% if entry.has_attachments %}<span class="badge badge-sm badge-warning">yes</span>{% else %}no{% endif %}</dd>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<dt class="font-medium w-28 shrink-0 text-base-content/60">Sender Type</dt>
|
||||
<dd>
|
||||
<span class="badge badge-sm {% if entry.sender_type == 'customer' %}badge-info{% elif entry.sender_type == 'vendor' %}badge-warning{% elif entry.sender_type == 'whitelist' %}badge-success{% else %}badge-ghost{% endif %}">
|
||||
{{ sender_type_labels.get(entry.sender_type, entry.sender_type) }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Decision -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base mb-2">Decision</h2>
|
||||
<dl class="space-y-2 text-sm">
|
||||
<div class="flex gap-2">
|
||||
<dt class="font-medium w-32 shrink-0 text-base-content/60">Final Action</dt>
|
||||
<dd class="font-semibold">{{ action_labels.get(entry.final_action, entry.final_action) }}</dd>
|
||||
</div>
|
||||
{% if entry.action_overridden %}
|
||||
<div class="flex gap-2">
|
||||
<dt class="font-medium w-32 shrink-0 text-base-content/60">LLM Suggested</dt>
|
||||
<dd class="line-through text-base-content/50">{{ action_labels.get(entry.llm_suggested_action, entry.llm_suggested_action or '—') }}</dd>
|
||||
</div>
|
||||
<div class="flex gap-2 col-span-2">
|
||||
<span class="badge badge-warning badge-sm">⚡ Overridden by business rule</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex gap-2">
|
||||
<dt class="font-medium w-32 shrink-0 text-base-content/60">Executed At</dt>
|
||||
<dd>{{ entry.action_executed_at.strftime('%H:%M:%S UTC') if entry.action_executed_at else '—' }}</dd>
|
||||
</div>
|
||||
{% if entry.action_error %}
|
||||
<div class="flex gap-2">
|
||||
<dt class="font-medium w-32 shrink-0 text-base-content/60">Error</dt>
|
||||
<dd class="text-error">{{ entry.action_error }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex gap-2">
|
||||
<dt class="font-medium w-32 shrink-0 text-base-content/60">Retry Count</dt>
|
||||
<dd>{{ entry.retry_count }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- LLM Classification -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base mb-2">LLM Classification</h2>
|
||||
{% if entry.llm_classification %}
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||
<div class="stat bg-base-200 rounded-box p-3">
|
||||
<div class="stat-title text-xs">Classification</div>
|
||||
<div class="stat-value text-sm">{{ classification_labels.get(entry.llm_classification, entry.llm_classification) }}</div>
|
||||
</div>
|
||||
<div class="stat bg-base-200 rounded-box p-3">
|
||||
<div class="stat-title text-xs">Confidence</div>
|
||||
<div class="stat-value text-sm {% if entry.llm_confidence and entry.llm_confidence < 0.6 %}text-error{% elif entry.llm_confidence and entry.llm_confidence < 0.8 %}text-warning{% else %}text-success{% endif %}">
|
||||
{% if entry.llm_confidence is not none %}{{ (entry.llm_confidence * 100)|int }}%{% else %}—{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat bg-base-200 rounded-box p-3">
|
||||
<div class="stat-title text-xs">Model</div>
|
||||
<div class="stat-value text-xs font-mono">{{ entry.llm_model or '—' }}</div>
|
||||
</div>
|
||||
<div class="stat bg-base-200 rounded-box p-3">
|
||||
<div class="stat-title text-xs">Tokens</div>
|
||||
<div class="stat-value text-sm">
|
||||
{% if entry.llm_input_tokens %}{{ entry.llm_input_tokens + (entry.llm_output_tokens or 0) }}{% else %}—{% endif %}
|
||||
</div>
|
||||
{% if entry.llm_input_tokens %}
|
||||
<div class="stat-desc text-xs">{{ entry.llm_input_tokens }} in / {{ entry.llm_output_tokens or 0 }} out</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if entry.llm_reasoning %}
|
||||
<div>
|
||||
<div class="text-xs font-medium text-base-content/60 mb-1">Reasoning</div>
|
||||
<div class="bg-base-200 rounded-lg p-3 text-sm leading-relaxed">{{ entry.llm_reasoning }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if entry.llm_raw_response %}
|
||||
<details class="mt-3">
|
||||
<summary class="text-xs cursor-pointer text-base-content/50 hover:text-base-content">Raw LLM response</summary>
|
||||
<pre class="bg-base-200 rounded-lg p-3 text-xs overflow-x-auto mt-2">{{ entry.llm_raw_response | tojson(indent=2) }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-sm text-base-content/50">No LLM classification data.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sender Profile -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="card-title text-base">Sender Profile</h2>
|
||||
{% if not profile %}
|
||||
<button class="btn btn-xs btn-primary"
|
||||
hx-get="/senders/form?email={{ entry.sender_address | urlencode }}{% if entry.sender_name %}&name={{ entry.sender_name | urlencode }}{% endif %}"
|
||||
hx-target="#profile-modal-body"
|
||||
hx-on::after-request="document.getElementById('profile-modal').showModal()">
|
||||
+ Profile this sender
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="sender-profile-card-content"
|
||||
hx-get="/senders/profile-card?email={{ entry.sender_address | urlencode }}"
|
||||
hx-trigger="profileSaved from:body"
|
||||
hx-swap="innerHTML">
|
||||
{% include "sender_profile_card.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pipeline timing -->
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base mb-2">Pipeline Timing</h2>
|
||||
<dl class="grid grid-cols-2 lg:grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<dt class="text-xs text-base-content/60">Started</dt>
|
||||
<dd>{{ entry.processing_started_at.strftime('%H:%M:%S UTC') if entry.processing_started_at else '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-base-content/60">Completed</dt>
|
||||
<dd>{{ entry.processing_completed_at.strftime('%H:%M:%S UTC') if entry.processing_completed_at else '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-base-content/60">Duration</dt>
|
||||
<dd>
|
||||
{% if entry.processing_started_at and entry.processing_completed_at %}
|
||||
{{ ((entry.processing_completed_at - entry.processing_started_at).total_seconds() * 1000)|int }}ms
|
||||
{% else %}—{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-base-content/60">Log Created</dt>
|
||||
<dd>{{ entry.created_at.strftime('%Y-%m-%d %H:%M:%S UTC') if entry.created_at else '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-base-content/60">Graph Message ID</dt>
|
||||
<dd class="font-mono text-xs truncate">{{ entry.graph_message_id[:40] }}…</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
55
howl/web/templates/purge.html
Normal file
55
howl/web/templates/purge.html
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Purge Rules{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-6xl mx-auto space-y-4">
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Purge Rules</h1>
|
||||
<p class="text-sm text-base-content/60 mt-0.5">
|
||||
Emails from these senders older than the configured threshold will be deleted when <code class="text-xs">howl purge</code> runs.
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm"
|
||||
hx-get="/purge/new"
|
||||
hx-target="#purge-tbody"
|
||||
hx-swap="afterbegin">
|
||||
+ Add Rule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-sm overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email Address</th>
|
||||
<th>Display Name</th>
|
||||
<th>Delete After</th>
|
||||
<th>Status</th>
|
||||
<th>Notes</th>
|
||||
<th>Added</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="purge-tbody">
|
||||
{% for rule in rules %}
|
||||
{% include "purge_rule_row.html" %}
|
||||
{% else %}
|
||||
<tr id="purge-empty-row">
|
||||
<td colspan="7" class="text-center text-base-content/50 py-8">
|
||||
No purge rules yet. Click <strong>+ Add Rule</strong> to get started.
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-base-content/40">
|
||||
{{ rules | length }} rule{{ 's' if rules | length != 1 else '' }} total ·
|
||||
{{ rules | selectattr('is_active') | list | length }} active
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
51
howl/web/templates/purge_form_row.html
Normal file
51
howl/web/templates/purge_form_row.html
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
{% if rule %}
|
||||
<tr id="purge-row-{{ rule.id }}">
|
||||
<form hx-put="/purge/{{ rule.id }}"
|
||||
hx-target="#purge-row-{{ rule.id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-encoding="application/x-www-form-urlencoded">
|
||||
<td><input name="email_address" class="input input-bordered input-xs w-full font-mono" value="{{ rule.email_address }}" required /></td>
|
||||
<td><input name="display_name" class="input input-bordered input-xs w-full" value="{{ rule.display_name or '' }}" /></td>
|
||||
<td><input name="older_than_days" type="number" class="input input-bordered input-xs w-16" value="{{ rule.older_than_days }}" min="1" /></td>
|
||||
<td>
|
||||
<label class="flex items-center gap-1 cursor-pointer">
|
||||
<input type="checkbox" name="is_active" class="checkbox checkbox-sm" {% if rule.is_active %}checked{% endif %} value="on" />
|
||||
<span class="text-xs">active</span>
|
||||
</label>
|
||||
</td>
|
||||
<td><input name="notes" class="input input-bordered input-xs w-full" value="{{ rule.notes or '' }}" /></td>
|
||||
<td></td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
<button type="submit" class="btn btn-primary btn-xs">Save</button>
|
||||
<button type="button" class="btn btn-ghost btn-xs"
|
||||
hx-get="/purge/{{ rule.id }}/row"
|
||||
hx-target="#purge-row-{{ rule.id }}"
|
||||
hx-swap="outerHTML">Cancel</button>
|
||||
</div>
|
||||
</td>
|
||||
</form>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr id="new-rule-form-row">
|
||||
<form hx-post="/purge"
|
||||
hx-target="#purge-tbody"
|
||||
hx-swap="afterbegin"
|
||||
hx-encoding="application/x-www-form-urlencoded"
|
||||
hx-on:htmx:after-request="if(event.detail.successful){this.reset(); document.getElementById('new-rule-form-row').remove();}">
|
||||
<td><input name="email_address" class="input input-bordered input-xs w-full font-mono" placeholder="sender@example.com" required /></td>
|
||||
<td><input name="display_name" class="input input-bordered input-xs w-full" placeholder="Display name" /></td>
|
||||
<td><input name="older_than_days" type="number" class="input input-bordered input-xs w-16" value="30" min="1" /></td>
|
||||
<td><span class="text-xs text-base-content/50">—</span></td>
|
||||
<td><input name="notes" class="input input-bordered input-xs w-full" placeholder="Optional notes" /></td>
|
||||
<td></td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
<button type="submit" class="btn btn-primary btn-xs">Add</button>
|
||||
<button type="button" class="btn btn-ghost btn-xs"
|
||||
onclick="document.getElementById('new-rule-form-row').remove()">Cancel</button>
|
||||
</div>
|
||||
</td>
|
||||
</form>
|
||||
</tr>
|
||||
{% endif %}
|
||||
29
howl/web/templates/purge_rule_row.html
Normal file
29
howl/web/templates/purge_rule_row.html
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<tr id="purge-row-{{ rule.id }}">
|
||||
<td class="font-mono text-sm">{{ rule.email_address }}</td>
|
||||
<td class="text-sm">{{ rule.display_name or '—' }}</td>
|
||||
<td class="text-sm tabular-nums">{{ rule.older_than_days }}d</td>
|
||||
<td>
|
||||
{% if rule.is_active %}
|
||||
<span class="badge badge-sm badge-success">active</span>
|
||||
{% else %}
|
||||
<span class="badge badge-sm badge-ghost">inactive</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-sm text-base-content/60 max-w-[200px] truncate">{{ rule.notes or '—' }}</td>
|
||||
<td class="text-xs text-base-content/40 whitespace-nowrap">
|
||||
{{ rule.created_at.strftime('%Y-%m-%d') if rule.created_at else '—' }}
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
<button class="btn btn-ghost btn-xs"
|
||||
hx-get="/purge/{{ rule.id }}/edit"
|
||||
hx-target="#purge-row-{{ rule.id }}"
|
||||
hx-swap="outerHTML">Edit</button>
|
||||
<button class="btn btn-ghost btn-xs text-error"
|
||||
hx-delete="/purge/{{ rule.id }}"
|
||||
hx-target="#purge-row-{{ rule.id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Deactivate rule for {{ rule.email_address }}?">Del</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
156
howl/web/templates/runner.html
Normal file
156
howl/web/templates/runner.html
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Run Purge{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-3xl mx-auto space-y-6">
|
||||
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Run Purge</h1>
|
||||
<p class="text-sm text-base-content/60 mt-0.5">
|
||||
Scan the mailbox and delete matching messages. Always preview first.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body space-y-5">
|
||||
|
||||
<!-- Source selector -->
|
||||
<div>
|
||||
<label class="label"><span class="label-text font-medium">Source</span></label>
|
||||
<div class="join w-full">
|
||||
<button type="button" id="btn-table"
|
||||
class="join-item btn btn-sm btn-primary flex-1"
|
||||
onclick="setSource('table')">
|
||||
Active Purge Rules
|
||||
{% if active_rules %}
|
||||
<span class="badge badge-sm ml-1">{{ active_rules | length }}</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
<button type="button" id="btn-custom"
|
||||
class="join-item btn btn-sm btn-ghost border border-base-300 flex-1"
|
||||
onclick="setSource('custom')">
|
||||
Custom Address
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active rules summary (shown when source=table) -->
|
||||
<div id="table-summary">
|
||||
{% if active_rules %}
|
||||
<div class="bg-base-200 rounded-box p-3 space-y-1 max-h-48 overflow-y-auto">
|
||||
{% for r in active_rules %}
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-mono">{{ r.email_address }}</span>
|
||||
<span class="text-base-content/50 text-xs">delete >{{ r.older_than_days }}d old</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning py-2 text-sm">
|
||||
No active purge rules.
|
||||
<a href="/purge" class="link">Add rules →</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Custom address fields (hidden by default) -->
|
||||
<div id="custom-fields" class="hidden space-y-3">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Email Address</span></label>
|
||||
<input id="custom-address-input" type="email" placeholder="sender@example.com"
|
||||
class="input input-bordered input-sm w-full" />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Delete emails older than</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="custom-days-input" type="number" value="30" min="1" max="3650"
|
||||
class="input input-bordered input-sm w-24" />
|
||||
<span class="text-sm text-base-content/60">days</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button class="btn btn-outline btn-sm flex-1"
|
||||
onclick="submitRun('preview')"
|
||||
id="btn-preview">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
Preview (dry run)
|
||||
</button>
|
||||
<button class="btn btn-error btn-sm flex-1"
|
||||
onclick="submitRun('execute')"
|
||||
id="btn-execute">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Execute Purge
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results area -->
|
||||
<div id="runner-results"></div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Hidden form for htmx submission -->
|
||||
<form id="runner-form"
|
||||
hx-post="/runner/go"
|
||||
hx-target="#runner-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#run-spinner"
|
||||
style="display:none">
|
||||
<input type="hidden" name="mode" id="f-mode" value="preview" />
|
||||
<input type="hidden" name="source" id="f-source" value="table" />
|
||||
<input type="hidden" name="custom_address" id="f-address" value="" />
|
||||
<input type="hidden" name="custom_days" id="f-days" value="30" />
|
||||
</form>
|
||||
|
||||
<script>
|
||||
let currentSource = 'table';
|
||||
|
||||
function setSource(src) {
|
||||
currentSource = src;
|
||||
document.getElementById('table-summary').classList.toggle('hidden', src !== 'table');
|
||||
document.getElementById('custom-fields').classList.toggle('hidden', src !== 'custom');
|
||||
document.getElementById('btn-table').className =
|
||||
'join-item btn btn-sm flex-1 ' + (src === 'table' ? 'btn-primary' : 'btn-ghost border border-base-300');
|
||||
document.getElementById('btn-custom').className =
|
||||
'join-item btn btn-sm flex-1 ' + (src === 'custom' ? 'btn-primary' : 'btn-ghost border border-base-300');
|
||||
}
|
||||
|
||||
function submitRun(mode) {
|
||||
if (mode === 'execute') {
|
||||
const count = document.querySelectorAll('#runner-results .result-count').length;
|
||||
const confirmed = confirm(
|
||||
'This will permanently delete emails from the mailbox.\n\nRun a Preview first to see what will be affected.\n\nContinue with execution?'
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
document.getElementById('f-mode').value = mode;
|
||||
document.getElementById('f-source').value = currentSource;
|
||||
document.getElementById('f-address').value =
|
||||
document.getElementById('custom-address-input').value;
|
||||
document.getElementById('f-days').value =
|
||||
document.getElementById('custom-days-input').value;
|
||||
|
||||
// Show spinner in results area immediately
|
||||
document.getElementById('runner-results').innerHTML =
|
||||
'<div class="card bg-base-100 shadow-sm"><div class="card-body text-center py-10">' +
|
||||
'<span class="loading loading-spinner loading-md text-primary"></span>' +
|
||||
'<p class="text-sm text-base-content/50 mt-2">Scanning mailbox' +
|
||||
(mode === 'execute' ? ' and deleting messages' : '') + '…</p></div></div>';
|
||||
|
||||
htmx.trigger(document.getElementById('runner-form'), 'submit');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
88
howl/web/templates/runner_results.html
Normal file
88
howl/web/templates/runner_results.html
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
{% if error %}
|
||||
<div class="alert alert-error shadow-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<div class="font-medium">Error</div>
|
||||
<div class="text-sm font-mono">{{ error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elif rows %}
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
|
||||
<!-- Summary banner -->
|
||||
<div class="alert {% if dry_run %}alert-info{% else %}alert-success{% endif %} py-3 mb-4">
|
||||
<div>
|
||||
{% if dry_run %}
|
||||
<div class="font-semibold">Preview — no messages deleted</div>
|
||||
<div class="text-sm">
|
||||
Found <strong>{{ total }}</strong> message{{ 's' if total != 1 else '' }} across
|
||||
<strong>{{ rows | length }}</strong> sender{{ 's' if rows | length != 1 else '' }}
|
||||
that match the purge rules.
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="font-semibold">Purge complete</div>
|
||||
<div class="text-sm">
|
||||
Deleted <strong>{{ total }}</strong> message{{ 's' if total != 1 else '' }} across
|
||||
<strong>{{ rows | length }}</strong> sender{{ 's' if rows | length != 1 else '' }}.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-sender breakdown -->
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sender</th>
|
||||
<th class="text-right">Messages</th>
|
||||
<th>Sample subjects</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr class="result-count">
|
||||
<td>
|
||||
<div class="font-mono text-sm">{{ row.address }}</div>
|
||||
{% if row.label != row.address %}
|
||||
<div class="text-xs text-base-content/50">{{ row.label }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right tabular-nums font-semibold {% if not dry_run %}text-error{% endif %}">
|
||||
{% if dry_run %}~{% endif %}{{ row.count }}
|
||||
</td>
|
||||
<td class="text-xs text-base-content/50">
|
||||
{% for s in row.subjects %}
|
||||
<div class="truncate max-w-xs">{{ s }}</div>
|
||||
{% else %}
|
||||
—
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if dry_run %}
|
||||
<div class="mt-4 text-sm text-base-content/50">
|
||||
This was a preview. Click <strong>Execute Purge</strong> to permanently delete these messages.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="alert alert-success shadow-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<div class="font-medium">Nothing to purge</div>
|
||||
<div class="text-sm">No messages matched the purge rules in the current mailbox.</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
37
howl/web/templates/sender_profile_card.html
Normal file
37
howl/web/templates/sender_profile_card.html
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{% if profile %}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="badge {% if profile.sender_type == 'customer' %}badge-info{% elif profile.sender_type == 'vendor' %}badge-warning{% elif profile.sender_type == 'whitelist' %}badge-success{% else %}badge-ghost{% endif %}">
|
||||
{{ sender_type_labels.get(profile.sender_type, profile.sender_type) }}
|
||||
</span>
|
||||
{% for t in (profile.email_types or []) %}
|
||||
{% set label = dict(email_type_options).get(t, t) %}
|
||||
<span class="badge badge-sm badge-outline">{{ label }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if profile.processing_instructions %}
|
||||
<div class="bg-base-200 rounded-lg p-3 text-sm leading-relaxed">{{ profile.processing_instructions }}</div>
|
||||
{% endif %}
|
||||
{% if profile.notes %}
|
||||
<p class="text-xs text-base-content/50">Notes: {{ profile.notes }}</p>
|
||||
{% endif %}
|
||||
<div class="pt-1">
|
||||
<button class="btn btn-xs btn-outline"
|
||||
hx-get="/senders/form?profile_id={{ profile.id }}"
|
||||
hx-target="#profile-modal-body"
|
||||
hx-on::after-request="document.getElementById('profile-modal').showModal()">
|
||||
Edit Profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-base-content/40 text-sm space-y-2">
|
||||
<p>No profile for <span class="font-mono">{{ email }}</span> yet.</p>
|
||||
<button class="btn btn-xs btn-primary"
|
||||
hx-get="/senders/form?email={{ email | urlencode }}"
|
||||
hx-target="#profile-modal-body"
|
||||
hx-on::after-request="document.getElementById('profile-modal').showModal()">
|
||||
+ Profile this sender
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
116
howl/web/templates/sender_profile_form.html
Normal file
116
howl/web/templates/sender_profile_form.html
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
{% set is_edit = profile is not none %}
|
||||
{% set form_email = profile.email_address if is_edit else prefill_email %}
|
||||
{% set form_name = profile.display_name or '' if is_edit else prefill_name %}
|
||||
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-bold text-lg">
|
||||
{% if is_edit %}Edit Sender Profile{% else %}Profile Sender{% endif %}
|
||||
</h3>
|
||||
<form method="dialog"><button class="btn btn-sm btn-circle btn-ghost">✕</button></form>
|
||||
</div>
|
||||
|
||||
{% if prefill_error is defined and prefill_error %}
|
||||
<div class="alert alert-warning text-sm mb-3 py-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<span>{{ prefill_error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if prefill_reasoning is defined and prefill_reasoning %}
|
||||
<div class="alert alert-info text-sm mb-3 py-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
<div>
|
||||
<div class="font-semibold text-xs">AI Recommendation</div>
|
||||
<p class="text-xs mt-0.5">{{ prefill_reasoning }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form {% if is_edit %}hx-put="/senders/{{ profile.id }}"{% else %}hx-post="/senders"{% endif %}
|
||||
hx-target="#profile-modal-body"
|
||||
hx-swap="innerHTML"
|
||||
hx-encoding="application/x-www-form-urlencoded"
|
||||
class="space-y-3">
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div class="form-control">
|
||||
<label class="label py-1"><span class="label-text text-xs font-medium">Email Address</span></label>
|
||||
<input name="email_address" type="email"
|
||||
class="input input-bordered input-sm font-mono"
|
||||
value="{{ form_email }}"
|
||||
placeholder="sender@example.com"
|
||||
required />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1"><span class="label-text text-xs font-medium">Display Name</span></label>
|
||||
<input name="display_name" type="text"
|
||||
class="input input-bordered input-sm"
|
||||
value="{{ form_name }}"
|
||||
placeholder="e.g. Store It 120 VIP" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label py-1"><span class="label-text text-xs font-medium">Sender Type</span></label>
|
||||
<select name="sender_type" class="select select-bordered select-sm">
|
||||
{% for val in sender_types %}
|
||||
<option value="{{ val }}"
|
||||
{% if is_edit and profile.sender_type == val %}selected
|
||||
{% elif not is_edit and prefill_sender_type is defined and prefill_sender_type and val == prefill_sender_type %}selected
|
||||
{% elif not is_edit and (prefill_sender_type is not defined or not prefill_sender_type) and val == 'unknown' %}selected
|
||||
{% endif %}>
|
||||
{{ sender_type_labels.get(val, val) }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label py-1"><span class="label-text text-xs font-medium">Email Types <span class="text-base-content/40 font-normal">(check all that apply)</span></span></label>
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
{% for val, label in email_type_options %}
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="email_types" value="{{ val }}" class="checkbox checkbox-sm"
|
||||
{% if is_edit and val in (profile.email_types or []) %}checked
|
||||
{% elif not is_edit and prefill_email_types is defined and prefill_email_types and val in prefill_email_types %}checked
|
||||
{% endif %} />
|
||||
<span class="text-sm">{{ label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text text-xs font-medium">Processing Instructions</span>
|
||||
<span class="label-text-alt text-xs text-base-content/40">Sent to AI on every email from this sender</span>
|
||||
</label>
|
||||
<textarea name="processing_instructions"
|
||||
class="textarea textarea-bordered text-sm leading-relaxed"
|
||||
rows="4"
|
||||
placeholder="Describe what this sender typically sends and how to handle it. Example: This vendor sends monthly invoices as PDF attachments. Compare the amount to the previous month and flag for review if it differs by more than 10%. File under Vendors/StoreName."
|
||||
>{% if is_edit %}{{ profile.processing_instructions or '' }}{% elif prefill_processing_instructions is defined and prefill_processing_instructions %}{{ prefill_processing_instructions }}{% endif %}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text text-xs font-medium">Internal Notes</span>
|
||||
<span class="label-text-alt text-xs text-base-content/40">Not sent to AI</span>
|
||||
</label>
|
||||
<input name="notes" type="text"
|
||||
class="input input-bordered input-sm"
|
||||
value="{{ profile.notes or '' if is_edit else '' }}"
|
||||
placeholder="e.g. Contact added by lorentz, April 2026" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<form method="dialog"><button type="submit" class="btn btn-sm btn-ghost">Cancel</button></form>
|
||||
<button type="submit" class="btn btn-sm btn-primary">
|
||||
{% if is_edit %}Save Changes{% else %}Create Profile{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
15
howl/web/templates/sender_profile_saved.html
Normal file
15
howl/web/templates/sender_profile_saved.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<div class="text-center space-y-4 py-4">
|
||||
<div class="text-4xl">✓</div>
|
||||
<div>
|
||||
<p class="font-semibold">Profile saved</p>
|
||||
<p class="text-sm text-base-content/60 mt-1 font-mono">{{ profile.email_address }}</p>
|
||||
<p class="text-sm text-base-content/60">
|
||||
{{ sender_type_labels.get(profile.sender_type, profile.sender_type) }}
|
||||
{% if profile.email_types %}· {{ profile.email_types | join(', ') }}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-center gap-2">
|
||||
<form method="dialog"><button class="btn btn-sm btn-ghost">Close</button></form>
|
||||
<a href="/senders" class="btn btn-sm btn-outline">View All Profiles</a>
|
||||
</div>
|
||||
</div>
|
||||
30
howl/web/templates/senders.html
Normal file
30
howl/web/templates/senders.html
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Sender Profiles{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-6xl mx-auto space-y-4">
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Sender Profiles</h1>
|
||||
<p class="text-sm text-base-content/60 mt-0.5">
|
||||
Rich per-sender context fed to the AI on every email — classification type, handling notes, and processing instructions.
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm"
|
||||
hx-get="/senders/form"
|
||||
hx-target="#profile-modal-body"
|
||||
hx-on::after-request="document.getElementById('profile-modal').showModal()">
|
||||
+ New Profile
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="senders-profiles-list"
|
||||
hx-get="/senders/list-partial"
|
||||
hx-trigger="profileSaved from:body"
|
||||
hx-swap="innerHTML">
|
||||
{% include "senders_list_partial.html" %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
56
howl/web/templates/senders_list_partial.html
Normal file
56
howl/web/templates/senders_list_partial.html
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
{% if profiles %}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{% for p in profiles %}
|
||||
<div class="card bg-base-100 shadow-sm" id="sender-profile-{{ p.id }}">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="font-mono text-sm font-semibold truncate">{{ p.email_address }}</div>
|
||||
{% if p.display_name %}
|
||||
<div class="text-sm text-base-content/60 truncate">{{ p.display_name }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="badge badge-sm shrink-0 {% if p.sender_type == 'customer' %}badge-info{% elif p.sender_type == 'vendor' %}badge-warning{% elif p.sender_type == 'whitelist' %}badge-success{% else %}badge-ghost{% endif %}">
|
||||
{{ sender_type_labels.get(p.sender_type, p.sender_type) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if p.email_types %}
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
{% for t in p.email_types %}
|
||||
{% set label = dict(email_type_options).get(t, t) %}
|
||||
<span class="badge badge-sm badge-outline">{{ label }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if p.processing_instructions %}
|
||||
<p class="text-xs text-base-content/70 mt-1 line-clamp-2">{{ p.processing_instructions }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="card-actions justify-end mt-2">
|
||||
<button class="btn btn-ghost btn-xs"
|
||||
hx-get="/senders/form?profile_id={{ p.id }}"
|
||||
hx-target="#profile-modal-body"
|
||||
hx-on::after-request="document.getElementById('profile-modal').showModal()">
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-xs text-error"
|
||||
hx-delete="/senders/{{ p.id }}"
|
||||
hx-confirm="Remove profile for {{ p.email_address }}?">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="text-xs text-base-content/40 mt-2">{{ profiles | length }} profile{{ 's' if profiles | length != 1 else '' }}</div>
|
||||
{% else %}
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body text-center py-12 text-base-content/40">
|
||||
<p>No sender profiles yet.</p>
|
||||
<p class="text-sm mt-1">Click <strong>+ New Profile</strong> or use the <strong>Profile Sender</strong> button on any email log entry.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
@ -148,7 +148,7 @@ def upgrade() -> None:
|
|||
sa.Column("subject", sa.Text),
|
||||
sa.Column("received_at", sa.DateTime(timezone=True)),
|
||||
sa.Column("has_attachments", sa.Boolean, nullable=False, server_default="FALSE"),
|
||||
sa.Column("sender_type", sa.Enum("customer", "vendor", "whitelist", "unknown", name="sender_type", create_type=False), nullable=False, server_default="unknown"),
|
||||
sa.Column("sender_type", postgresql.ENUM("customer", "vendor", "whitelist", "unknown", name="sender_type", create_type=False), nullable=False, server_default="unknown"),
|
||||
sa.Column("matched_entity_id", postgresql.UUID(as_uuid=True)),
|
||||
sa.Column("matched_entity_table", sa.Text),
|
||||
sa.Column("llm_model", sa.Text),
|
||||
|
|
@ -158,12 +158,12 @@ def upgrade() -> None:
|
|||
sa.Column("llm_classification", sa.Text),
|
||||
sa.Column("llm_confidence", sa.Numeric(4, 3)),
|
||||
sa.Column("llm_reasoning", sa.Text),
|
||||
sa.Column("llm_suggested_action", sa.Enum("inbox_keep", "flag_follow_up", "move_customer", "move_vendor", "move_whitelist", "move_spam", "move_review", "escalate", name="email_action", create_type=False)),
|
||||
sa.Column("final_action", sa.Enum("inbox_keep", "flag_follow_up", "move_customer", "move_vendor", "move_whitelist", "move_spam", "move_review", "escalate", name="email_action", create_type=False), nullable=False, server_default="inbox_keep"),
|
||||
sa.Column("llm_suggested_action", postgresql.ENUM("inbox_keep", "flag_follow_up", "move_customer", "move_vendor", "move_whitelist", "move_spam", "move_review", "escalate", name="email_action", create_type=False)),
|
||||
sa.Column("final_action", postgresql.ENUM("inbox_keep", "flag_follow_up", "move_customer", "move_vendor", "move_whitelist", "move_spam", "move_review", "escalate", name="email_action", create_type=False), nullable=False, server_default="inbox_keep"),
|
||||
sa.Column("action_overridden", sa.Boolean, nullable=False, server_default="FALSE"),
|
||||
sa.Column("action_executed_at", sa.DateTime(timezone=True)),
|
||||
sa.Column("action_error", sa.Text),
|
||||
sa.Column("status", sa.Enum("pending", "processing", "completed", "failed", "skipped", name="processing_status", create_type=False), nullable=False, server_default="pending"),
|
||||
sa.Column("status", postgresql.ENUM("pending", "processing", "completed", "failed", "skipped", name="processing_status", create_type=False), nullable=False, server_default="pending"),
|
||||
sa.Column("processing_started_at", sa.DateTime(timezone=True)),
|
||||
sa.Column("processing_completed_at", sa.DateTime(timezone=True)),
|
||||
sa.Column("error_message", sa.Text),
|
||||
|
|
|
|||
25
migrations/versions/0002_add_infosec_action.py
Normal file
25
migrations/versions/0002_add_infosec_action.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""Add move_infosec to email_action enum
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
Create Date: 2026-04-01
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0002"
|
||||
down_revision: Union[str, None] = "0001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TYPE email_action ADD VALUE IF NOT EXISTS 'move_infosec'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Postgres does not support removing enum values; a full recreate would be needed.
|
||||
pass
|
||||
37
migrations/versions/0003_add_purge_rules.py
Normal file
37
migrations/versions/0003_add_purge_rules.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"""Add purge_rules table
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002
|
||||
Create Date: 2026-04-01
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0003"
|
||||
down_revision: Union[str, None] = "0002"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"purge_rules",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("email_address", sa.Text, nullable=False, unique=True),
|
||||
sa.Column("display_name", sa.Text),
|
||||
sa.Column("older_than_days", sa.Integer, nullable=False, server_default="30"),
|
||||
sa.Column("is_active", sa.Boolean, nullable=False, server_default="TRUE"),
|
||||
sa.Column("notes", sa.Text),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
|
||||
)
|
||||
op.create_index("idx_purge_rules_active", "purge_rules", ["email_address"],
|
||||
postgresql_where=sa.text("is_active = TRUE"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("purge_rules")
|
||||
54
migrations/versions/0004_add_sender_profiles.py
Normal file
54
migrations/versions/0004_add_sender_profiles.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""Add sender_profiles table
|
||||
|
||||
Revision ID: 0004
|
||||
Revises: 0003
|
||||
Create Date: 2026-04-02
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0004"
|
||||
down_revision: Union[str, None] = "0003"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"sender_profiles",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("email_address", sa.Text, nullable=False, unique=True),
|
||||
sa.Column("display_name", sa.Text),
|
||||
sa.Column(
|
||||
"sender_type",
|
||||
postgresql.ENUM("customer", "vendor", "whitelist", "unknown", name="sender_type", create_type=False),
|
||||
nullable=False,
|
||||
server_default="unknown",
|
||||
),
|
||||
sa.Column("email_types", postgresql.JSONB, nullable=False, server_default=sa.text("'[]'")),
|
||||
sa.Column("processing_instructions", sa.Text),
|
||||
sa.Column("notes", sa.Text),
|
||||
sa.Column("is_active", sa.Boolean, nullable=False, server_default="TRUE"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_sender_profiles_address",
|
||||
"sender_profiles",
|
||||
[sa.text("LOWER(email_address)")],
|
||||
)
|
||||
op.execute("""
|
||||
CREATE TRIGGER trg_sender_profiles_updated_at
|
||||
BEFORE UPDATE ON sender_profiles
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TRIGGER IF EXISTS trg_sender_profiles_updated_at ON sender_profiles")
|
||||
op.drop_table("sender_profiles")
|
||||
|
|
@ -33,6 +33,11 @@ dependencies = [
|
|||
"tenacity>=9.0.0",
|
||||
# HTML stripping for email bodies
|
||||
"beautifulsoup4>=4.12.0",
|
||||
# Web UI
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.32.0",
|
||||
"jinja2>=3.1.0",
|
||||
"python-multipart>=0.0.12",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
|
|
|||
170
seed_from_pulse.py
Normal file
170
seed_from_pulse.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Seed howl database from pulse_autotask (Autotask contacts/companies).
|
||||
|
||||
Sources:
|
||||
customers + customer_emails <- company_type=1 (Customer) active contacts w/ emails
|
||||
vendors + vendor_emails <- company_type=7 (Partner) active contacts w/ emails
|
||||
whitelist <- unique domains from all client company email addresses
|
||||
"""
|
||||
|
||||
import psycopg2
|
||||
import re
|
||||
|
||||
PULSE_PARAMS = dict(host='localhost', port=5432, user='pulse_user',
|
||||
password='9KuYTjjGEB7NsJc_togj6R9wYLRRrhudZiR4@i@N',
|
||||
dbname='pulse_autotask')
|
||||
HOWL_PARAMS = dict(host='localhost', port=5434, user='howl',
|
||||
password='ztPEGUexRYkfWqUtd15WMdJF98N1jsui',
|
||||
dbname='howl')
|
||||
|
||||
EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
|
||||
|
||||
def valid_email(e):
|
||||
return e and EMAIL_RE.match(e.strip())
|
||||
|
||||
def domain_of(e):
|
||||
return e.strip().lower().split('@')[1] if e and '@' in e else None
|
||||
|
||||
def seed_contacts(pulse_cur, howl_cur, company_type, parent_table, email_table, fk_col):
|
||||
"""Generic: pull contacts of given company_type, insert parent rows then email rows."""
|
||||
pulse_cur.execute("""
|
||||
SELECT
|
||||
trim(concat(c.first_name, ' ', c.last_name)) AS name,
|
||||
co.company_name AS company,
|
||||
COALESCE(NULLIF(c.phone,''), NULLIF(co.phone,'')) AS phone,
|
||||
c.email_address,
|
||||
c.email_address2,
|
||||
c.email_address3,
|
||||
c.primary_contact
|
||||
FROM contacts c
|
||||
JOIN companies co ON c.company_id = co.id
|
||||
WHERE co.company_type = %s
|
||||
AND co.is_active = true AND co.is_deleted = false
|
||||
AND c.is_active = true AND c.is_deleted = false
|
||||
AND c.email_address IS NOT NULL AND c.email_address != ''
|
||||
ORDER BY co.company_name, c.primary_contact DESC, c.last_name, c.first_name
|
||||
""", (company_type,))
|
||||
rows = pulse_cur.fetchall()
|
||||
|
||||
inserted_parents = 0
|
||||
inserted_emails = 0
|
||||
skipped_emails = 0
|
||||
seen_emails = set() # guard against dupes within this batch
|
||||
|
||||
for row in rows:
|
||||
name, company, phone, e1, e2, e3, is_primary = row
|
||||
|
||||
# Collect valid emails for this contact
|
||||
emails = []
|
||||
for addr in [e1, e2, e3]:
|
||||
if valid_email(addr):
|
||||
emails.append(addr.strip().lower())
|
||||
if not emails:
|
||||
continue
|
||||
|
||||
# Insert parent row
|
||||
howl_cur.execute(
|
||||
f"INSERT INTO {parent_table} (name, company, phone) VALUES (%s,%s,%s) RETURNING id",
|
||||
(name or 'Unknown', company, phone)
|
||||
)
|
||||
parent_id = howl_cur.fetchone()[0]
|
||||
inserted_parents += 1
|
||||
|
||||
# Insert email rows
|
||||
for i, addr in enumerate(emails):
|
||||
if addr in seen_emails:
|
||||
skipped_emails += 1
|
||||
continue
|
||||
try:
|
||||
howl_cur.execute(
|
||||
f"INSERT INTO {email_table} ({fk_col}, email_address, label, is_primary) VALUES (%s,%s,%s,%s)",
|
||||
(parent_id, addr, 'work', i == 0 and is_primary)
|
||||
)
|
||||
seen_emails.add(addr)
|
||||
inserted_emails += 1
|
||||
except psycopg2.errors.UniqueViolation:
|
||||
howl_cur.connection.rollback()
|
||||
skipped_emails += 1
|
||||
except Exception as e:
|
||||
howl_cur.connection.rollback()
|
||||
print(f" WARN email {addr}: {e}")
|
||||
skipped_emails += 1
|
||||
|
||||
return inserted_parents, inserted_emails, skipped_emails
|
||||
|
||||
|
||||
def seed_whitelist(pulse_cur, howl_cur):
|
||||
"""Insert one whitelist row per unique domain used by customer company contacts."""
|
||||
pulse_cur.execute("""
|
||||
SELECT DISTINCT lower(split_part(c.email_address, '@', 2)) AS domain
|
||||
FROM contacts c
|
||||
JOIN companies co ON c.company_id = co.id
|
||||
WHERE co.company_type = 1
|
||||
AND co.is_active = true AND co.is_deleted = false
|
||||
AND c.is_active = true AND c.is_deleted = false
|
||||
AND c.email_address LIKE '%@%'
|
||||
ORDER BY 1
|
||||
""")
|
||||
domains = [r[0] for r in pulse_cur.fetchall() if r[0] and '.' in r[0]]
|
||||
|
||||
inserted = 0
|
||||
for domain in domains:
|
||||
try:
|
||||
howl_cur.execute(
|
||||
"INSERT INTO whitelist (domain, description, added_by) VALUES (%s,%s,%s)",
|
||||
(domain, 'Auto-seeded from Autotask customer contacts', 'seed_from_pulse.py')
|
||||
)
|
||||
inserted += 1
|
||||
except psycopg2.errors.UniqueViolation:
|
||||
howl_cur.connection.rollback()
|
||||
except Exception as e:
|
||||
howl_cur.connection.rollback()
|
||||
print(f" WARN domain {domain}: {e}")
|
||||
|
||||
return inserted
|
||||
|
||||
|
||||
def main():
|
||||
print("Connecting to pulse_autotask …")
|
||||
pulse = psycopg2.connect(**PULSE_PARAMS)
|
||||
pulse.autocommit = False
|
||||
pc = pulse.cursor()
|
||||
|
||||
print("Connecting to howl …")
|
||||
howl = psycopg2.connect(**HOWL_PARAMS)
|
||||
howl.autocommit = False
|
||||
hc = howl.cursor()
|
||||
|
||||
try:
|
||||
# --- Customers (company_type=1) ---
|
||||
print("\nSeeding customers …")
|
||||
cp, ce, cs = seed_contacts(pc, hc, 1, 'customers', 'customer_emails', 'customer_id')
|
||||
print(f" customers: {cp:4d} rows")
|
||||
print(f" customer_emails: {ce:4d} rows ({cs} skipped/dupes)")
|
||||
|
||||
# --- Vendors / Partners (company_type=7) ---
|
||||
print("\nSeeding vendors …")
|
||||
vp, ve, vs = seed_contacts(pc, hc, 7, 'vendors', 'vendor_emails', 'vendor_id')
|
||||
print(f" vendors: {vp:4d} rows")
|
||||
print(f" vendor_emails: {ve:4d} rows ({vs} skipped/dupes)")
|
||||
|
||||
# --- Whitelist domains ---
|
||||
print("\nSeeding whitelist …")
|
||||
wl = seed_whitelist(pc, hc)
|
||||
print(f" whitelist: {wl:4d} domain rows")
|
||||
|
||||
howl.commit()
|
||||
print("\nDone — all changes committed.")
|
||||
|
||||
except Exception as e:
|
||||
howl.rollback()
|
||||
print(f"\nERROR — rolled back: {e}")
|
||||
raise
|
||||
finally:
|
||||
pc.close(); pulse.close()
|
||||
hc.close(); howl.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue