Files
email_alerts_v2/src/database.py
T
bolade d553d6f31e Implement email alert system with WhatsApp notifications
- Added alerts processing logic in src/alerts.py to analyze threads and send WhatsApp alerts based on configured time frames.
- Created FastAPI application in src/app.py to manage threads, display configurations, and trigger alert processing.
- Developed database models and utility functions in src/database.py for managing threads and messages.
- Integrated Twilio API for sending WhatsApp messages in src/whatsapp_sender.py.
- Implemented Zoho email client in src/zoho_client.py to fetch emails and check for replies.
- Added configuration management for email settings and alert parameters.
- Established auto-processing loop for periodic email syncing and alert generation.
2025-08-11 17:34:35 +01:00

393 lines
12 KiB
Python

from email.utils import parseaddr
from typing import Annotated, Iterable, List, Optional, Tuple
from fastapi import Depends
from sqlalchemy import (
Boolean,
Column,
DateTime,
Float,
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)
_migrate_add_missing_columns()
def _column_exists(table: str, column: str) -> bool:
try:
with engine.connect() as conn:
rows = conn.exec_driver_sql(f"PRAGMA table_info('{table}')").fetchall()
cols = {row[1] for row in rows}
return column in cols
except Exception:
return False
def _migrate_add_missing_columns() -> None:
"""Best-effort SQLite migrations for newly added columns."""
try:
with engine.begin() as conn:
if not _column_exists("threads", "actionable"):
conn.exec_driver_sql(
"ALTER TABLE threads ADD COLUMN actionable BOOLEAN NOT NULL DEFAULT 0"
)
if not _column_exists("threads", "ai_summary"):
conn.exec_driver_sql("ALTER TABLE threads ADD COLUMN ai_summary TEXT")
if not _column_exists("threads", "ai_confidence"):
conn.exec_driver_sql(
"ALTER TABLE threads ADD COLUMN ai_confidence FLOAT"
)
if not _column_exists("threads", "last_analyzed_at"):
conn.exec_driver_sql(
"ALTER TABLE threads ADD COLUMN last_analyzed_at DATETIME"
)
if not _column_exists("threads", "last_alert_level_sent"):
conn.exec_driver_sql(
"ALTER TABLE threads ADD COLUMN last_alert_level_sent INTEGER NOT NULL DEFAULT 0"
)
if not _column_exists("threads", "last_alert_sent_at"):
conn.exec_driver_sql(
"ALTER TABLE threads ADD COLUMN last_alert_sent_at DATETIME"
)
except Exception as e:
print(f"DB migration warning: {e}")
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)
# AI/alert fields
actionable = Column(Boolean, nullable=False, default=False)
ai_summary = Column(Text)
ai_confidence = Column(Float)
last_analyzed_at = Column(DateTime)
last_alert_level_sent = Column(Integer, nullable=False, default=0)
last_alert_sent_at = Column(DateTime)
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 get_last_incoming_outgoing(
db: Session, thread_id: int
) -> Tuple[Optional["Message"], Optional["Message"]]:
"""Return the last incoming and outgoing messages for a thread."""
last_incoming = (
db.query(Message)
.filter(Message.thread_id == thread_id, Message.is_incoming.is_(True))
.order_by(Message.date_sent.desc(), Message.id.desc())
.first()
)
last_outgoing = (
db.query(Message)
.filter(Message.thread_id == thread_id, Message.is_incoming.is_(False))
.order_by(Message.date_sent.desc(), Message.id.desc())
.first()
)
return last_incoming, last_outgoing
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()