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:
lorentz 2026-04-02 14:12:56 -04:00
parent 3bfda9e585
commit ecc6681432
48 changed files with 4394 additions and 59 deletions

74
SCHEMA.md Normal file
View 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.

View file

@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Literal from typing import Literal
from pydantic import Field, field_validator from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@ -16,14 +16,18 @@ class Settings(BaseSettings):
graph_auth_mode: Literal["client_credentials", "delegated"] = "client_credentials" graph_auth_mode: Literal["client_credentials", "delegated"] = "client_credentials"
msal_token_cache_path: str = ".msal_cache.bin" msal_token_cache_path: str = ".msal_cache.bin"
graph_mailbox: str graph_mailbox: str
graph_mailboxes: str = "" # comma-separated list of additional mailboxes for the scanner
graph_poll_interval_seconds: int = 300 graph_poll_interval_seconds: int = 300
graph_batch_size: int = 50 graph_batch_size: int = 50
graph_max_body_chars: int = 4000 graph_max_body_chars: int = 4000
# Anthropic # LLM
anthropic_api_key: str llm_provider: Literal["anthropic", "ollama"] = "anthropic"
anthropic_api_key: str = ""
anthropic_model: str = "claude-sonnet-4-6" anthropic_model: str = "claude-sonnet-4-6"
anthropic_max_tokens: int = 1024 anthropic_max_tokens: int = 1024
ollama_base_url: str = "http://localhost:11434"
ollama_model: str = "qwen2.5:14b"
llm_confidence_threshold: float = 0.60 llm_confidence_threshold: float = 0.60
# PostgreSQL # PostgreSQL
@ -42,6 +46,12 @@ class Settings(BaseSettings):
folder_review: str = "Needs Review" folder_review: str = "Needs Review"
folder_spam: str = "Junk Email" folder_spam: str = "Junk Email"
folder_escalate: str = "Escalate" folder_escalate: str = "Escalate"
folder_infosec: str = "Infosec"
# Web UI
web_host: str = "127.0.0.1"
web_port: int = 8080
# Logging # Logging
log_level: str = "INFO" log_level: str = "INFO"

View file

@ -9,6 +9,7 @@ from sqlalchemy import (
CheckConstraint, CheckConstraint,
DateTime, DateTime,
Enum, Enum,
ForeignKey,
Index, Index,
Integer, Integer,
Numeric, Numeric,
@ -59,6 +60,7 @@ class CustomerEmail(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
customer_id: Mapped[uuid.UUID] = mapped_column( customer_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), UUID(as_uuid=True),
ForeignKey("customers.id", ondelete="CASCADE"),
nullable=False, nullable=False,
) )
email_address: Mapped[str] = mapped_column(Text, 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"),) __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) 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) email_address: Mapped[str] = mapped_column(Text, nullable=False)
label: Mapped[Optional[str]] = mapped_column(Text) label: Mapped[Optional[str]] = mapped_column(Text)
is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) 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( SENDER_TYPE_ENUM = Enum(
"customer", "vendor", "whitelist", "unknown", name="sender_type", create_type=False "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): class EmailLog(Base):
__tablename__ = "email_log" __tablename__ = "email_log"

View file

@ -9,7 +9,7 @@ import structlog
from sqlalchemy import select, text, update from sqlalchemy import select, text, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from howl.db.models import EmailLog from howl.db.models import EmailLog, SenderProfile
log = structlog.get_logger(__name__) 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) select(EmailLog).order_by(EmailLog.created_at.desc()).limit(limit)
) )
return list(result.scalars().all()) 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()

View file

