saving progress
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
.venv/
|
||||||
|
.env
|
||||||
|
config.json
|
||||||
|
*__pycache__/
|
||||||
|
*.db
|
||||||
|
*.txt
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from groq import Groq
|
||||||
|
|
||||||
|
|
||||||
|
def _format_messages_for_context(messages: List[dict]) -> str:
|
||||||
|
lines = []
|
||||||
|
for m in messages:
|
||||||
|
direction = "IN" if m.get("is_incoming", True) else "OUT"
|
||||||
|
date = m.get("date_sent")
|
||||||
|
subj = m.get("subject") or ""
|
||||||
|
from_email = m.get("from_email") or ""
|
||||||
|
to_email = m.get("to_email") or ""
|
||||||
|
body = (m.get("body") or "").strip()
|
||||||
|
if len(body) > 1000:
|
||||||
|
body = body[:1000] + "..."
|
||||||
|
lines.append(
|
||||||
|
f"[{date}] [{direction}] {from_email} -> {to_email}\nSubject: {subj}\n{body}"
|
||||||
|
)
|
||||||
|
return "\n\n---\n\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _heuristic_analyze(messages: List[dict]) -> Dict:
|
||||||
|
# Simple fallback if Groq isn't available
|
||||||
|
body_concat = "\n\n".join([(m.get("body") or "") for m in messages[-4:]])
|
||||||
|
question_like = "?" in body_concat or any(
|
||||||
|
kw in body_concat.lower()
|
||||||
|
for kw in ["could you", "can you", "please", "let me know", "need", "request"]
|
||||||
|
)
|
||||||
|
last_subj = (messages[-1].get("subject") or "") if messages else ""
|
||||||
|
return {
|
||||||
|
"actionable": bool(question_like),
|
||||||
|
"summary": (body_concat[:350] + "...")
|
||||||
|
if len(body_concat) > 350
|
||||||
|
else body_concat,
|
||||||
|
"subject": last_subj,
|
||||||
|
"confidence": 0.35,
|
||||||
|
"model": "heuristic",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_thread(
|
||||||
|
thread_subject: str, messages: List[dict], max_messages: int = 4
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Analyze a thread using Groq LLM. Returns dict with keys:
|
||||||
|
- actionable: bool
|
||||||
|
- summary: str
|
||||||
|
- subject: str
|
||||||
|
- confidence: float (0..1)
|
||||||
|
- model: str
|
||||||
|
Gracefully falls back to a heuristic when GROQ_API_KEY is missing or calls fail.
|
||||||
|
"""
|
||||||
|
msgs = messages[-max_messages:] if max_messages else messages
|
||||||
|
|
||||||
|
api_key = os.getenv("GROQ_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
return _heuristic_analyze(msgs)
|
||||||
|
|
||||||
|
client = Groq(api_key=api_key)
|
||||||
|
|
||||||
|
system_prompt = (
|
||||||
|
"You are a helpful assistant that triages email threads and writes concise summaries. "
|
||||||
|
"Decide if the thread requires a reply from our side now, based on the last few messages. "
|
||||||
|
"Ignore newsletters/automations (e.g., from no-reply), and focus on whether there's a clear question or request. "
|
||||||
|
"Return a strict JSON object with keys: actionable (true/false), summary (<= 80 words), confidence (0..1)."
|
||||||
|
)
|
||||||
|
|
||||||
|
user_prompt = (
|
||||||
|
f"Thread subject: {thread_subject or ''}\n\n"
|
||||||
|
"Recent messages (oldest to newest):\n\n"
|
||||||
|
f"{_format_messages_for_context(msgs)}\n\n"
|
||||||
|
"Respond with only JSON, no extra commentary."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
completion = client.chat.completions.create(
|
||||||
|
model=os.getenv("GROQ_MODEL", "llama-3.1-70b-versatile"),
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_prompt},
|
||||||
|
],
|
||||||
|
temperature=0.2,
|
||||||
|
max_tokens=300,
|
||||||
|
)
|
||||||
|
content = completion.choices[0].message.content.strip()
|
||||||
|
# Attempt to extract JSON
|
||||||
|
data = json.loads(content)
|
||||||
|
data.setdefault("subject", thread_subject or "")
|
||||||
|
data.setdefault("model", os.getenv("GROQ_MODEL", "llama-3.1-70b-versatile"))
|
||||||
|
# Basic validation
|
||||||
|
if not isinstance(data.get("actionable"), bool) or not isinstance(
|
||||||
|
data.get("summary"), str
|
||||||
|
):
|
||||||
|
raise ValueError("Invalid schema from model")
|
||||||
|
return data
|
||||||
|
except Exception:
|
||||||
|
# Fallback to heuristic
|
||||||
|
return _heuristic_analyze(msgs)
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ai import analyze_thread
|
||||||
|
from database import (
|
||||||
|
Message,
|
||||||
|
SessionLocal,
|
||||||
|
Thread,
|
||||||
|
create_db_tables,
|
||||||
|
get_thread_messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
create_db_tables()
|
||||||
|
app = FastAPI(title="Email Alerts UI")
|
||||||
|
|
||||||
|
# Static and templates
|
||||||
|
os.makedirs("templates", exist_ok=True)
|
||||||
|
os.makedirs("static", exist_ok=True)
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
def home(request: Request, db: Session = Depends(get_db), account: str | None = None):
|
||||||
|
q = db.query(Thread).order_by(Thread.updated_at.desc())
|
||||||
|
if account:
|
||||||
|
q = q.filter(Thread.account_email == account.lower())
|
||||||
|
threads = q.limit(100).all()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"threads.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"threads": threads,
|
||||||
|
"account": account or "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/thread/{thread_id}", response_class=HTMLResponse)
|
||||||
|
def show_thread(thread_id: int, request: Request, db: Session = Depends(get_db)):
|
||||||
|
thread = db.query(Thread).filter(Thread.id == thread_id).one_or_none()
|
||||||
|
if not thread:
|
||||||
|
raise HTTPException(status_code=404, detail="Thread not found")
|
||||||
|
messages: List[Message] = get_thread_messages(db, thread_id)
|
||||||
|
# Convert for AI analyzer and template
|
||||||
|
msg_dicts = [
|
||||||
|
{
|
||||||
|
"id": m.id,
|
||||||
|
"date_sent": m.date_sent,
|
||||||
|
"subject": m.subject,
|
||||||
|
"from_email": m.from_email,
|
||||||
|
"to_email": m.to_email,
|
||||||
|
"body": m.body,
|
||||||
|
"is_incoming": m.is_incoming,
|
||||||
|
}
|
||||||
|
for m in messages
|
||||||
|
]
|
||||||
|
ai = analyze_thread(thread.subject or "", msg_dicts)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"thread_detail.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"thread": thread,
|
||||||
|
"messages": messages,
|
||||||
|
"ai": ai,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------
|
||||||
|
# Config editor routes
|
||||||
|
# ---------------------
|
||||||
|
|
||||||
|
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json")
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> dict:
|
||||||
|
if not os.path.exists(CONFIG_PATH):
|
||||||
|
return {
|
||||||
|
"email_address": "",
|
||||||
|
"time_frames": [
|
||||||
|
{"name": "1-24 hours", "hours": 24, "alert_level": 1},
|
||||||
|
{"name": "24-48 hours", "hours": 48, "alert_level": 2},
|
||||||
|
{"name": "48+ hours", "hours": 72, "alert_level": 3},
|
||||||
|
],
|
||||||
|
"email_days_back": 7,
|
||||||
|
"agency_domains": [],
|
||||||
|
"zoho_email": "",
|
||||||
|
"zoho_app_password": "",
|
||||||
|
"auto_process": False,
|
||||||
|
"auto_process_interval": 30,
|
||||||
|
}
|
||||||
|
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(cfg: dict) -> None:
|
||||||
|
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/config", response_class=HTMLResponse)
|
||||||
|
def config_form(request: Request, saved: int | None = None):
|
||||||
|
cfg = load_config()
|
||||||
|
# Render up to existing frames or at least 3 rows
|
||||||
|
frames = cfg.get("time_frames") or []
|
||||||
|
min_rows = max(len(frames), 3)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"config.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"cfg": cfg,
|
||||||
|
"rows": list(range(min_rows)),
|
||||||
|
"saved": bool(saved),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/config")
|
||||||
|
async def config_save(request: Request):
|
||||||
|
form = await request.form()
|
||||||
|
cfg = load_config()
|
||||||
|
|
||||||
|
# Basic fields
|
||||||
|
cfg["email_address"] = (form.get("email_address") or "").strip()
|
||||||
|
# email_days_back
|
||||||
|
try:
|
||||||
|
cfg["email_days_back"] = int(
|
||||||
|
form.get("email_days_back") or cfg.get("email_days_back", 7)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
cfg["email_days_back"] = 7
|
||||||
|
# agency domains (comma or newline separated)
|
||||||
|
domains_raw = (form.get("agency_domains") or "").replace("\r", "")
|
||||||
|
parts = [p.strip() for p in domains_raw.replace(",", "\n").split("\n") if p.strip()]
|
||||||
|
cfg["agency_domains"] = parts
|
||||||
|
# auto_process
|
||||||
|
cfg["auto_process"] = form.get("auto_process") == "on"
|
||||||
|
# auto_process_interval
|
||||||
|
try:
|
||||||
|
cfg["auto_process_interval"] = int(
|
||||||
|
form.get("auto_process_interval") or cfg.get("auto_process_interval", 30)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
cfg["auto_process_interval"] = 30
|
||||||
|
|
||||||
|
# Zoho (optional - note: current client reads env vars)
|
||||||
|
cfg["zoho_email"] = (form.get("zoho_email") or cfg.get("zoho_email", "")).strip()
|
||||||
|
cfg["zoho_app_password"] = (
|
||||||
|
form.get("zoho_app_password") or cfg.get("zoho_app_password", "")
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
# Time frames: collect indexed rows
|
||||||
|
frames: list[dict] = []
|
||||||
|
# find indices present
|
||||||
|
indices = set()
|
||||||
|
for k in form.keys():
|
||||||
|
if k.startswith("time_name_"):
|
||||||
|
try:
|
||||||
|
indices.add(int(k.split("_")[-1]))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for i in sorted(indices):
|
||||||
|
name = (form.get(f"time_name_{i}") or "").strip()
|
||||||
|
hrs_raw = form.get(f"time_hours_{i}") or ""
|
||||||
|
lvl_raw = form.get(f"time_alert_{i}") or ""
|
||||||
|
if not name and not hrs_raw and not lvl_raw:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
hours = int(hrs_raw)
|
||||||
|
except Exception:
|
||||||
|
hours = 0
|
||||||
|
try:
|
||||||
|
level = int(lvl_raw)
|
||||||
|
except Exception:
|
||||||
|
level = 0
|
||||||
|
if name:
|
||||||
|
frames.append({"name": name, "hours": hours, "alert_level": level})
|
||||||
|
if frames:
|
||||||
|
cfg["time_frames"] = frames
|
||||||
|
|
||||||
|
save_config(cfg)
|
||||||
|
return RedirectResponse(url="/config?saved=1", status_code=303)
|
||||||
+324
@@ -0,0 +1,324 @@
|
|||||||
|
from email.utils import parseaddr
|
||||||
|
from typing import Annotated, Iterable, List, Optional, Tuple
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
create_engine,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import Session, relationship, sessionmaker
|
||||||
|
|
||||||
|
SQLALCHEMY_DATABASE_URL = "sqlite:///./sql_app.db"
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
db_dependency = Annotated[Session, Depends(get_db)]
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def create_db_tables():
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
|
||||||
|
class Thread(Base):
|
||||||
|
__tablename__ = "threads"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
# The mailbox this thread belongs to (scopes data when analyzing multiple inboxes)
|
||||||
|
account_email = Column(String, nullable=False, index=True)
|
||||||
|
# A stable key for the thread, typically the root message-id (or a synthetic key)
|
||||||
|
thread_key = Column(String, nullable=False)
|
||||||
|
subject = Column(String, index=True)
|
||||||
|
requires_reply = Column(Boolean, nullable=False, default=False)
|
||||||
|
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure uniqueness per account
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("account_email", "thread_key", name="uq_thread_account_key"),
|
||||||
|
Index("ix_threads_account_updated", "account_email", "updated_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ORM relationship
|
||||||
|
messages = relationship(
|
||||||
|
"Message",
|
||||||
|
back_populates="thread",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="Message.date_sent",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Message(Base):
|
||||||
|
__tablename__ = "messages"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
# Links to Thread
|
||||||
|
thread_id = Column(Integer, ForeignKey("threads.id"), nullable=False, index=True)
|
||||||
|
|
||||||
|
# RFC 5322 identifiers for threading
|
||||||
|
message_id = Column(String, nullable=False, unique=True, index=True)
|
||||||
|
in_reply_to = Column(String, index=True) # parent message-id if any
|
||||||
|
|
||||||
|
# Headers / metadata
|
||||||
|
subject = Column(String, index=True)
|
||||||
|
from_email = Column(String, index=True)
|
||||||
|
to_email = Column(String, index=True)
|
||||||
|
folder = Column(String, index=True) # e.g. INBOX, Sent
|
||||||
|
is_incoming = Column(Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
date_sent = Column(DateTime, index=True)
|
||||||
|
body = Column(Text)
|
||||||
|
|
||||||
|
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
thread = relationship("Thread", back_populates="messages")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_messages_thread_date", "thread_id", "date_sent"),
|
||||||
|
Index("ix_messages_inreplyto", "in_reply_to"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------
|
||||||
|
# Utility / DAO functions
|
||||||
|
# ----------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_email(addr: Optional[str]) -> str:
|
||||||
|
if not addr:
|
||||||
|
return ""
|
||||||
|
name, email_addr = parseaddr(addr)
|
||||||
|
return email_addr.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_incoming_message(account_email: str, from_email: str) -> bool:
|
||||||
|
account = (account_email or "").lower()
|
||||||
|
sender = _normalize_email(from_email)
|
||||||
|
# If sender is the account itself, it's outgoing; otherwise incoming
|
||||||
|
return sender != account and sender != ""
|
||||||
|
|
||||||
|
|
||||||
|
def find_or_create_thread(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
account_email: str,
|
||||||
|
subject: Optional[str],
|
||||||
|
message_id: str,
|
||||||
|
in_reply_to: Optional[str] = None,
|
||||||
|
) -> Thread:
|
||||||
|
"""
|
||||||
|
Resolves the thread for a message.
|
||||||
|
Strategy:
|
||||||
|
- If in_reply_to matches an existing message, reuse its thread and its thread_key.
|
||||||
|
- Else if a message with message_id already exists, reuse its thread.
|
||||||
|
- Else create a new thread using message_id as thread_key.
|
||||||
|
"""
|
||||||
|
account_email = (account_email or "").lower()
|
||||||
|
|
||||||
|
# 1) Try to find parent by in_reply_to
|
||||||
|
parent_msg: Optional[Message] = None
|
||||||
|
if in_reply_to:
|
||||||
|
parent_msg = (
|
||||||
|
db.query(Message)
|
||||||
|
.join(Thread, Message.thread_id == Thread.id)
|
||||||
|
.filter(
|
||||||
|
Message.message_id == in_reply_to,
|
||||||
|
Thread.account_email == account_email,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if parent_msg:
|
||||||
|
# Parent's thread
|
||||||
|
parent_thread = parent_msg.thread
|
||||||
|
return parent_thread
|
||||||
|
|
||||||
|
# 2) If message exists already, reuse its thread (idempotent ingest)
|
||||||
|
existing_msg = (
|
||||||
|
db.query(Message)
|
||||||
|
.join(Thread, Message.thread_id == Thread.id)
|
||||||
|
.filter(Message.message_id == message_id, Thread.account_email == account_email)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if existing_msg:
|
||||||
|
return existing_msg.thread
|
||||||
|
|
||||||
|
# 3) Create a new thread using message_id as the thread_key
|
||||||
|
thread = Thread(account_email=account_email, thread_key=message_id, subject=subject)
|
||||||
|
db.add(thread)
|
||||||
|
db.flush() # assign id
|
||||||
|
return thread
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_message(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
account_email: str,
|
||||||
|
message_id: str,
|
||||||
|
subject: Optional[str],
|
||||||
|
from_email: Optional[str],
|
||||||
|
to_email: Optional[str],
|
||||||
|
date_sent,
|
||||||
|
body: Optional[str],
|
||||||
|
in_reply_to: Optional[str] = None,
|
||||||
|
folder: Optional[str] = None,
|
||||||
|
) -> Tuple[Message, Thread]:
|
||||||
|
"""Insert or update a message, linking it to the proper thread."""
|
||||||
|
thread = find_or_create_thread(
|
||||||
|
db,
|
||||||
|
account_email=account_email,
|
||||||
|
subject=subject,
|
||||||
|
message_id=message_id,
|
||||||
|
in_reply_to=in_reply_to,
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = db.query(Message).filter_by(message_id=message_id).one_or_none()
|
||||||
|
if msg is None:
|
||||||
|
msg = Message(message_id=message_id, thread_id=thread.id)
|
||||||
|
db.add(msg)
|
||||||
|
|
||||||
|
msg.thread_id = thread.id
|
||||||
|
msg.in_reply_to = in_reply_to
|
||||||
|
msg.subject = subject
|
||||||
|
msg.from_email = _normalize_email(from_email)
|
||||||
|
msg.to_email = _normalize_email(to_email)
|
||||||
|
msg.date_sent = date_sent
|
||||||
|
msg.body = body
|
||||||
|
msg.folder = folder or "INBOX"
|
||||||
|
msg.is_incoming = _is_incoming_message(account_email, msg.from_email)
|
||||||
|
|
||||||
|
# Keep thread subject if missing; otherwise prefer the earliest subject
|
||||||
|
if not thread.subject and subject:
|
||||||
|
thread.subject = subject
|
||||||
|
|
||||||
|
# Update requires_reply flag after inserting/updating the message
|
||||||
|
update_thread_requires_reply(db, thread, account_email)
|
||||||
|
|
||||||
|
return msg, thread
|
||||||
|
|
||||||
|
|
||||||
|
def update_thread_requires_reply(
|
||||||
|
db: Session, thread: Thread, account_email: str
|
||||||
|
) -> None:
|
||||||
|
"""Set thread.requires_reply based on the latest message direction.
|
||||||
|
|
||||||
|
Rule: If the most recent message in the thread is incoming (from someone else),
|
||||||
|
then the thread requires a reply. Otherwise, it doesn't.
|
||||||
|
"""
|
||||||
|
latest: Optional[Message] = (
|
||||||
|
db.query(Message)
|
||||||
|
.filter(Message.thread_id == thread.id)
|
||||||
|
.order_by(Message.date_sent.desc(), Message.id.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if latest is None:
|
||||||
|
thread.requires_reply = False
|
||||||
|
else:
|
||||||
|
thread.requires_reply = latest.is_incoming
|
||||||
|
|
||||||
|
# Touch updated_at
|
||||||
|
thread.updated_at = func.now()
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def get_thread_messages(db: Session, thread_id: int) -> List[Message]:
|
||||||
|
return (
|
||||||
|
db.query(Message)
|
||||||
|
.filter(Message.thread_id == thread_id)
|
||||||
|
.order_by(Message.date_sent.asc(), Message.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_threads_requiring_reply(db: Session, account_email: str) -> List[Thread]:
|
||||||
|
return (
|
||||||
|
db.query(Thread)
|
||||||
|
.filter(
|
||||||
|
Thread.account_email == account_email.lower(),
|
||||||
|
Thread.requires_reply.is_(True),
|
||||||
|
)
|
||||||
|
.order_by(Thread.updated_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ingest_emails(
|
||||||
|
db: Session,
|
||||||
|
account_email: str,
|
||||||
|
emails: Iterable[dict],
|
||||||
|
default_folder: str = "INBOX",
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Bulk-ingest emails fetched via ZohoClient.fetch_emails.
|
||||||
|
Expected fields per email dict: subject, from, date, snippet/body, messageId, optional inReplyTo, optional to.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
for e in emails:
|
||||||
|
# Map common keys from ZohoClient output
|
||||||
|
message_id = e.get("messageId") or e.get("id")
|
||||||
|
if not message_id:
|
||||||
|
# Skip messages without identifiers
|
||||||
|
continue
|
||||||
|
|
||||||
|
subject = e.get("subject")
|
||||||
|
from_header = e.get("from") or e.get("from_email")
|
||||||
|
to_header = e.get("to") or e.get("to_email")
|
||||||
|
in_reply_to = e.get("inReplyTo") or e.get("in_reply_to")
|
||||||
|
folder = e.get("folder") or default_folder
|
||||||
|
body = e.get("body") or e.get("snippet")
|
||||||
|
|
||||||
|
# Parse date if it's a string
|
||||||
|
date_val = e.get("date") or e.get("date_sent")
|
||||||
|
if isinstance(date_val, str):
|
||||||
|
try:
|
||||||
|
# Try multiple formats; fall back to now on failure
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
|
|
||||||
|
date_sent = parsedate_to_datetime(date_val)
|
||||||
|
except Exception:
|
||||||
|
date_sent = datetime.utcnow()
|
||||||
|
else:
|
||||||
|
date_sent = date_val
|
||||||
|
|
||||||
|
upsert_message(
|
||||||
|
db,
|
||||||
|
account_email=account_email,
|
||||||
|
message_id=message_id,
|
||||||
|
subject=subject,
|
||||||
|
from_email=from_header,
|
||||||
|
to_email=to_header,
|
||||||
|
date_sent=date_sent,
|
||||||
|
body=body,
|
||||||
|
in_reply_to=in_reply_to,
|
||||||
|
folder=folder,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from database import (
|
||||||
|
Message,
|
||||||
|
SessionLocal,
|
||||||
|
Thread,
|
||||||
|
create_db_tables,
|
||||||
|
get_thread_messages,
|
||||||
|
get_threads_requiring_reply,
|
||||||
|
ingest_emails,
|
||||||
|
)
|
||||||
|
from zoho_client import ZohoClient
|
||||||
|
|
||||||
|
|
||||||
|
def ingest_action(
|
||||||
|
account_email: str, days_back: int = 7, max_results: int = 50
|
||||||
|
) -> None:
|
||||||
|
create_db_tables()
|
||||||
|
client = ZohoClient(email=account_email)
|
||||||
|
inbox = client.fetch_folder_emails(
|
||||||
|
folder="INBOX", max_results=max_results, days_back=days_back
|
||||||
|
)
|
||||||
|
sent = client.fetch_folder_emails(
|
||||||
|
folder="Sent", max_results=max_results, days_back=days_back
|
||||||
|
)
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
ingest_emails(
|
||||||
|
db, account_email=account_email, emails=inbox, default_folder="INBOX"
|
||||||
|
)
|
||||||
|
ingest_emails(
|
||||||
|
db, account_email=account_email, emails=sent, default_folder="Sent"
|
||||||
|
)
|
||||||
|
threads: List[Thread] = get_threads_requiring_reply(db, account_email)
|
||||||
|
print(f"Threads requiring reply for {account_email}: {len(threads)}")
|
||||||
|
for t in threads:
|
||||||
|
print(
|
||||||
|
f"- Thread #{t.id} | Subject: {t.subject!r} | requires_reply={t.requires_reply}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def list_threads_action(
|
||||||
|
account_email: Optional[str] = None, limit: int = 20, only_requiring: bool = False
|
||||||
|
) -> None:
|
||||||
|
create_db_tables()
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
q = db.query(Thread).order_by(Thread.updated_at.desc())
|
||||||
|
if account_email:
|
||||||
|
q = q.filter(Thread.account_email == account_email.lower())
|
||||||
|
if only_requiring:
|
||||||
|
q = q.filter(Thread.requires_reply.is_(True))
|
||||||
|
threads = q.limit(limit).all()
|
||||||
|
print(
|
||||||
|
f"Showing {len(threads)} threads"
|
||||||
|
+ (f" for {account_email}" if account_email else "")
|
||||||
|
)
|
||||||
|
for t in threads:
|
||||||
|
count = db.query(Message).filter(Message.thread_id == t.id).count()
|
||||||
|
print(
|
||||||
|
f"- id={t.id} msgs={count} requires_reply={t.requires_reply} subject={t.subject!r}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def show_thread_action(thread_id: int) -> None:
|
||||||
|
create_db_tables()
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
thread = db.query(Thread).filter(Thread.id == thread_id).one_or_none()
|
||||||
|
if not thread:
|
||||||
|
print(f"Thread {thread_id} not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Thread #{thread.id} subject={thread.subject!r} account={thread.account_email} requires_reply={thread.requires_reply}"
|
||||||
|
)
|
||||||
|
messages: List[Message] = get_thread_messages(db, thread.id)
|
||||||
|
for i, m in enumerate(messages, 1):
|
||||||
|
direction = "IN" if m.is_incoming else "OUT"
|
||||||
|
snippet = (m.body or "").strip().replace("\n", " ")
|
||||||
|
if len(snippet) > 140:
|
||||||
|
snippet = snippet[:140] + "..."
|
||||||
|
print(
|
||||||
|
f"[{i}] {m.date_sent} [{direction}] {m.folder} | from={m.from_email} -> to={m.to_email}\n"
|
||||||
|
f" subject={m.subject!r}\n"
|
||||||
|
f" message_id={m.message_id} in_reply_to={m.in_reply_to}\n"
|
||||||
|
f" body={snippet}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Email alerts utility")
|
||||||
|
sub = parser.add_subparsers(dest="cmd", required=False)
|
||||||
|
|
||||||
|
p_ingest = sub.add_parser("ingest", help="Fetch INBOX and Sent and ingest into DB")
|
||||||
|
p_ingest.add_argument(
|
||||||
|
"--account", dest="account", default=os.getenv("ZOHO_EMAIL", "")
|
||||||
|
)
|
||||||
|
p_ingest.add_argument("--days-back", dest="days_back", type=int, default=7)
|
||||||
|
p_ingest.add_argument("--max-results", dest="max_results", type=int, default=50)
|
||||||
|
|
||||||
|
p_list = sub.add_parser("list-threads", help="List threads")
|
||||||
|
p_list.add_argument("--account", dest="account", default=None)
|
||||||
|
p_list.add_argument("--limit", dest="limit", type=int, default=20)
|
||||||
|
p_list.add_argument("--only-requiring", dest="only_req", action="store_true")
|
||||||
|
|
||||||
|
p_show = sub.add_parser("show-thread", help="Print all messages in a thread")
|
||||||
|
p_show.add_argument("thread_id", type=int)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.cmd == "ingest":
|
||||||
|
acct = args.account or os.getenv("ZOHO_EMAIL", "")
|
||||||
|
if not acct:
|
||||||
|
raise SystemExit("Provide --account or set ZOHO_EMAIL")
|
||||||
|
ingest_action(acct, days_back=args.days_back, max_results=args.max_results)
|
||||||
|
elif args.cmd == "list-threads":
|
||||||
|
list_threads_action(
|
||||||
|
account_email=args.account, limit=args.limit, only_requiring=args.only_req
|
||||||
|
)
|
||||||
|
elif args.cmd == "show-thread":
|
||||||
|
show_thread_action(args.thread_id)
|
||||||
|
else:
|
||||||
|
# Default behavior: run ingest using env and then list requiring-reply threads
|
||||||
|
acct = os.getenv("ZOHO_EMAIL", "")
|
||||||
|
if not acct:
|
||||||
|
parser.print_help()
|
||||||
|
raise SystemExit(0)
|
||||||
|
ingest_action(acct)
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0b1020;
|
||||||
|
--panel: #11162a;
|
||||||
|
--panel-soft: #151b30;
|
||||||
|
--text: #e6e8ef;
|
||||||
|
--muted: #a6adc8;
|
||||||
|
--brand: #4f8cff;
|
||||||
|
--brand-weak: rgba(79, 140, 255, 0.15);
|
||||||
|
--success: #22c55e;
|
||||||
|
--warn: #f59e0b;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--border: #23304f;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, "Helvetica Neue", Arial;
|
||||||
|
color: var(--text);
|
||||||
|
background: radial-gradient(1200px 800px at 20% -10%, #1a2140 0%, var(--bg) 45%), var(--bg);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
background: rgba(10, 14, 28, 0.7);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
header .inner {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 1rem; padding: 0.75rem 1rem; max-width: 1100px; margin: 0 auto;
|
||||||
|
}
|
||||||
|
header h1 { font-size: 1.05rem; margin: 0; letter-spacing: 0.4px; }
|
||||||
|
nav a { text-decoration: none; color: var(--muted); margin-left: 0.75rem; }
|
||||||
|
nav a:hover { color: var(--text); }
|
||||||
|
|
||||||
|
.container { max-width: 1100px; margin: 1.25rem auto; padding: 0 1rem; }
|
||||||
|
|
||||||
|
h2 { margin: 0.25rem 0 0.75rem; font-size: 1.2rem; }
|
||||||
|
h3 { margin: 0.5rem 0 0.5rem; font-size: 1.05rem; color: var(--muted); }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: linear-gradient(180deg, var(--panel) 0%, var(--panel-soft) 100%);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; padding: 1rem; box-shadow: 0 6px 24px rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
.card + .card { margin-top: 1rem; }
|
||||||
|
|
||||||
|
.badge { display: inline-block; padding: 0.2rem 0.5rem; border-radius: 999px; font-size: 0.75rem; border: 1px solid var(--border); background: #0e1425; color: var(--muted); }
|
||||||
|
.badge.success { color: #0f2f1d; background: #d1fae5; border-color: #86efac; }
|
||||||
|
.badge.warn { color: #3b2a07; background: #fef3c7; border-color: #fcd34d; }
|
||||||
|
.badge.danger { color: #4b0a0a; background: #fee2e2; border-color: #fca5a5; }
|
||||||
|
.badge.brand { color: #0a2a62; background: #dbe8ff; border-color: #9fc0ff; }
|
||||||
|
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
table { border-collapse: collapse; width: 100%; font-size: 0.95rem; }
|
||||||
|
th, td { border: 1px solid var(--border); padding: 10px; vertical-align: top; }
|
||||||
|
th { background: #0f152a; color: var(--muted); text-align: left; position: sticky; top: 0; }
|
||||||
|
tbody tr:hover { background: #0e1426; }
|
||||||
|
|
||||||
|
pre, code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||||
|
pre { background: #0b1121; padding: 0.75rem; border-radius: 8px; white-space: pre-wrap; border: 1px solid var(--border); }
|
||||||
|
|
||||||
|
/* Chat-style messages */
|
||||||
|
.messages { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
.msg-item { display: flex; }
|
||||||
|
.msg-item.incoming { justify-content: flex-start; }
|
||||||
|
.msg-item.outgoing { justify-content: flex-end; }
|
||||||
|
.msg-bubble {
|
||||||
|
max-width: 800px; width: fit-content;
|
||||||
|
background: #0f152a; border: 1px solid var(--border); border-radius: 12px;
|
||||||
|
padding: 0.75rem 0.9rem; box-shadow: 0 4px 20px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
.incoming .msg-bubble { background: #0f152a; }
|
||||||
|
.outgoing .msg-bubble { background: var(--brand-weak); border-color: #345fb0; }
|
||||||
|
.msg-meta { font-size: 0.78rem; color: var(--muted); margin-bottom: 0.35rem; }
|
||||||
|
.msg-subject { font-size: 0.9rem; margin-bottom: 0.25rem; color: var(--text); }
|
||||||
|
.msg-body { font-size: 0.92rem; }
|
||||||
|
|
||||||
|
.row { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||||
|
.col { flex: 1 1 360px; }
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
a { color: var(--brand); }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* Small helpers */
|
||||||
|
.pill { padding: 0.15rem 0.5rem; border-radius: 999px; border: 1px solid var(--border); }
|
||||||
|
.right { text-align: right; }
|
||||||
|
.mt-1 { margin-top: 0.5rem; } .mt-2 { margin-top: 1rem; }
|
||||||
|
.mb-1 { margin-bottom: 0.5rem; } .mb-2 { margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
label { display: block; font-size: 0.9rem; color: var(--muted); }
|
||||||
|
input[type="text"], input[type="email"], input[type="number"], input[type="password"], textarea, select {
|
||||||
|
width: 100%;
|
||||||
|
background: #0b1121;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
input:focus, textarea:focus, select:focus { border-color: #3c64b1; box-shadow: 0 0 0 3px rgba(79, 140, 255, 0.2); }
|
||||||
|
|
||||||
|
button {
|
||||||
|
appearance: none; border: 0; cursor: pointer;
|
||||||
|
background: linear-gradient(180deg, #5a8dff 0%, #3c64b1 100%);
|
||||||
|
color: white; padding: 0.55rem 0.9rem; border-radius: 8px;
|
||||||
|
box-shadow: 0 6px 18px rgba(63, 99, 183, 0.35);
|
||||||
|
}
|
||||||
|
button:hover { filter: brightness(1.05); }
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Email Alerts</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="inner">
|
||||||
|
<h1>Email Alerts</h1>
|
||||||
|
<nav>
|
||||||
|
<a href="/">Threads</a>
|
||||||
|
<a href="/config">Config</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="container">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col">
|
||||||
|
<h2>Configuration</h2>
|
||||||
|
{% if saved %}
|
||||||
|
<p class="badge success">Saved</p>
|
||||||
|
{% endif %}
|
||||||
|
<p class="muted">Edit core settings for triage, time frames, and processing.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="card" method="post" action="/config">
|
||||||
|
<h3>General</h3>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<label>Email Address<br>
|
||||||
|
<input type="email" name="email_address" value="{{ cfg.email_address }}" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label>Days Back to Fetch<br>
|
||||||
|
<input type="number" name="email_days_back" min="1" max="60" value="{{ cfg.email_days_back }}" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-1">
|
||||||
|
<div class="col">
|
||||||
|
<label>Agency Domains (comma or newline separated)<br>
|
||||||
|
<textarea name="agency_domains" rows="3">{{ cfg.agency_domains | join(', ') }}</textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="mt-2">Zoho (optional)</h3>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<label>Zoho Email<br>
|
||||||
|
<input type="text" name="zoho_email" value="{{ cfg.zoho_email }}" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label>Zoho App Password<br>
|
||||||
|
<input type="password" name="zoho_app_password" value="{{ cfg.zoho_app_password }}" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="mt-2">Processing</h3>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<label><input type="checkbox" name="auto_process" {% if cfg.auto_process %}checked{% endif %}/> Auto Process</label>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label>Process Interval (minutes)<br>
|
||||||
|
<input type="number" name="auto_process_interval" min="1" max="360" value="{{ cfg.auto_process_interval }}" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="mt-2">Alert Time Frames</h3>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Hours</th>
|
||||||
|
<th>Alert Level</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% set frames = cfg.time_frames %}
|
||||||
|
{% for i in rows %}
|
||||||
|
{% set row = frames[i] if frames and i < frames|length else None %}
|
||||||
|
<tr>
|
||||||
|
<td><input type="text" name="time_name_{{ i }}" value="{{ row.name if row else '' }}" /></td>
|
||||||
|
<td><input type="number" name="time_hours_{{ i }}" min="0" max="168" value="{{ row.hours if row else '' }}" /></td>
|
||||||
|
<td><input type="number" name="time_alert_{{ i }}" min="0" max="10" value="{{ row.alert_level if row else '' }}" /></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="right mt-2">
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col">
|
||||||
|
<h2>Thread #{{ thread.id }}</h2>
|
||||||
|
<p class="muted">Subject: <strong>{{ thread.subject }}</strong></p>
|
||||||
|
<p class="muted">Account: <span class="badge">{{ thread.account_email }}</span></p>
|
||||||
|
<p>
|
||||||
|
{% if thread.requires_reply %}
|
||||||
|
<span class="badge warn">Needs reply</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge success">Up to date</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<h3>AI Analysis</h3>
|
||||||
|
<p>
|
||||||
|
<strong>Actionable:</strong>
|
||||||
|
{% if ai.actionable %}<span class="badge warn">Yes</span>{% else %}<span class="badge success">No</span>{% endif %}
|
||||||
|
</p>
|
||||||
|
<p><strong>Summary:</strong> {{ ai.summary }}</p>
|
||||||
|
<p class="muted">Confidence: {{ ai.confidence }} • Model: {{ ai.model }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-2">
|
||||||
|
<div class="col">
|
||||||
|
<h3>Messages</h3>
|
||||||
|
<div class="messages">
|
||||||
|
{% for m in messages %}
|
||||||
|
<div class="msg-item {% if m.is_incoming %}incoming{% else %}outgoing{% endif %}">
|
||||||
|
<div class="msg-bubble">
|
||||||
|
<div class="msg-meta">{{ m.date_sent }} • {% if m.is_incoming %}Incoming{% else %}Outgoing{% endif %} • {{ m.folder }}</div>
|
||||||
|
<div class="msg-meta">From: <span class="pill">{{ m.from_email }}</span> → To: <span class="pill">{{ m.to_email }}</span></div>
|
||||||
|
<div class="msg-subject">{{ m.subject }}</div>
|
||||||
|
<div class="msg-body"><pre>{{ m.body }}</pre></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No messages.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col">
|
||||||
|
<h2>Threads{% if account %} for {{ account }}{% endif %}</h2>
|
||||||
|
<p class="muted">Latest updated threads. Click an ID to view details and AI analysis.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Subject</th>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Msgs</th>
|
||||||
|
<th>Requires Reply</th>
|
||||||
|
<th>Updated</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in threads %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/thread/{{ t.id }}">{{ t.id }}</a></td>
|
||||||
|
<td>{{ t.subject }}</td>
|
||||||
|
<td><span class="badge">{{ t.account_email }}</span></td>
|
||||||
|
<td><span class="badge brand">{{ t.messages|length }}</span></td>
|
||||||
|
<td>
|
||||||
|
{% if t.requires_reply %}
|
||||||
|
<span class="badge warn">Needs reply</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge success">Up to date</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="muted">{{ t.updated_at }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="6">No threads yet</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import os
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from twilio.base.exceptions import TwilioException
|
||||||
|
from twilio.rest import Client
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsAppSender:
|
||||||
|
def __init__(self):
|
||||||
|
self.account_sid = os.getenv("TWILIO_ACCOUNT_SID")
|
||||||
|
self.auth_token = os.getenv("TWILIO_AUTH_TOKEN")
|
||||||
|
self.from_number = os.getenv("TWILIO_WHATSAPP_NUMBER")
|
||||||
|
self.to_number = os.getenv("WHATSAPP_TO_NUMBER") # Individual phone number
|
||||||
|
|
||||||
|
if self.account_sid and self.auth_token:
|
||||||
|
try:
|
||||||
|
self.client = Client(self.account_sid, self.auth_token)
|
||||||
|
self.use_mock = False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Twilio client failed to initialize: {e}")
|
||||||
|
self.use_mock = True
|
||||||
|
else:
|
||||||
|
self.use_mock = True
|
||||||
|
print("Note: Using mock WhatsApp sender (add Twilio credentials to .env)")
|
||||||
|
|
||||||
|
# Use real WhatsApp mode
|
||||||
|
self.use_mock = False
|
||||||
|
print("📱 Using WhatsApp mode")
|
||||||
|
|
||||||
|
def send_alert(self, alert_message: str, thread_id: str = None) -> Dict[str, Any]:
|
||||||
|
"""Send alert message to WhatsApp"""
|
||||||
|
if self.use_mock:
|
||||||
|
return self._mock_send(alert_message, thread_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Format message for WhatsApp
|
||||||
|
formatted_message = self._format_message(alert_message)
|
||||||
|
|
||||||
|
# Send to WhatsApp
|
||||||
|
message = self.client.messages.create(
|
||||||
|
from_=f"whatsapp:{self.from_number}",
|
||||||
|
body=formatted_message,
|
||||||
|
to=f"whatsapp:{self.to_number}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message_sid": message.sid,
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"sent_at": message.date_created,
|
||||||
|
}
|
||||||
|
|
||||||
|
except TwilioException as e:
|
||||||
|
print(f"WhatsApp send error: {e}")
|
||||||
|
return {"status": "error", "error": str(e), "thread_id": thread_id}
|
||||||
|
|
||||||
|
def _mock_send(self, alert_message: str, thread_id: str = None) -> Dict[str, Any]:
|
||||||
|
"""Mock WhatsApp sending for testing"""
|
||||||
|
print("📱 [MOCK] WhatsApp Alert Sent:")
|
||||||
|
print(f" To: {self.to_number or 'your_number'}")
|
||||||
|
print(f" Thread ID: {thread_id}")
|
||||||
|
print(f" Message: {alert_message[:100]}...")
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message_sid": "mock_sid_123",
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"sent_at": "2024-01-15T10:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _format_message(self, alert_message: str) -> str:
|
||||||
|
"""Format alert message for WhatsApp"""
|
||||||
|
# WhatsApp has character limits, so we might need to truncate
|
||||||
|
max_length = 1000
|
||||||
|
if len(alert_message) > max_length:
|
||||||
|
alert_message = alert_message[: max_length - 3] + "..."
|
||||||
|
|
||||||
|
return alert_message
|
||||||
|
|
||||||
|
def send_bulk_alerts(self, alerts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
|
"""Send multiple alerts to WhatsApp"""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for alert in alerts:
|
||||||
|
message = alert.get("message", "")
|
||||||
|
thread_id = alert.get("thread_id", "unknown")
|
||||||
|
|
||||||
|
result = self.send_alert(message, thread_id)
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
# Add small delay between messages to avoid rate limits
|
||||||
|
import time
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Test WhatsApp sender
|
||||||
|
sender = WhatsAppSender()
|
||||||
|
|
||||||
|
test_message = """
|
||||||
|
🚨 LEVEL 1 ALERT (24 Hours)
|
||||||
|
|
||||||
|
🟢 Urgency: LOW
|
||||||
|
📧 Thread ID: test_thread_123
|
||||||
|
|
||||||
|
📝 Summary:
|
||||||
|
Client inquiry about project status. Requires follow-up.
|
||||||
|
|
||||||
|
🎯 Action Required:
|
||||||
|
Respond to client question
|
||||||
|
|
||||||
|
⏰ Confidence: 70.0%
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
result = sender.send_alert(test_message, "test_thread_123")
|
||||||
|
print(f"Send result: {result}")
|
||||||
+280
@@ -0,0 +1,280 @@
|
|||||||
|
import email
|
||||||
|
import imaplib
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from email.header import decode_header
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
class ZohoClient:
|
||||||
|
def __init__(self, email=None, app_password=None):
|
||||||
|
self.imap_server = "imap.zoho.com"
|
||||||
|
self.imap_port = 993
|
||||||
|
# Use provided credentials or fall back to environment variables
|
||||||
|
self.email = email or os.getenv("ZOHO_EMAIL", "")
|
||||||
|
self.app_password = app_password or os.getenv("ZOHO_PASSWORD", "")
|
||||||
|
|
||||||
|
if not self.email or not self.app_password:
|
||||||
|
raise ValueError("Zoho email and app password must be provided")
|
||||||
|
|
||||||
|
self.connection = None
|
||||||
|
self._connect()
|
||||||
|
|
||||||
|
def _connect(self):
|
||||||
|
"""Connect to Zoho IMAP server using app password"""
|
||||||
|
try:
|
||||||
|
self.connection = imaplib.IMAP4_SSL(self.imap_server, self.imap_port)
|
||||||
|
self.connection.login(self.email, self.app_password)
|
||||||
|
print(f"✅ Connected to Zoho IMAP server as {self.email}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Failed to connect to Zoho IMAP: {e}")
|
||||||
|
print("💡 Make sure IMAP is enabled in your Zoho Mail settings")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def fetch_folder_emails(
|
||||||
|
self,
|
||||||
|
folder: str = "INBOX",
|
||||||
|
query: str = None,
|
||||||
|
max_results: int = None,
|
||||||
|
days_back: int = 7,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Fetch emails from a given folder with date filtering"""
|
||||||
|
try:
|
||||||
|
# Select folder
|
||||||
|
mailbox = '"Sent"' if folder.lower() == "sent" else folder
|
||||||
|
print(f"📥 Selecting {folder}...[STEP 2]")
|
||||||
|
self.connection.select(mailbox)
|
||||||
|
|
||||||
|
# Build search criteria - only emails from specified days back
|
||||||
|
days_ago = (datetime.now() - timedelta(days=days_back)).strftime("%d-%b-%Y")
|
||||||
|
search_criteria = f"SINCE {days_ago}"
|
||||||
|
|
||||||
|
if query:
|
||||||
|
search_criteria += f" {query}"
|
||||||
|
|
||||||
|
# Search for emails
|
||||||
|
status, message_numbers = self.connection.search(None, search_criteria)
|
||||||
|
|
||||||
|
if status != "OK":
|
||||||
|
print(f"❌ Search failed: {status}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
email_list = message_numbers[0].split()
|
||||||
|
|
||||||
|
# Limit results if specified
|
||||||
|
if max_results is not None:
|
||||||
|
email_list = email_list[-max_results:] # Get the most recent emails
|
||||||
|
|
||||||
|
emails = []
|
||||||
|
for i, num in enumerate(email_list):
|
||||||
|
try:
|
||||||
|
print(f"📧 Fetching email {num.decode()}... [STEP 3] {i}")
|
||||||
|
# Fetch email data
|
||||||
|
status, data = self.connection.fetch(num, "(RFC822)")
|
||||||
|
|
||||||
|
if status == "OK":
|
||||||
|
raw_email = data[0][1]
|
||||||
|
email_message = email.message_from_bytes(raw_email)
|
||||||
|
|
||||||
|
# Extract headers
|
||||||
|
subject = self._decode_header(email_message.get("Subject", ""))
|
||||||
|
from_header = self._decode_header(email_message.get("From", ""))
|
||||||
|
to_header = self._decode_header(email_message.get("To", ""))
|
||||||
|
date_header = email_message.get("Date", "")
|
||||||
|
message_id = email_message.get("Message-ID", "")
|
||||||
|
in_reply_to = email_message.get("In-Reply-To", "")
|
||||||
|
|
||||||
|
# Generate thread ID (using Message-ID as fallback)
|
||||||
|
thread_id = message_id or f"thread_{num.decode()}"
|
||||||
|
|
||||||
|
# Get email body snippet
|
||||||
|
body = self._get_email_body(email_message)
|
||||||
|
snippet = body[:200] + "..." if len(body) > 200 else body
|
||||||
|
|
||||||
|
email_data = {
|
||||||
|
"id": num.decode(),
|
||||||
|
"threadId": thread_id,
|
||||||
|
"from": from_header,
|
||||||
|
"to": to_header,
|
||||||
|
"subject": subject,
|
||||||
|
"date": date_header,
|
||||||
|
"messageId": message_id,
|
||||||
|
"inReplyTo": in_reply_to,
|
||||||
|
"folder": folder,
|
||||||
|
"snippet": snippet,
|
||||||
|
}
|
||||||
|
|
||||||
|
emails.append(email_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error processing email {num}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"📧 Fetched {len(emails)} real emails from {folder} for last {days_back} days"
|
||||||
|
)
|
||||||
|
return emails
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error fetching emails from {folder}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def fetch_emails(
|
||||||
|
self, query: str = None, max_results: int = None, days_back: int = 7
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Fetch emails from INBOX (backwards-compatible wrapper)."""
|
||||||
|
return self.fetch_folder_emails(
|
||||||
|
folder="INBOX", query=query, max_results=max_results, days_back=days_back
|
||||||
|
)
|
||||||
|
|
||||||
|
def _decode_header(self, header_value: str) -> str:
|
||||||
|
"""Decode email header values"""
|
||||||
|
if not header_value:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
decoded_parts = decode_header(header_value)
|
||||||
|
decoded_string = ""
|
||||||
|
|
||||||
|
for part, encoding in decoded_parts:
|
||||||
|
if isinstance(part, bytes):
|
||||||
|
if encoding:
|
||||||
|
decoded_string += part.decode(encoding)
|
||||||
|
else:
|
||||||
|
decoded_string += part.decode("utf-8", errors="ignore")
|
||||||
|
else:
|
||||||
|
decoded_string += str(part)
|
||||||
|
|
||||||
|
return decoded_string
|
||||||
|
except Exception:
|
||||||
|
return str(header_value)
|
||||||
|
|
||||||
|
def _get_email_body(self, email_message) -> str:
|
||||||
|
"""Extract email body text"""
|
||||||
|
body = ""
|
||||||
|
|
||||||
|
if email_message.is_multipart():
|
||||||
|
for part in email_message.walk():
|
||||||
|
if part.get_content_type() == "text/plain":
|
||||||
|
try:
|
||||||
|
body += part.get_payload(decode=True).decode(
|
||||||
|
"utf-8", errors="ignore"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
body = email_message.get_payload(decode=True).decode(
|
||||||
|
"utf-8", errors="ignore"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return body
|
||||||
|
|
||||||
|
def get_thread_messages(self, thread_id: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all messages in a thread (simplified for IMAP)"""
|
||||||
|
# For IMAP, we'll return a single message since thread grouping is more complex
|
||||||
|
# This is a simplified implementation
|
||||||
|
return []
|
||||||
|
|
||||||
|
def check_sent_folder_for_replies(self, subject: str, days_back: int = 7) -> bool:
|
||||||
|
"""Check SENT folder to see if we've replied to emails with this subject"""
|
||||||
|
try:
|
||||||
|
# Select SENT folder
|
||||||
|
self.connection.select('"Sent"') # Zoho uses "Sent" folder
|
||||||
|
|
||||||
|
# Build search criteria for sent emails within the date range
|
||||||
|
days_ago = (datetime.now() - timedelta(days=days_back)).strftime("%d-%b-%Y")
|
||||||
|
|
||||||
|
# Search for emails with similar subject (remove "Re:" prefixes for matching)
|
||||||
|
clean_subject = (
|
||||||
|
subject.replace("Re: ", "").replace("RE: ", "").replace("re: ", "")
|
||||||
|
)
|
||||||
|
search_criteria = f'SINCE {days_ago} SUBJECT "{clean_subject}"'
|
||||||
|
|
||||||
|
status, message_numbers = self.connection.search(None, search_criteria)
|
||||||
|
|
||||||
|
if status == "OK" and message_numbers[0]:
|
||||||
|
# Found sent emails with matching subject
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error checking sent folder: {e}")
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
# Always return to INBOX
|
||||||
|
try:
|
||||||
|
self.connection.select("INBOX")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def check_message_reply_status(
|
||||||
|
self, message_id: str, subject: str, days_back: int = 7
|
||||||
|
) -> bool:
|
||||||
|
"""Check if a specific message has been replied to by checking SENT folder"""
|
||||||
|
try:
|
||||||
|
# First check by subject in SENT folder
|
||||||
|
if self.check_sent_folder_for_replies(subject, days_back):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Additional check: look for In-Reply-To headers matching our message ID
|
||||||
|
self.connection.select('"Sent"')
|
||||||
|
days_ago = (datetime.now() - timedelta(days=days_back)).strftime("%d-%b-%Y")
|
||||||
|
|
||||||
|
# Search all sent emails in date range
|
||||||
|
status, message_numbers = self.connection.search(None, f"SINCE {days_ago}")
|
||||||
|
|
||||||
|
if status == "OK" and message_numbers[0]:
|
||||||
|
email_list = message_numbers[0].split()
|
||||||
|
|
||||||
|
for num in email_list[
|
||||||
|
-20:
|
||||||
|
]: # Check last 20 sent emails for performance
|
||||||
|
try:
|
||||||
|
status, data = self.connection.fetch(num, "(RFC822)")
|
||||||
|
if status == "OK":
|
||||||
|
raw_email = data[0][1]
|
||||||
|
email_message = email.message_from_bytes(raw_email)
|
||||||
|
|
||||||
|
# Check In-Reply-To header
|
||||||
|
in_reply_to = email_message.get("In-Reply-To", "")
|
||||||
|
if message_id and message_id in in_reply_to:
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error checking message reply status: {e}")
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
# Always return to INBOX
|
||||||
|
try:
|
||||||
|
self.connection.select("INBOX")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Close the IMAP connection"""
|
||||||
|
if self.connection:
|
||||||
|
try:
|
||||||
|
self.connection.close()
|
||||||
|
self.connection.logout()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error closing connection: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
client = ZohoClient()
|
||||||
|
emails = client.fetch_emails(max_results=10)
|
||||||
|
print(f"Fetched {len(emails)} emails")
|
||||||
|
client.close()
|
||||||
Reference in New Issue
Block a user