Step 5: Web Interface
In this lesson, you will build the web dashboard that ties everything together. Using Flask on the backend and vanilla JavaScript on the frontend, you will create a priority inbox view, draft review/edit panel, action item tracker, and a complete send workflow. No frontend frameworks required.
Flask Routes
The backend API provides endpoints for all dashboard features. Each route returns JSON for the frontend to render:
# app/web/routes.py
"""Flask routes for the email assistant dashboard."""
from datetime import datetime
from flask import (
Flask, render_template, request, jsonify, redirect, url_for
)
from app.config import config
from app.database import SessionLocal, Email, Classification, Draft, ActionItem, FollowUp
from app.gmail.client import GmailClient
from app.ai.classifier import classify_email
from app.ai.drafter import (
generate_draft, generate_and_store_draft,
generate_from_template, RESPONSE_TEMPLATES
)
from app.ai.smart import (
summarize_email, extract_and_store_action_items,
generate_daily_digest, get_pending_follow_ups
)
def create_app() -> Flask:
"""Create and configure the Flask application."""
app = Flask(
__name__,
template_folder="templates",
static_folder="static",
)
app.secret_key = config.flask_secret_key
register_routes(app)
return app
def register_routes(app: Flask):
"""Register all routes on the Flask app."""
# ─── Dashboard Home ─────────────────────────────────
@app.route("/")
def dashboard():
"""Main dashboard with priority inbox."""
return render_template("inbox.html")
# ─── Inbox API ──────────────────────────────────────
@app.route("/api/emails")
def api_emails():
"""Get emails with classifications, sorted by priority."""
priority = request.args.get("priority")
category = request.args.get("category")
limit = int(request.args.get("limit", 50))
session = SessionLocal()
try:
query = (
session.query(Email, Classification)
.outerjoin(Classification)
.order_by(Email.date.desc())
)
if priority:
query = query.filter(Classification.priority == priority)
if category:
query = query.filter(Classification.category == category)
results = query.limit(limit).all()
emails = []
for email, classification in results:
emails.append({
"id": email.id,
"gmail_id": email.gmail_id,
"subject": email.subject,
"sender": email.sender,
"sender_name": email.sender_name,
"date": email.date.isoformat() if email.date else None,
"snippet": email.snippet,
"is_read": email.is_read,
"has_attachments": email.has_attachments,
"priority": classification.priority if classification else None,
"category": classification.category if classification else None,
"sentiment": classification.sentiment if classification else None,
"summary": classification.summary if classification else None,
})
return jsonify({"emails": emails, "total": len(emails)})
finally:
session.close()
@app.route("/api/emails/<int:email_id>")
def api_email_detail(email_id):
"""Get full email details with classification and drafts."""
session = SessionLocal()
try:
email = session.query(Email).get(email_id)
if not email:
return jsonify({"error": "Email not found"}), 404
classification = (
session.query(Classification)
.filter(Classification.email_id == email_id)
.first()
)
drafts = (
session.query(Draft)
.filter(Draft.email_id == email_id)
.order_by(Draft.created_at.desc())
.all()
)
action_items = (
session.query(ActionItem)
.filter(ActionItem.email_id == email_id)
.all()
)
return jsonify({
"email": {
"id": email.id,
"subject": email.subject,
"sender": email.sender,
"sender_name": email.sender_name,
"date": email.date.isoformat() if email.date else None,
"body_text": email.body_text,
"body_html": email.body_html,
"is_read": email.is_read,
"has_attachments": email.has_attachments,
},
"classification": {
"priority": classification.priority,
"category": classification.category,
"sentiment": classification.sentiment,
"confidence": classification.confidence,
"summary": classification.summary,
} if classification else None,
"drafts": [
{
"id": d.id,
"content": d.content,
"tone": d.tone,
"status": d.status,
"created_at": d.created_at.isoformat(),
}
for d in drafts
],
"action_items": [
{
"id": ai.id,
"description": ai.description,
"due_date": ai.due_date.isoformat() if ai.due_date else None,
"is_completed": ai.is_completed,
}
for ai in action_items
],
})
finally:
session.close()
# ─── Draft API ──────────────────────────────────────
@app.route("/api/drafts/generate", methods=["POST"])
def api_generate_draft():
"""Generate a new draft for an email."""
data = request.json
email_id = data.get("email_id")
tone = data.get("tone", "professional")
instructions = data.get("instructions", "")
try:
draft = generate_and_store_draft(
email_id=email_id,
tone=tone,
additional_instructions=instructions,
)
return jsonify({
"draft": {
"id": draft.id,
"content": draft.content,
"tone": draft.tone,
"status": draft.status,
}
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/drafts/<int:draft_id>/update", methods=["PUT"])
def api_update_draft(draft_id):
"""Update a draft's content."""
data = request.json
session = SessionLocal()
try:
draft = session.query(Draft).get(draft_id)
if not draft:
return jsonify({"error": "Draft not found"}), 404
draft.content = data.get("content", draft.content)
session.commit()
return jsonify({"status": "updated"})
finally:
session.close()
@app.route("/api/drafts/<int:draft_id>/send", methods=["POST"])
def api_send_draft(draft_id):
"""Send a draft as an email reply."""
session = SessionLocal()
try:
draft = session.query(Draft).get(draft_id)
if not draft:
return jsonify({"error": "Draft not found"}), 404
email = session.query(Email).get(draft.email_id)
if not email:
return jsonify({"error": "Original email not found"}), 404
# Send via Gmail API
gmail_client = GmailClient()
result = gmail_client.send_message(
to=email.sender,
subject=f"Re: {email.subject}",
body=draft.content,
thread_id=email.thread_id,
)
# Update draft status
draft.status = "sent"
draft.sent_at = datetime.utcnow()
session.commit()
return jsonify({
"status": "sent",
"message_id": result.get("id"),
})
except Exception as e:
session.rollback()
return jsonify({"error": str(e)}), 500
finally:
session.close()
# ─── Action Items API ───────────────────────────────
@app.route("/api/action-items")
def api_action_items():
"""Get all pending action items."""
session = SessionLocal()
try:
items = (
session.query(ActionItem, Email)
.join(Email)
.filter(ActionItem.is_completed == False)
.order_by(ActionItem.due_date.asc().nullslast())
.all()
)
return jsonify({
"action_items": [
{
"id": ai.id,
"description": ai.description,
"due_date": ai.due_date.isoformat() if ai.due_date else None,
"is_completed": ai.is_completed,
"email_subject": e.subject,
"email_sender": e.sender_name or e.sender,
}
for ai, e in items
]
})
finally:
session.close()
@app.route("/api/action-items/<int:item_id>/complete", methods=["POST"])
def api_complete_action_item(item_id):
"""Mark an action item as completed."""
session = SessionLocal()
try:
item = session.query(ActionItem).get(item_id)
if not item:
return jsonify({"error": "Not found"}), 404
item.is_completed = True
session.commit()
return jsonify({"status": "completed"})
finally:
session.close()
# ─── Digest API ─────────────────────────────────────
@app.route("/api/digest")
def api_digest():
"""Generate and return a daily digest."""
try:
digest = generate_daily_digest()
return jsonify({"digest": digest})
except Exception as e:
return jsonify({"error": str(e)}), 500
# ─── Health Check ───────────────────────────────────
@app.route("/api/health")
def api_health():
"""Health check endpoint."""
return jsonify({
"status": "healthy",
"model": config.openai_model,
"poll_interval": config.poll_interval_minutes,
})
Dashboard HTML Template
The base template provides the layout shell. Jinja2 handles server-side rendering, and vanilla JavaScript handles dynamic updates:
<!-- app/web/templates/base.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}AI Email Assistant{% endblock %}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f5; color: #333;
}
.app-header {
background: #6366f1; color: white; padding: 1rem 2rem;
display: flex; align-items: center; justify-content: space-between;
}
.app-header h1 { font-size: 1.25rem; }
.app-nav { display: flex; gap: 1rem; }
.app-nav a {
color: rgba(255,255,255,0.8); text-decoration: none;
padding: 0.5rem 1rem; border-radius: 6px;
}
.app-nav a:hover, .app-nav a.active {
color: white; background: rgba(255,255,255,0.15);
}
.container { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
.stats-bar {
display: grid; grid-template-columns: repeat(4, 1fr);
gap: 1rem; margin-bottom: 1.5rem;
}
.stat-card {
background: white; border-radius: 8px; padding: 1.25rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1); text-align: center;
}
.stat-card .number { font-size: 2rem; font-weight: 700; color: #6366f1; }
.stat-card .label { font-size: 0.875rem; color: #666; margin-top: 0.25rem; }
.email-list { background: white; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.email-item {
display: flex; align-items: center; padding: 1rem 1.5rem;
border-bottom: 1px solid #eee; cursor: pointer; transition: background 0.15s;
}
.email-item:hover { background: #f8f9fa; }
.email-item.unread { font-weight: 600; }
.priority-badge {
padding: 0.2rem 0.6rem; border-radius: 12px;
font-size: 0.7rem; font-weight: 600; text-transform: uppercase;
margin-right: 1rem; min-width: 60px; text-align: center;
}
.priority-urgent { background: #fee2e2; color: #dc2626; }
.priority-high { background: #fef3c7; color: #d97706; }
.priority-normal { background: #dbeafe; color: #2563eb; }
.priority-low { background: #f3f4f6; color: #6b7280; }
.email-sender { width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.email-subject { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.email-date { color: #999; font-size: 0.875rem; margin-left: 1rem; }
.email-detail { display: none; background: white; border-radius: 8px; padding: 2rem; margin-top: 1rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.draft-panel { background: #f8f9fa; border-radius: 8px; padding: 1.5rem; margin-top: 1rem; }
.draft-panel textarea {
width: 100%; min-height: 150px; padding: 1rem;
border: 1px solid #ddd; border-radius: 6px; font-family: inherit;
font-size: 0.95rem; resize: vertical;
}
.btn {
padding: 0.5rem 1.25rem; border: none; border-radius: 6px;
font-size: 0.875rem; cursor: pointer; font-weight: 500;
}
.btn-primary { background: #6366f1; color: white; }
.btn-primary:hover { background: #5558e6; }
.btn-success { background: #10b981; color: white; }
.btn-outline { background: white; border: 1px solid #ddd; color: #333; }
.btn-group { display: flex; gap: 0.5rem; margin-top: 1rem; }
.filter-bar { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.filter-bar button {
padding: 0.4rem 1rem; border: 1px solid #ddd; background: white;
border-radius: 20px; cursor: pointer; font-size: 0.8rem;
}
.filter-bar button.active { background: #6366f1; color: white; border-color: #6366f1; }
.action-items { margin-top: 1.5rem; }
.action-item {
display: flex; align-items: center; padding: 0.75rem 1rem;
border-bottom: 1px solid #eee;
}
.action-item input[type="checkbox"] { margin-right: 1rem; }
.action-item.completed { text-decoration: line-through; opacity: 0.5; }
</style>
</head>
<body>
<header class="app-header">
<h1>AI Email Assistant</h1>
<nav class="app-nav">
<a href="/" class="active">Inbox</a>
<a href="#" onclick="showActionItems()">Action Items</a>
<a href="#" onclick="showDigest()">Digest</a>
</nav>
</header>
<div class="container">
{% block content %}{% endblock %}
</div>
{% block scripts %}{% endblock %}
</body>
</html>
Inbox Template with JavaScript
<!-- app/web/templates/inbox.html -->
{% extends "base.html" %}
{% block title %}Inbox - AI Email Assistant{% endblock %}
{% block content %}
<div class="stats-bar" id="stats-bar">
<div class="stat-card">
<div class="number" id="stat-total">-</div>
<div class="label">Total Emails</div>
</div>
<div class="stat-card">
<div class="number" id="stat-urgent" style="color:#dc2626">-</div>
<div class="label">Urgent</div>
</div>
<div class="stat-card">
<div class="number" id="stat-drafts" style="color:#10b981">-</div>
<div class="label">Pending Drafts</div>
</div>
<div class="stat-card">
<div class="number" id="stat-actions" style="color:#d97706">-</div>
<div class="label">Action Items</div>
</div>
</div>
<div class="filter-bar">
<button class="active" onclick="filterEmails(null, this)">All</button>
<button onclick="filterEmails('urgent', this)">Urgent</button>
<button onclick="filterEmails('high', this)">High</button>
<button onclick="filterEmails('normal', this)">Normal</button>
<button onclick="filterEmails('low', this)">Low</button>
</div>
<div class="email-list" id="email-list">
<p style="padding:2rem;text-align:center;color:#999">Loading emails...</p>
</div>
<div class="email-detail" id="email-detail"></div>
{% endblock %}
{% block scripts %}
<script>
let currentFilter = null;
async function loadEmails(priority) {
const params = new URLSearchParams();
if (priority) params.set('priority', priority);
params.set('limit', '50');
const resp = await fetch('/api/emails?' + params);
const data = await resp.json();
document.getElementById('stat-total').textContent = data.total;
const list = document.getElementById('email-list');
if (data.emails.length === 0) {
list.innerHTML = '<p style="padding:2rem;text-align:center;color:#999">No emails found</p>';
return;
}
let urgentCount = 0;
list.innerHTML = data.emails.map(e => {
if (e.priority === 'urgent') urgentCount++;
const priorityClass = e.priority ? 'priority-' + e.priority : 'priority-normal';
const unreadClass = !e.is_read ? 'unread' : '';
const date = e.date ? new Date(e.date).toLocaleDateString() : '';
return `
<div class="email-item ${unreadClass}" onclick="showEmail(${e.id})">
<span class="priority-badge ${priorityClass}">${e.priority || 'new'}</span>
<span class="email-sender">${e.sender_name || e.sender}</span>
<span class="email-subject">${e.subject}</span>
<span class="email-date">${date}</span>
</div>`;
}).join('');
document.getElementById('stat-urgent').textContent = urgentCount;
}
async function showEmail(emailId) {
const resp = await fetch('/api/emails/' + emailId);
const data = await resp.json();
const detail = document.getElementById('email-detail');
detail.style.display = 'block';
const e = data.email;
const c = data.classification;
const drafts = data.drafts;
const actions = data.action_items;
let html = `
<h2>${e.subject}</h2>
<p><strong>From:</strong> ${e.sender_name} <${e.sender}></p>
<p><strong>Date:</strong> ${e.date ? new Date(e.date).toLocaleString() : 'Unknown'}</p>`;
if (c) {
html += `
<p>
<span class="priority-badge priority-${c.priority}">${c.priority}</span>
<strong>Category:</strong> ${c.category} |
<strong>Sentiment:</strong> ${c.sentiment}
</p>
<p><em>${c.summary}</em></p>`;
}
html += `<hr style="margin:1rem 0">`;
html += `<div style="white-space:pre-wrap">${e.body_text || 'No text content'}</div>`;
// Draft section
html += `
<div class="draft-panel">
<h3>Reply Draft</h3>`;
if (drafts.length > 0) {
const latest = drafts[0];
html += `
<textarea id="draft-content">${latest.content}</textarea>
<div class="btn-group">
<button class="btn btn-success" onclick="sendDraft(${latest.id})">Send Reply</button>
<button class="btn btn-outline" onclick="updateDraft(${latest.id})">Save Changes</button>
<button class="btn btn-outline" onclick="regenerateDraft(${emailId})">Regenerate</button>
</div>`;
} else {
html += `
<p>No draft yet. Generate one:</p>
<div class="btn-group">
<button class="btn btn-primary" onclick="generateDraft(${emailId}, 'professional')">Professional</button>
<button class="btn btn-outline" onclick="generateDraft(${emailId}, 'friendly')">Friendly</button>
<button class="btn btn-outline" onclick="generateDraft(${emailId}, 'brief')">Brief</button>
</div>`;
}
html += `</div>`;
// Action items
if (actions.length > 0) {
html += `<div class="action-items"><h3>Action Items</h3>`;
actions.forEach(ai => {
const checked = ai.is_completed ? 'checked' : '';
const cls = ai.is_completed ? 'completed' : '';
html += `
<div class="action-item ${cls}">
<input type="checkbox" ${checked} onchange="toggleAction(${ai.id})">
<span>${ai.description}</span>
${ai.due_date ? '<span style="margin-left:auto;color:#999">Due: ' + new Date(ai.due_date).toLocaleDateString() + '</span>' : ''}
</div>`;
});
html += `</div>`;
}
detail.innerHTML = html;
}
async function generateDraft(emailId, tone) {
const resp = await fetch('/api/drafts/generate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({email_id: emailId, tone: tone})
});
const data = await resp.json();
if (data.draft) showEmail(emailId);
}
async function sendDraft(draftId) {
if (!confirm('Send this reply?')) return;
const resp = await fetch('/api/drafts/' + draftId + '/send', {method: 'POST'});
const data = await resp.json();
alert(data.status === 'sent' ? 'Reply sent!' : 'Error: ' + data.error);
}
async function updateDraft(draftId) {
const content = document.getElementById('draft-content').value;
await fetch('/api/drafts/' + draftId + '/update', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({content: content})
});
alert('Draft saved');
}
function regenerateDraft(emailId) {
generateDraft(emailId, 'professional');
}
async function toggleAction(itemId) {
await fetch('/api/action-items/' + itemId + '/complete', {method: 'POST'});
}
function filterEmails(priority, btn) {
document.querySelectorAll('.filter-bar button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
loadEmails(priority);
}
async function showActionItems() {
const resp = await fetch('/api/action-items');
const data = await resp.json();
const detail = document.getElementById('email-detail');
detail.style.display = 'block';
let html = '<h2>Action Items</h2>';
data.action_items.forEach(ai => {
html += `
<div class="action-item">
<input type="checkbox" ${ai.is_completed ? 'checked' : ''} onchange="toggleAction(${ai.id})">
<div>
<strong>${ai.description}</strong><br>
<small>From: ${ai.email_sender} - ${ai.email_subject}</small>
</div>
${ai.due_date ? '<span style="margin-left:auto;color:#999">Due: ' + new Date(ai.due_date).toLocaleDateString() + '</span>' : ''}
</div>`;
});
detail.innerHTML = html;
}
async function showDigest() {
const detail = document.getElementById('email-detail');
detail.style.display = 'block';
detail.innerHTML = '<p>Generating digest...</p>';
const resp = await fetch('/api/digest');
const data = await resp.json();
detail.innerHTML = '<h2>Daily Digest</h2><pre style="white-space:pre-wrap">' + data.digest + '</pre>';
}
// Load emails on page load
loadEmails();
</script>
{% endblock %}
Updated Application Entry Point
Update run.py to start the Flask server with the scheduler:
# run.py (updated)
"""Application entry point with Flask server and scheduler."""
from app.config import config
from app.database import init_db
from app.web.routes import create_app
from app.scheduler import create_scheduler
def main():
"""Initialize and start the AI email assistant."""
print("AI Email Assistant - Starting up...")
print(f" OpenAI Model: {config.openai_model}")
print(f" Database: {config.database_url}")
print(f" Poll Interval: {config.poll_interval_minutes} minutes")
# Initialize database
init_db()
# Create Flask app
app = create_app()
# Start background scheduler
scheduler = create_scheduler()
scheduler.start()
print(" Background scheduler started")
# Run Flask
print(f"\nDashboard: http://localhost:{config.flask_port}")
app.run(
host="0.0.0.0",
port=config.flask_port,
debug=False, # Set True for development
)
if __name__ == "__main__":
main()
# Start the application
python run.py
# Expected:
# AI Email Assistant - Starting up...
# OpenAI Model: gpt-4o-mini
# Database: sqlite:///email_assistant.db
# Poll Interval: 5 minutes
# Background scheduler started
# Dashboard: http://localhost:5000
http://localhost:5000 in your browser. You should see the dashboard with your inbox, priority badges, and the ability to generate drafts, review them, edit, and send replies. The filter bar should let you filter by priority level.Key Takeaways
- Flask provides a clean REST API for all dashboard operations. Each endpoint returns JSON for the frontend.
- The send workflow goes: generate draft, review/edit in textarea, click send, confirm, Gmail API sends the reply as a threaded response.
- Vanilla JavaScript handles all dynamic updates without any framework dependency. Fetch API calls the Flask endpoints.
- The dashboard provides three views: priority inbox, action items, and daily digest.
- The updated
run.pystarts both the Flask server and the background scheduler in a single process.
What Is Next
In the final lesson, you will explore enhancements — multi-account support, Outlook integration, email scheduling, privacy controls, and production deployment best practices.
Lilly Tech Systems