@ -89,13 +89,17 @@ class GraphClient:
folder: str = "Inbox", folder: str = "Inbox",
top: int = 50, top: int = 50,
skip_token: Optional[str] = None, skip_token: Optional[str] = None,
received_after: Optional[datetime] = None,
) -> tuple[list[Message], Optional[str]]: ) -> tuple[list[Message], Optional[str]]:
""" """
Return a batch of unread messages and the next skip token (or None if done). Return a batch of unread messages and the next skip token (or None if done).
""" """
url = f"{GRAPH_BASE}/users/{mailbox}/mailFolders/{folder}/messages" 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 = { params: dict = {
"$filter": "isRead eq false", "$filter": " and ".join(filter_parts),
"$top": str(top), "$top": str(top),
"$select": ( "$select": (
"id,conversationId,subject,sender,receivedDateTime," "id,conversationId,subject,sender,receivedDateTime,"
@ -197,6 +201,119 @@ class GraphClient:
log.info("folder_created", mailbox=mailbox, folder_name=folder_name) log.info("folder_created", mailbox=mailbox, folder_name=folder_name)
return data["id"] 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: def close(self) -> None:
self._http.close() self._http.close()

View file

@ -1,23 +1,182 @@
from __future__ import annotations from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any, Optional
import httpx
import structlog import structlog
from anthropic import Anthropic
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
from howl.config import Settings from howl.config import Settings
from howl.db.queries import SenderMatch from howl.db.queries import SenderMatch
from howl.graph.client import Message from howl.graph.client import Message
from howl.llm.prompts import SYSTEM_PROMPT, build_prompt 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, EmailClassification 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__) 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: def __init__(self, settings: Settings) -> None:
from anthropic import Anthropic
self._client = Anthropic(api_key=settings.anthropic_api_key) self._client = Anthropic(api_key=settings.anthropic_api_key)
self._model = settings.anthropic_model self._model = settings.anthropic_model
self._max_tokens = settings.anthropic_max_tokens 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 self._max_body_chars = settings.graph_max_body_chars
@retry( @retry(
@ -31,52 +190,72 @@ class LLMClient:
message: Message, message: Message,
body: str, body: str,
sender_match: SenderMatch, sender_match: SenderMatch,
sender_profile: Optional["SenderProfile"] = None,
) -> tuple[EmailClassification, dict]: ) -> tuple[EmailClassification, dict]:
""" """
Send the email to Claude for classification via tool use. Send the email for classification via tool use.
Returns: Returns:
(EmailClassification, raw_response_dict) (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( tool_input, raw_response = self._backend.call(
model=self._model,
max_tokens=self._max_tokens,
system=SYSTEM_PROMPT, system=SYSTEM_PROMPT,
tools=[CLASSIFY_EMAIL_TOOL], user_prompt=user_prompt,
tool_choice={"type": "tool", "name": "classify_email"}, tool=CLASSIFY_EMAIL_TOOL,
messages=[{"role": "user", "content": user_prompt}], tool_name="classify_email",
) )
# Extract the tool use block classification = EmailClassification.model_validate(tool_input)
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,
}
log.debug( log.debug(
"email_classified", "email_classified",
classification=classification.classification, classification=classification.classification,
action=classification.action, action=classification.action,
confidence=classification.confidence, confidence=classification.confidence,
model=response.model, model=raw_response.get("model"),
) )
return classification, raw_response 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

View file

@ -1,8 +1,14 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from howl.db.queries import SenderMatch from howl.db.queries import SenderMatch
from howl.graph.client import Message from howl.graph.client import Message
if TYPE_CHECKING:
from howl.db.models import SenderProfile
SYSTEM_PROMPT = """\ SYSTEM_PROMPT = """\
You are an email triage assistant. Your job is to classify incoming emails and \ You are an email triage assistant. Your job is to classify incoming emails and \
recommend the appropriate action for each one. 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 - customer_inquiry: A customer asking a question, requesting support, or following up
- vendor_invoice: An invoice, bill, or payment request from a vendor - vendor_invoice: An invoice, bill, or payment request from a vendor
- vendor_notification: Order confirmations, shipping notifications, account updates from vendors - 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 - spam: Unsolicited promotional or bulk email from unknown senders
- phishing: Emails attempting to deceive the recipient (fake invoices, credential requests, etc.) - phishing: Emails attempting to deceive the recipient (fake invoices, credential requests, etc.)
- support_request: A request for help or technical support - support_request: A request for help or technical support
@ -28,14 +37,15 @@ Classification guidelines:
- unknown: Cannot determine the nature of the email - unknown: Cannot determine the nature of the email
Action guidelines: Action guidelines:
- move_customer: Use for customer emails that are inquiries, requests, or correspondence - inbox_keep: Use for customer emails (inquiries, correspondence) and routine vendor emails (invoices, notifications)
- move_vendor: Use for vendor invoices, notifications, and correspondence - 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 - 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 - 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_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 - 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 \ 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 \ rather than taking an irreversible action. Never recommend moving email from a known \
@ -48,6 +58,7 @@ def build_prompt(
body: str, body: str,
sender_match: SenderMatch, sender_match: SenderMatch,
max_body_chars: int = 4000, max_body_chars: int = 4000,
sender_profile: Optional["SenderProfile"] = None,
) -> str: ) -> str:
"""Build the user-turn prompt string for a single email.""" """Build the user-turn prompt string for a single email."""
lines: list[str] = [] lines: list[str] = []
@ -60,6 +71,12 @@ def build_prompt(
if sender_match.notes: if sender_match.notes:
lines.append(f"Notes: {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"Subject: {message.subject or '(no subject)'}")
lines.append(f"Received: {message.received_at.isoformat() if message.received_at else 'unknown'}") 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'}") lines.append(f"Has Attachments: {'yes' if message.has_attachments else 'no'}")
@ -75,3 +92,50 @@ def build_prompt(
lines.append("---") lines.append("---")
return "\n".join(lines) 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)

View file

@ -11,6 +11,7 @@ EmailActionLiteral = Literal[
"move_customer", "move_customer",
"move_vendor", "move_vendor",
"move_whitelist", "move_whitelist",
"move_infosec",
"move_spam", "move_spam",
"move_review", "move_review",
"escalate", "escalate",
@ -20,6 +21,9 @@ ClassificationLiteral = Literal[
"customer_inquiry", "customer_inquiry",
"vendor_invoice", "vendor_invoice",
"vendor_notification", "vendor_notification",
"vendor_marketing",
"vendor_event",
"infosec_advisory",
"newsletter", "newsletter",
"spam", "spam",
"phishing", "phishing",
@ -44,6 +48,13 @@ class EmailClassification(BaseModel):
return round(v, 3) 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 # Tool definition passed to Claude
CLASSIFY_EMAIL_TOOL = { CLASSIFY_EMAIL_TOOL = {
"name": "classify_email", "name": "classify_email",
@ -60,6 +71,9 @@ CLASSIFY_EMAIL_TOOL = {
"customer_inquiry", "customer_inquiry",
"vendor_invoice", "vendor_invoice",
"vendor_notification", "vendor_notification",
"vendor_marketing",
"vendor_event",
"infosec_advisory",
"newsletter", "newsletter",
"spam", "spam",
"phishing", "phishing",
@ -83,6 +97,7 @@ CLASSIFY_EMAIL_TOOL = {
"move_customer", "move_customer",
"move_vendor", "move_vendor",
"move_whitelist", "move_whitelist",
"move_infosec",
"move_spam", "move_spam",
"move_review", "move_review",
"escalate", "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"],
},
}

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
import structlog import structlog
@ -72,6 +73,305 @@ def dry_run(
console.print("[green]Dry run complete. Check the email_log table for results.[/green]") 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() @app.command()
def status( def status(
limit: int = typer.Option(20, "--limit", "-n", help="Number of recent log entries to show"), limit: int = typer.Option(20, "--limit", "-n", help="Number of recent log entries to show"),
@ -120,5 +420,35 @@ def status(
asyncio.run(_run()) 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__": if __name__ == "__main__":
app() app()

View file

@ -46,6 +46,10 @@ class ActionExecutor:
folder_id = self._graph.get_or_create_folder(mailbox, self._settings.folder_spam) folder_id = self._graph.get_or_create_folder(mailbox, self._settings.folder_spam)
self._graph.move_message(mailbox, message_id, folder_id) 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": elif final_action == "move_review":
folder_id = self._graph.get_or_create_folder(mailbox, self._settings.folder_review) folder_id = self._graph.get_or_create_folder(mailbox, self._settings.folder_review)
self._graph.move_message(mailbox, message_id, folder_id) self._graph.move_message(mailbox, message_id, folder_id)

View file

@ -24,7 +24,78 @@ def decide(
final_action = llm_action final_action = llm_action
overridden = False 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",): if sender_match.sender_type == "whitelist" and llm_action in ("move_spam",):
final_action = "inbox_keep" final_action = "inbox_keep"
overridden = True overridden = True

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
import httpx
import structlog import structlog
from howl.config import Settings from howl.config import Settings
@ -60,19 +61,35 @@ class EmailProcessor:
# DB sender lookup # DB sender lookup
async with db_engine.async_session() as session: async with db_engine.async_session() as session:
sender_match = await queries.lookup_sender(session, message.sender_address) sender_match = await queries.lookup_sender(session, message.sender_address)
sender_profile = await queries.lookup_sender_profile(session, message.sender_address)
log.info( log.info(
"sender_matched", "sender_matched",
sender_type=sender_match.sender_type, sender_type=sender_match.sender_type,
entity=sender_match.entity_name, entity=sender_match.entity_name,
has_profile=sender_profile is not None,
message_id=message.id, message_id=message.id,
) )
# Fetch full email body # Fetch full email body
try:
body = self._graph.get_message_body(self._settings.graph_mailbox, message.id) 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 # 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 # Business rule override
final_action, overridden = classifier.decide( final_action, overridden = classifier.decide(
@ -102,8 +119,10 @@ class EmailProcessor:
# Execute action # Execute action
self._executor.execute(log_entry, final_action) self._executor.execute(log_entry, final_action)
# Mark as read (no-op in dry_run — action executor already skipped) # Mark as read only when the message wasn't moved — after a move the
if not self._settings.dry_run: # 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) self._graph.mark_as_read(self._settings.graph_mailbox, message.id)
# Mark log as completed # Mark log as completed
@ -136,7 +155,7 @@ class EmailProcessor:
retry_count=log_entry.retry_count + 1, 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. Fetch one batch of unread messages and process each one.
Returns the number of messages processed. Returns the number of messages processed.
@ -148,7 +167,7 @@ class EmailProcessor:
while True: while True:
messages, skip_token = self._graph.list_unread_messages( 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: for message in messages:
await self.process_message(message) await self.process_message(message)
@ -190,8 +209,10 @@ class EmailProcessor:
"""Re-run pipeline for a failed message, updating existing log row.""" """Re-run pipeline for a failed message, updating existing log row."""
try: try:
sender_match_result = None sender_match_result = None
sender_profile = None
async with db_engine.async_session() as session: async with db_engine.async_session() as session:
sender_match_result = await queries.lookup_sender(session, message.sender_address) 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( await queries.update_email_log(
session, log_entry.id, session, log_entry.id,
status="processing", status="processing",
@ -199,11 +220,12 @@ class EmailProcessor:
) )
body = self._graph.get_message_body(self._settings.graph_mailbox, message.id) 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) final_action, overridden = classifier.decide(sender_match_result, classification, self._settings)
self._executor.execute(log_entry, final_action) 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) self._graph.mark_as_read(self._settings.graph_mailbox, message.id)
async with db_engine.async_session() as session: async with db_engine.async_session() as session:

0
howl/web/__init__.py Normal file
View file

40
howl/web/app.py Normal file
View 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
View 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

View file

472
howl/web/routes/analysis.py Normal file
View 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

View 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
View 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
View 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
View 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
View 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
View 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,
},
)

View 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 %}

View 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>

View 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 %}

View 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 %}

View 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>

View 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 %}

View 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
View 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 %}

View 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 %}&lt;{{ entry.sender_address }}&gt;{% 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 %}

View 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 &nbsp;·&nbsp;
{{ rules | selectattr('is_active') | list | length }} active
</div>
</div>
{% endblock %}

View 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 %}

View 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>

View 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 &gt;{{ 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 %}

View 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 %}

View 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 %}

View 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>

View 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>

View 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 %}

View 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 %}

View file

@ -148,7 +148,7 @@ def upgrade() -> None:
sa.Column("subject", sa.Text), sa.Column("subject", sa.Text),
sa.Column("received_at", sa.DateTime(timezone=True)), sa.Column("received_at", sa.DateTime(timezone=True)),
sa.Column("has_attachments", sa.Boolean, nullable=False, server_default="FALSE"), 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_id", postgresql.UUID(as_uuid=True)),
sa.Column("matched_entity_table", sa.Text), sa.Column("matched_entity_table", sa.Text),
sa.Column("llm_model", sa.Text), sa.Column("llm_model", sa.Text),
@ -158,12 +158,12 @@ def upgrade() -> None:
sa.Column("llm_classification", sa.Text), sa.Column("llm_classification", sa.Text),
sa.Column("llm_confidence", sa.Numeric(4, 3)), sa.Column("llm_confidence", sa.Numeric(4, 3)),
sa.Column("llm_reasoning", sa.Text), 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("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", 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("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_overridden", sa.Boolean, nullable=False, server_default="FALSE"),
sa.Column("action_executed_at", sa.DateTime(timezone=True)), sa.Column("action_executed_at", sa.DateTime(timezone=True)),
sa.Column("action_error", sa.Text), 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_started_at", sa.DateTime(timezone=True)),
sa.Column("processing_completed_at", sa.DateTime(timezone=True)), sa.Column("processing_completed_at", sa.DateTime(timezone=True)),
sa.Column("error_message", sa.Text), sa.Column("error_message", sa.Text),

View 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

View 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")

View 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")

View file

@ -33,6 +33,11 @@ dependencies = [
"tenacity>=9.0.0", "tenacity>=9.0.0",
# HTML stripping for email bodies # HTML stripping for email bodies
"beautifulsoup4>=4.12.0", "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] [project.scripts]

170
seed_from_pulse.py Normal file
View 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()