Antigravity commited on
Commit
e895030
Β·
0 Parent(s):

feat: Cloud-ready release of AI Gmail Agent with premium glassmorphism telemetry dashboard and Dockerfile

Browse files
.dockerignore ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .git/
4
+ .env
5
+ .env.*
6
+ env/
7
+ venv/
8
+ emails.db
9
+ logs/
10
+ *.log
11
+ credentials.json
12
+ token.json
13
+ .DS_Store
14
+ README.md
.gitignore ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Environments
7
+ .env
8
+ .env.*
9
+ env/
10
+ venv/
11
+ ENV/
12
+ env.bak/
13
+ venv.bak/
14
+
15
+ # SQLite Databases & logs
16
+ emails.db
17
+ logs/
18
+ *.log
19
+
20
+ # Sensitive Google Credentials
21
+ credentials.json
22
+ token.json
23
+
24
+ # OS specific files
25
+ .DS_Store
26
+ Thumbs.db
Dockerfile ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use an official lightweight Python base image
2
+ FROM python:3.10-slim
3
+
4
+ # Set environment variables
5
+ ENV PYTHONDONTWRITEBYTECODE=1
6
+ ENV PYTHONUNBUFFERED=1
7
+ ENV PORT=7860
8
+
9
+ # Set working directory
10
+ WORKDIR /code
11
+
12
+ # Install system dependencies if any are needed
13
+ RUN apt-get update && apt-get install -y --no-install-recommends \
14
+ build-essential \
15
+ && rm -rf /var/lib/apt/lists/*
16
+
17
+ # Copy requirements and install dependencies
18
+ COPY requirements.txt /code/
19
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
20
+
21
+ # Create directories and files with write access for Hugging Face non-root user (UID 1000)
22
+ RUN mkdir -p /code/logs && \
23
+ touch /code/emails.db && \
24
+ chmod -R 777 /code
25
+
26
+ # Copy the rest of the application code
27
+ COPY . /code/
28
+
29
+ # Set ownership of the application directory to the non-root user (UID 1000)
30
+ RUN chown -R 1000:1000 /code
31
+
32
+ # Switch to the non-root user (Hugging Face standard)
33
+ USER 1000
34
+
35
+ # Expose port 7860 (Hugging Face standard port)
36
+ EXPOSE 7860
37
+
38
+ # Command to run the FastAPI app
39
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: AI Gmail Agent
3
+ emoji: πŸ“§
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # πŸ“§ AI Gmail Agent & Intelligent Assistant
12
+
13
+ An autonomous AI-powered assistant that reads your unread Gmail inbox, categorizes incoming emails using **Groq (Llama 3)**, filters out noise/job alerts/automated replies, saves drafts for medium/low priority messages, and **automatically replies to urgent/high-priority messages**.
14
+
15
+ All of this is monitored via a sleek, interactive, glassmorphism telemetry dashboard!
16
+
17
+ ---
18
+
19
+ ## πŸš€ Key Features
20
+
21
+ * **Smart Email Categorization**: Uses Groq LLM to categorize emails (e.g. Sales, Question, Support, Newsletter, Personal, etc.).
22
+ * **Automated Replying**: Instantly drafts and auto-sends personalized replies to urgent, high-priority emails.
23
+ * **Drafting Engine**: Crafts high-quality responses and saves them directly as drafts in your Gmail account for review.
24
+ * **Intelligent Noise Filtering**: Detects spam, automated no-reply mail, and job alert digests, archiving/marking them read automatically without wasting LLM resources.
25
+ * **SQL Database Thread Tracking**: Saves audit logs of every categorization, priority level, action taken, and timestamp in a local SQLite database.
26
+ * **Vibrant Telemetry Dashboard**: Modern, real-time glassmorphism web interface served directly from the FastAPI backend.
27
+
28
+ ---
29
+
30
+ ## πŸ”’ Security & Cloud Deployment (Hugging Face Secrets)
31
+
32
+ This agent is built to be cloud-native and secure. **Do not** upload your local `token.json` or `.env` files directly to the public repository. Instead, configure them as secure environment variables (**Repository Secrets**) in your Hugging Face Space settings:
33
+
34
+ ### Required Secrets
35
+ 1. **`GROQ_API_KEY`**: Your Groq Console API key (e.g. `gsk_...`).
36
+ 2. **`GMAIL_TOKEN_JSON`**: The exact contents of your local `token.json` file.
37
+ - Run the agent locally first to complete the Google OAuth login.
38
+ - Once successfully logged in, open the newly created `token.json` file in the root.
39
+ - Copy the entire JSON content, and paste it into the `GMAIL_TOKEN_JSON` secret in Hugging Face.
40
+
41
+ ### Optional Config (Variables)
42
+ - **`POLL_INTERVAL`**: How often the worker checks Gmail for new unread mail (defaults to `60` seconds).
43
+
44
+ ---
45
+
46
+ ## πŸ’» Running Locally
47
+
48
+ ### 1. Requirements & Setup
49
+ Ensure you have Python 3.10+ installed.
50
+
51
+ ```bash
52
+ # Clone the repository
53
+ git clone <your-repo-url>
54
+ cd gmail-ai-agent
55
+
56
+ # Install dependencies
57
+ pip install -r requirements.txt
58
+ ```
59
+
60
+ ### 2. Configure Credentials
61
+ 1. Obtain Google OAuth `credentials.json` from the Google Cloud Console (enabled for the Gmail API with `https://www.googleapis.com/auth/gmail.modify` scopes).
62
+ 2. Create a `.env` file in the root directory:
63
+ ```env
64
+ GROQ_API_KEY=your_groq_api_key
65
+ ```
66
+
67
+ ### 3. Start the Server
68
+ Run the FastAPI application locally:
69
+ ```bash
70
+ uvicorn main:app --reload --port 7860
71
+ ```
72
+ Upon running, your browser will open to complete the Google Gmail authorization flow and save your `token.json`. Once authorized, you can access the premium dashboard at `http://localhost:7860`.
73
+
74
+ ---
75
+
76
+ ## πŸ›  Tech Stack
77
+
78
+ - **Backend**: Python 3.10, FastAPI, Uvicorn, SQLAlchemy (SQLite)
79
+ - **LLM Engine**: Groq SDK (Llama 3 / Mixtral)
80
+ - **Gmail Engine**: Google API Python Client & Google OAuth
81
+ - **Frontend**: HTML5, Vanilla CSS3 (Custom Glassmorphic HSL Design), JavaScript ES6 (Fetch Telemetry API), FontAwesome 6
82
+ - **Deployment**: Docker, Hugging Face Spaces
app/__init__.py ADDED
File without changes
app/ai/__init__.py ADDED
File without changes
app/ai/groq_client.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from groq import Groq
2
+ from config import GROQ_API_KEY
3
+
4
+ client = Groq(api_key=GROQ_API_KEY)
5
+
6
+ SYSTEM_PROMPT = """
7
+ You are a professional AI email assistant.
8
+
9
+ Rules:
10
+ - Reply professionally
11
+ - Keep responses concise
12
+ - Avoid hallucinations
13
+ - Never promise unavailable actions
14
+ """
15
+
16
+ def generate_ai_reply(email_text: str):
17
+
18
+ completion = client.chat.completions.create(
19
+ model="llama-3.3-70b-versatile",
20
+ messages=[
21
+ {
22
+ "role": "system",
23
+ "content": SYSTEM_PROMPT
24
+ },
25
+ {
26
+ "role": "user",
27
+ "content": email_text
28
+ }
29
+ ],
30
+ temperature=0.6,
31
+ max_tokens=300
32
+ )
33
+
34
+ return completion.choices[0].message.content.strip()
app/gmail/__init__.py ADDED
File without changes
app/gmail/auth.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from google.auth.transport.requests import Request
2
+ from google.oauth2.credentials import Credentials
3
+ from google_auth_oauthlib.flow import InstalledAppFlow
4
+ import os
5
+ import json
6
+ from loguru import logger
7
+ from config import GMAIL_TOKEN_JSON, GMAIL_CREDENTIALS_JSON, GMAIL_TOKEN_FILE, GMAIL_CREDENTIALS_FILE
8
+
9
+ SCOPES = ["https://www.googleapis.com/auth/gmail.modify"]
10
+
11
+ def authenticate():
12
+ creds = None
13
+
14
+ # 1. Try loading authorized user token from environment variable
15
+ if GMAIL_TOKEN_JSON:
16
+ try:
17
+ logger.info("πŸ”‘ Loading Gmail token from GMAIL_TOKEN_JSON environment variable...")
18
+ info = json.loads(GMAIL_TOKEN_JSON)
19
+ creds = Credentials.from_authorized_user_info(info, SCOPES)
20
+ logger.info("βœ… Gmail token successfully loaded from environment variable")
21
+ except Exception as e:
22
+ logger.error(f"❌ Failed to load token from GMAIL_TOKEN_JSON environment variable: {e}")
23
+ creds = None
24
+
25
+ # 2. Try loading from local token file
26
+ if not creds and os.path.exists(GMAIL_TOKEN_FILE):
27
+ try:
28
+ logger.info(f"πŸ”‘ Loading Gmail token from file: {GMAIL_TOKEN_FILE}...")
29
+ creds = Credentials.from_authorized_user_file(GMAIL_TOKEN_FILE, SCOPES)
30
+ logger.info("βœ… Gmail token successfully loaded from file")
31
+ except Exception as e:
32
+ logger.error(f"❌ Failed to load token from file {GMAIL_TOKEN_FILE}: {e}")
33
+ creds = None
34
+
35
+ # 3. If credentials don't exist or are invalid, handle renewal/flow
36
+ if not creds or not creds.valid:
37
+ if creds and creds.expired and creds.refresh_token:
38
+ try:
39
+ logger.info("πŸ”„ Gmail access token expired. Refreshing token...")
40
+ creds.refresh(Request())
41
+ logger.info("βœ… Gmail token successfully refreshed")
42
+ # If we're local and loaded from file, update the file
43
+ if os.path.exists(GMAIL_TOKEN_FILE):
44
+ try:
45
+ with open(GMAIL_TOKEN_FILE, "w") as f:
46
+ f.write(creds.to_json())
47
+ except Exception as e:
48
+ logger.warning(f"Could not save refreshed token to file: {e}")
49
+ except Exception as e:
50
+ logger.error(f"❌ Failed to refresh Gmail token: {e}")
51
+ creds = None
52
+
53
+ if not creds:
54
+ # Check if we are running in a headless environment (like HF Spaces)
55
+ is_headless = os.getenv("SPACE_ID") is not None or os.getenv("PORT") is not None
56
+
57
+ if is_headless:
58
+ logger.error("❌ Gmail authorization is missing. In headless/cloud environments, please set GMAIL_TOKEN_JSON environment variable with your token.json contents!")
59
+ raise RuntimeError("Gmail credentials are not configured. Please set GMAIL_TOKEN_JSON environment variable in Hugging Face Secrets.")
60
+
61
+ logger.info("🌐 Authenticating via local browser flow...")
62
+
63
+ # Determine how to load client secrets
64
+ if GMAIL_CREDENTIALS_JSON:
65
+ try:
66
+ secrets_info = json.loads(GMAIL_CREDENTIALS_JSON)
67
+ flow = InstalledAppFlow.from_client_config(secrets_info, SCOPES)
68
+ except Exception as e:
69
+ logger.error(f"❌ Failed to parse client secrets from GMAIL_CREDENTIALS_JSON: {e}")
70
+ raise
71
+ elif os.path.exists(GMAIL_CREDENTIALS_FILE):
72
+ flow = InstalledAppFlow.from_client_secrets_file(GMAIL_CREDENTIALS_FILE, SCOPES)
73
+ else:
74
+ logger.error(f"❌ Missing client credentials. GMAIL_CREDENTIALS_FILE ({GMAIL_CREDENTIALS_FILE}) not found!")
75
+ raise FileNotFoundError(f"Missing {GMAIL_CREDENTIALS_FILE} or GMAIL_CREDENTIALS_JSON environment variable.")
76
+
77
+ try:
78
+ creds = flow.run_local_server(
79
+ port=8080,
80
+ prompt="consent",
81
+ access_type="offline",
82
+ open_browser=True
83
+ )
84
+
85
+ with open(GMAIL_TOKEN_FILE, "w") as f:
86
+ f.write(creds.to_json())
87
+ logger.info(f"βœ… Authorization successful! token saved to {GMAIL_TOKEN_FILE}")
88
+ except Exception as e:
89
+ logger.error(f"❌ Authorization flow failed: {e}")
90
+ raise
91
+
92
+ return creds
app/gmail/parser.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ from bs4 import BeautifulSoup
3
+
4
+ def extract_email_body(payload):
5
+
6
+ if "parts" in payload:
7
+
8
+ for part in payload["parts"]:
9
+
10
+ data = part["body"].get("data")
11
+
12
+ if not data:
13
+ continue
14
+
15
+ decoded = base64.urlsafe_b64decode(
16
+ data
17
+ ).decode(errors="ignore")
18
+
19
+ mime = part.get("mimeType")
20
+
21
+ if mime == "text/html":
22
+ return BeautifulSoup(
23
+ decoded,
24
+ "html.parser"
25
+ ).get_text()
26
+
27
+ return decoded
28
+
29
+ else:
30
+
31
+ data = payload["body"].get("data")
32
+
33
+ if data:
34
+ return base64.urlsafe_b64decode(
35
+ data
36
+ ).decode(errors="ignore")
37
+
38
+ return ""
app/gmail/service.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import re
3
+ from email.mime.text import MIMEText
4
+ from email.mime.multipart import MIMEMultipart
5
+ from google.oauth2.credentials import Credentials
6
+ from google_auth_oauthlib.flow import InstalledAppFlow
7
+ from google.auth.transport.requests import Request
8
+ from googleapiclient.discovery import build
9
+ import os
10
+ import pickle
11
+ from loguru import logger
12
+ from config import GMAIL_CREDENTIALS_FILE, GMAIL_TOKEN_FILE
13
+
14
+ SCOPES = [
15
+ "https://www.googleapis.com/auth/gmail.readonly",
16
+ "https://www.googleapis.com/auth/gmail.send",
17
+ "https://www.googleapis.com/auth/gmail.modify",
18
+ ]
19
+
20
+ # ── Patterns that indicate no-reply / automated senders ──────────────────────
21
+ NO_REPLY_PATTERNS = [
22
+ r"no.?reply",
23
+ r"do.?not.?reply",
24
+ r"noreply",
25
+ r"donotreply",
26
+ r"mailer.?daemon",
27
+ r"notifications?@",
28
+ r"alerts?@",
29
+ r"updates?@",
30
+ r"newsletter@",
31
+ r"automated@",
32
+ r"system@",
33
+ r"bounce@",
34
+ ]
35
+
36
+ # ── Known job-alert domains / senders ────────────────────────────────────────
37
+ JOB_ALERT_PATTERNS = [
38
+ r"naukri\.com",
39
+ r"linkedin\.com",
40
+ r"indeed\.com",
41
+ r"monster\.com",
42
+ r"shine\.com",
43
+ r"foundit\.in",
44
+ r"internshala\.com",
45
+ r"wellfound\.com",
46
+ r"angellist\.com",
47
+ r"jobs\.google\.com",
48
+ r"glassdoor\.com",
49
+ ]
50
+
51
+
52
+ def get_gmail_service():
53
+ creds = None
54
+ if os.path.exists(GMAIL_TOKEN_FILE):
55
+ with open(GMAIL_TOKEN_FILE, "rb") as token:
56
+ creds = pickle.load(token)
57
+
58
+ if not creds or not creds.valid:
59
+ if creds and creds.expired and creds.refresh_token:
60
+ creds.refresh(Request())
61
+ else:
62
+ flow = InstalledAppFlow.from_client_secrets_file(GMAIL_CREDENTIALS_FILE, SCOPES)
63
+ creds = flow.run_local_server(port=0)
64
+ with open(GMAIL_TOKEN_FILE, "wb") as token:
65
+ pickle.dump(creds, token)
66
+
67
+ return build("gmail", "v1", credentials=creds)
68
+
69
+
70
+ def fetch_unread_emails(service, max_results: int = 10) -> list[dict]:
71
+ """Fetch unread emails from inbox (excludes promotions/spam labels)."""
72
+ results = service.users().messages().list(
73
+ userId="me",
74
+ labelIds=["INBOX", "UNREAD"],
75
+ maxResults=max_results,
76
+ q="-label:SPAM -label:CATEGORY_PROMOTIONS"
77
+ ).execute()
78
+
79
+ messages = results.get("messages", [])
80
+ emails = []
81
+
82
+ for msg in messages:
83
+ detail = service.users().messages().get(
84
+ userId="me", id=msg["id"], format="full"
85
+ ).execute()
86
+ emails.append(parse_email(detail))
87
+
88
+ return emails
89
+
90
+
91
+ def parse_email(raw_message: dict) -> dict:
92
+ headers = {h["name"]: h["value"] for h in raw_message["payload"]["headers"]}
93
+ body = extract_body(raw_message["payload"])
94
+ gmail_labels = raw_message.get("labelIds", [])
95
+
96
+ return {
97
+ "id": raw_message["id"],
98
+ "sender": headers.get("From", ""),
99
+ "subject": headers.get("Subject", "(No Subject)"),
100
+ "body": body[:3000], # limit to 3k chars for LLM
101
+ "labels": gmail_labels,
102
+ "is_spam": "SPAM" in gmail_labels or "CATEGORY_PROMOTIONS" in gmail_labels,
103
+ }
104
+
105
+
106
+ def extract_body(payload: dict) -> str:
107
+ body = ""
108
+ if "parts" in payload:
109
+ for part in payload["parts"]:
110
+ if part["mimeType"] == "text/plain" and "data" in part.get("body", {}):
111
+ body += base64.urlsafe_b64decode(part["body"]["data"]).decode("utf-8", errors="ignore")
112
+ elif "body" in payload and "data" in payload["body"]:
113
+ body = base64.urlsafe_b64decode(payload["body"]["data"]).decode("utf-8", errors="ignore")
114
+ return body.strip()
115
+
116
+
117
+ def is_no_reply_sender(sender: str) -> bool:
118
+ sender_lower = sender.lower()
119
+ return any(re.search(p, sender_lower) for p in NO_REPLY_PATTERNS)
120
+
121
+
122
+ def is_job_alert_email(sender: str, subject: str) -> bool:
123
+ text = (sender + " " + subject).lower()
124
+ return any(re.search(p, text) for p in JOB_ALERT_PATTERNS)
125
+
126
+
127
+ def send_email(service, to: str, subject: str, body: str) -> bool:
128
+ try:
129
+ message = MIMEMultipart()
130
+ message["to"] = to
131
+ message["subject"] = f"Re: {subject}"
132
+ message.attach(MIMEText(body, "plain"))
133
+
134
+ raw = base64.urlsafe_b64encode(message.as_bytes()).decode()
135
+ service.users().messages().send(
136
+ userId="me", body={"raw": raw}
137
+ ).execute()
138
+ logger.success(f"βœ… Auto-reply sent to {to}")
139
+ return True
140
+ except Exception as e:
141
+ logger.error(f"❌ Failed to send email to {to}: {e}")
142
+ return False
143
+
144
+
145
+ def save_draft(service, to: str, subject: str, body: str) -> bool:
146
+ try:
147
+ message = MIMEMultipart()
148
+ message["to"] = to
149
+ message["subject"] = f"Re: {subject}"
150
+ message.attach(MIMEText(body, "plain"))
151
+
152
+ raw = base64.urlsafe_b64encode(message.as_bytes()).decode()
153
+ service.users().drafts().create(
154
+ userId="me", body={"message": {"raw": raw}}
155
+ ).execute()
156
+ logger.info(f"πŸ“ Draft saved for {to}")
157
+ return True
158
+ except Exception as e:
159
+ logger.error(f"❌ Failed to save draft for {to}: {e}")
160
+ return False
161
+
162
+
163
+ def mark_as_read(service, email_id: str):
164
+ try:
165
+ service.users().messages().modify(
166
+ userId="me",
167
+ id=email_id,
168
+ body={"removeLabelIds": ["UNREAD"]}
169
+ ).execute()
170
+ except Exception as e:
171
+ logger.warning(f"Could not mark email {email_id} as read: {e}")
app/models/__init__.py ADDED
File without changes
app/models/database.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import create_engine, Column, String, DateTime, Boolean, Text
2
+ from sqlalchemy.ext.declarative import declarative_base
3
+ from sqlalchemy.orm import sessionmaker
4
+ from datetime import datetime
5
+ from config import DATABASE_URL
6
+
7
+ engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
8
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
9
+ Base = declarative_base()
10
+
11
+
12
+ class ProcessedEmail(Base):
13
+ __tablename__ = "processed_emails"
14
+
15
+ email_id = Column(String, primary_key=True, index=True)
16
+ sender = Column(String)
17
+ subject = Column(String)
18
+ category = Column(String) # Recruiter, Client, Spam, etc.
19
+ priority = Column(String) # High, Medium, Low
20
+ action_taken = Column(String) # auto_sent, draft_saved, skipped
21
+ reply_sent = Column(Boolean, default=False)
22
+ processed_at = Column(DateTime, default=datetime.utcnow)
23
+ notes = Column(Text, nullable=True)
24
+
25
+
26
+ def init_db():
27
+ Base.metadata.create_all(bind=engine)
28
+
29
+
30
+ def get_db():
31
+ db = SessionLocal()
32
+ try:
33
+ yield db
34
+ finally:
35
+ db.close()
app/routes/__init__.py ADDED
File without changes
app/routes/health.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends
2
+ from sqlalchemy.orm import Session
3
+ from sqlalchemy import func
4
+ from app.models.database import get_db, ProcessedEmail
5
+
6
+ router = APIRouter(prefix="/api", tags=["health"])
7
+
8
+
9
+ @router.get("/health")
10
+ async def health():
11
+ return {"status": "ok", "service": "AI Gmail Agent"}
12
+
13
+
14
+ @router.get("/stats")
15
+ async def stats(db: Session = Depends(get_db)):
16
+ total = db.query(ProcessedEmail).count()
17
+ sent = db.query(ProcessedEmail).filter(ProcessedEmail.action_taken == "auto_sent").count()
18
+ drafted = db.query(ProcessedEmail).filter(ProcessedEmail.action_taken == "draft_saved").count()
19
+ skipped = db.query(ProcessedEmail).filter(ProcessedEmail.action_taken == "skipped").count()
20
+ high_pri = db.query(ProcessedEmail).filter(ProcessedEmail.priority == "High").count()
21
+
22
+ by_category = (
23
+ db.query(ProcessedEmail.category, func.count(ProcessedEmail.email_id))
24
+ .group_by(ProcessedEmail.category)
25
+ .all()
26
+ )
27
+
28
+ return {
29
+ "total_processed": total,
30
+ "auto_sent": sent,
31
+ "drafts_saved": drafted,
32
+ "skipped": skipped,
33
+ "high_priority": high_pri,
34
+ "by_category": {cat: count for cat, count in by_category},
35
+ }
36
+
37
+
38
+ @router.get("/logs")
39
+ async def recent_logs(limit: int = 20, db: Session = Depends(get_db)):
40
+ records = (
41
+ db.query(ProcessedEmail)
42
+ .order_by(ProcessedEmail.processed_at.desc())
43
+ .limit(limit)
44
+ .all()
45
+ )
46
+ return [
47
+ {
48
+ "email_id": r.email_id,
49
+ "sender": r.sender,
50
+ "subject": r.subject,
51
+ "category": r.category,
52
+ "priority": r.priority,
53
+ "action_taken": r.action_taken,
54
+ "reply_sent": r.reply_sent,
55
+ "processed_at": r.processed_at,
56
+ "notes": r.notes,
57
+ }
58
+ for r in records
59
+ ]
app/services/__init__.py ADDED
File without changes
app/services/ai_servise.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import re
3
+ from groq import Groq
4
+ from loguru import logger
5
+ from config import GROQ_API_KEY
6
+
7
+ client = Groq(api_key=GROQ_API_KEY)
8
+ MODEL = "llama3-70b-8192"
9
+
10
+
11
+ ANALYSIS_PROMPT = """You are an intelligent email assistant. Analyze the email below and respond ONLY with a valid JSON object. No explanation, no markdown.
12
+
13
+ Email:
14
+ From: {sender}
15
+ Subject: {subject}
16
+ Body:
17
+ {body}
18
+
19
+ Respond with this exact JSON structure:
20
+ {{
21
+ "category": "Recruiter | Client | Support | Personal | Spam | Promotional | Other",
22
+ "priority": "High | Medium | Low",
23
+ "requires_reply": true or false,
24
+ "is_spam": true or false,
25
+ "reason": "one line explanation"
26
+ }}
27
+
28
+ Rules:
29
+ - priority=High if it's a recruiter reaching out personally, a client with an urgent issue, or important personal matter
30
+ - requires_reply=false for newsletters, job alerts, noreply senders, automated digests
31
+ - is_spam=true if it looks like unsolicited bulk email
32
+ """
33
+
34
+ REPLY_PROMPT = """You are a professional email assistant. Write a polite, concise reply to this email.
35
+
36
+ From: {sender}
37
+ Subject: {subject}
38
+ Body:
39
+ {body}
40
+
41
+ Instructions:
42
+ - Keep it under 100 words
43
+ - Be professional but warm
44
+ - Do not include subject line, just the body
45
+ - End with: "Best regards"
46
+ - If it's a recruiter, express interest and mention you are available to discuss further
47
+ """
48
+
49
+
50
+ def analyze_email(sender: str, subject: str, body: str) -> dict:
51
+ try:
52
+ prompt = ANALYSIS_PROMPT.format(sender=sender, subject=subject, body=body[:2000])
53
+ response = client.chat.completions.create(
54
+ model=MODEL,
55
+ messages=[{"role": "user", "content": prompt}],
56
+ temperature=0.1,
57
+ max_tokens=300,
58
+ )
59
+ raw = response.choices[0].message.content.strip()
60
+
61
+ # Strip markdown fences if present
62
+ raw = re.sub(r"```json|```", "", raw).strip()
63
+ result = json.loads(raw)
64
+ logger.info(f"πŸ“Š Analysis for '{subject}': {result}")
65
+ return result
66
+
67
+ except Exception as e:
68
+ logger.error(f"❌ AI analysis failed: {e}")
69
+ return {
70
+ "category": "Other",
71
+ "priority": "Low",
72
+ "requires_reply": False,
73
+ "is_spam": False,
74
+ "reason": "Analysis failed"
75
+ }
76
+
77
+
78
+ def generate_reply(sender: str, subject: str, body: str) -> str:
79
+ try:
80
+ prompt = REPLY_PROMPT.format(sender=sender, subject=subject, body=body[:2000])
81
+ response = client.chat.completions.create(
82
+ model=MODEL,
83
+ messages=[{"role": "user", "content": prompt}],
84
+ temperature=0.7,
85
+ max_tokens=200,
86
+ )
87
+ reply = response.choices[0].message.content.strip()
88
+ logger.info(f"✍️ Reply generated for '{subject}'")
89
+ return reply
90
+
91
+ except Exception as e:
92
+ logger.error(f"❌ Reply generation failed: {e}")
93
+ return ""
app/services/gmail_servise.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import re
3
+ from email.mime.text import MIMEText
4
+ from email.mime.multipart import MIMEMultipart
5
+ from google.oauth2.credentials import Credentials
6
+ from google_auth_oauthlib.flow import InstalledAppFlow
7
+ from google.auth.transport.requests import Request
8
+ from googleapiclient.discovery import build
9
+ import os
10
+ import pickle
11
+ from loguru import logger
12
+ from config import GMAIL_CREDENTIALS_FILE, GMAIL_TOKEN_FILE
13
+
14
+ SCOPES = [
15
+ "https://www.googleapis.com/auth/gmail.modify",
16
+ ]
17
+
18
+
19
+ # ── Patterns that indicate no-reply / automated senders ──────────────────────
20
+ NO_REPLY_PATTERNS = [
21
+ r"no.?reply",
22
+ r"do.?not.?reply",
23
+ r"noreply",
24
+ r"donotreply",
25
+ r"mailer.?daemon",
26
+ r"notifications?@",
27
+ r"alerts?@",
28
+ r"updates?@",
29
+ r"newsletter@",
30
+ r"automated@",
31
+ r"system@",
32
+ r"bounce@",
33
+ ]
34
+
35
+ # ── Known job-alert domains / senders ────────────────────────────────────────
36
+ JOB_ALERT_PATTERNS = [
37
+ r"naukri\.com",
38
+ r"linkedin\.com",
39
+ r"indeed\.com",
40
+ r"monster\.com",
41
+ r"shine\.com",
42
+ r"foundit\.in",
43
+ r"internshala\.com",
44
+ r"wellfound\.com",
45
+ r"angellist\.com",
46
+ r"jobs\.google\.com",
47
+ r"glassdoor\.com",
48
+ ]
49
+
50
+
51
+ def get_gmail_service():
52
+ creds = None
53
+ if os.path.exists(GMAIL_TOKEN_FILE):
54
+ try:
55
+ if GMAIL_TOKEN_FILE.endswith(".json"):
56
+ creds = Credentials.from_authorized_user_file(GMAIL_TOKEN_FILE, SCOPES)
57
+ else:
58
+ with open(GMAIL_TOKEN_FILE, "rb") as token:
59
+ creds = pickle.load(token)
60
+ except Exception as e:
61
+ logger.warning(f"Failed to load credentials from file {GMAIL_TOKEN_FILE}: {e}. Retrying OAuth flow.")
62
+ creds = None
63
+
64
+ if not creds or not creds.valid:
65
+ if creds and creds.expired and creds.refresh_token:
66
+ creds.refresh(Request())
67
+ else:
68
+ flow = InstalledAppFlow.from_client_secrets_file(GMAIL_CREDENTIALS_FILE, SCOPES)
69
+ creds = flow.run_local_server(port=0)
70
+
71
+ try:
72
+ if GMAIL_TOKEN_FILE.endswith(".json"):
73
+ with open(GMAIL_TOKEN_FILE, "w") as token:
74
+ token.write(creds.to_json())
75
+ else:
76
+ with open(GMAIL_TOKEN_FILE, "wb") as token:
77
+ pickle.dump(creds, token)
78
+ except Exception as e:
79
+ logger.error(f"Failed to save credentials to {GMAIL_TOKEN_FILE}: {e}")
80
+
81
+ return build("gmail", "v1", credentials=creds)
82
+
83
+
84
+
85
+ def fetch_unread_emails(service, max_results: int = 10) -> list[dict]:
86
+ """Fetch unread emails from inbox (excludes promotions/spam labels)."""
87
+ results = service.users().messages().list(
88
+ userId="me",
89
+ labelIds=["INBOX", "UNREAD"],
90
+ maxResults=max_results,
91
+ q="-label:SPAM -label:CATEGORY_PROMOTIONS"
92
+ ).execute()
93
+
94
+ messages = results.get("messages", [])
95
+ emails = []
96
+
97
+ for msg in messages:
98
+ detail = service.users().messages().get(
99
+ userId="me", id=msg["id"], format="full"
100
+ ).execute()
101
+ emails.append(parse_email(detail))
102
+
103
+ return emails
104
+
105
+
106
+ def parse_email(raw_message: dict) -> dict:
107
+ headers = {h["name"]: h["value"] for h in raw_message["payload"]["headers"]}
108
+ body = extract_body(raw_message["payload"])
109
+ gmail_labels = raw_message.get("labelIds", [])
110
+
111
+ return {
112
+ "id": raw_message["id"],
113
+ "sender": headers.get("From", ""),
114
+ "subject": headers.get("Subject", "(No Subject)"),
115
+ "body": body[:3000], # limit to 3k chars for LLM
116
+ "labels": gmail_labels,
117
+ "is_spam": "SPAM" in gmail_labels or "CATEGORY_PROMOTIONS" in gmail_labels,
118
+ }
119
+
120
+
121
+ def extract_body(payload: dict) -> str:
122
+ body = ""
123
+ if "parts" in payload:
124
+ for part in payload["parts"]:
125
+ if part["mimeType"] == "text/plain" and "data" in part.get("body", {}):
126
+ body += base64.urlsafe_b64decode(part["body"]["data"]).decode("utf-8", errors="ignore")
127
+ elif "body" in payload and "data" in payload["body"]:
128
+ body = base64.urlsafe_b64decode(payload["body"]["data"]).decode("utf-8", errors="ignore")
129
+ return body.strip()
130
+
131
+
132
+ def is_no_reply_sender(sender: str) -> bool:
133
+ sender_lower = sender.lower()
134
+ return any(re.search(p, sender_lower) for p in NO_REPLY_PATTERNS)
135
+
136
+
137
+ def is_job_alert_email(sender: str, subject: str) -> bool:
138
+ text = (sender + " " + subject).lower()
139
+ return any(re.search(p, text) for p in JOB_ALERT_PATTERNS)
140
+
141
+
142
+ def send_email(service, to: str, subject: str, body: str) -> bool:
143
+ try:
144
+ message = MIMEMultipart()
145
+ message["to"] = to
146
+ message["subject"] = f"Re: {subject}"
147
+ message.attach(MIMEText(body, "plain"))
148
+
149
+ raw = base64.urlsafe_b64encode(message.as_bytes()).decode()
150
+ service.users().messages().send(
151
+ userId="me", body={"raw": raw}
152
+ ).execute()
153
+ logger.success(f"βœ… Auto-reply sent to {to}")
154
+ return True
155
+ except Exception as e:
156
+ logger.error(f"❌ Failed to send email to {to}: {e}")
157
+ return False
158
+
159
+
160
+ def save_draft(service, to: str, subject: str, body: str) -> bool:
161
+ try:
162
+ message = MIMEMultipart()
163
+ message["to"] = to
164
+ message["subject"] = f"Re: {subject}"
165
+ message.attach(MIMEText(body, "plain"))
166
+
167
+ raw = base64.urlsafe_b64encode(message.as_bytes()).decode()
168
+ service.users().drafts().create(
169
+ userId="me", body={"message": {"raw": raw}}
170
+ ).execute()
171
+ logger.info(f"πŸ“ Draft saved for {to}")
172
+ return True
173
+ except Exception as e:
174
+ logger.error(f"❌ Failed to save draft for {to}: {e}")
175
+ return False
176
+
177
+
178
+ def mark_as_read(service, email_id: str):
179
+ try:
180
+ service.users().messages().modify(
181
+ userId="me",
182
+ id=email_id,
183
+ body={"removeLabelIds": ["UNREAD"]}
184
+ ).execute()
185
+ except Exception as e:
186
+ logger.warning(f"Could not mark email {email_id} as read: {e}")
app/workers/__init__.py ADDED
File without changes
app/workers/email_worker.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from loguru import logger
3
+ from sqlalchemy.orm import Session
4
+
5
+ from app.models.database import SessionLocal, ProcessedEmail
6
+ from app.services.gmail_servise import (
7
+ get_gmail_service,
8
+ fetch_unread_emails,
9
+ is_no_reply_sender,
10
+ is_job_alert_email,
11
+ send_email,
12
+ save_draft,
13
+ mark_as_read,
14
+ )
15
+ from app.services.ai_servise import analyze_email, generate_reply
16
+ from config import POLL_INTERVAL
17
+
18
+
19
+ def already_processed(db: Session, email_id: str) -> bool:
20
+ return db.query(ProcessedEmail).filter(ProcessedEmail.email_id == email_id).first() is not None
21
+
22
+
23
+ def save_to_db(db: Session, email: dict, analysis: dict, action: str, reply_sent: bool, notes: str = ""):
24
+ record = ProcessedEmail(
25
+ email_id = email["id"],
26
+ sender = email["sender"],
27
+ subject = email["subject"],
28
+ category = analysis.get("category", "Other"),
29
+ priority = analysis.get("priority", "Low"),
30
+ action_taken = action,
31
+ reply_sent = reply_sent,
32
+ notes = notes,
33
+ )
34
+ db.add(record)
35
+ db.commit()
36
+
37
+
38
+ def decide_and_act(service, db: Session, email: dict):
39
+ """
40
+ Core decision engine:
41
+
42
+ SKIP if:
43
+ - already processed
44
+ - Gmail flagged as spam/promo
45
+ - sender is noreply/automated
46
+ - email is a job alert/digest
47
+
48
+ ANALYZE with AI:
49
+ - If AI says spam β†’ skip
50
+ - If AI says no reply needed β†’ skip
51
+ - If HIGH priority β†’ auto send reply
52
+ - Otherwise β†’ save as draft
53
+ """
54
+ email_id = email["id"]
55
+ sender = email["sender"]
56
+ subject = email["subject"]
57
+
58
+ # ── Guard 1: Already processed ────────────────────────────────────────────
59
+ if already_processed(db, email_id):
60
+ logger.debug(f"⏭ Already processed: {subject}")
61
+ return
62
+
63
+ # ── Guard 2: Gmail spam/promo label ──────────────────────────────────────
64
+ if email["is_spam"]:
65
+ logger.info(f"🚫 Gmail flagged as spam/promo: {subject}")
66
+ save_to_db(db, email, {"category": "Spam", "priority": "Low"}, "skipped", False, "Gmail spam/promo label")
67
+ mark_as_read(service, email_id)
68
+ return
69
+
70
+ # ── Guard 3: No-reply sender ──────────────────────────────────────────────
71
+ if is_no_reply_sender(sender):
72
+ logger.info(f"🚫 No-reply sender detected: {sender}")
73
+ save_to_db(db, email, {"category": "Automated", "priority": "Low"}, "skipped", False, "No-reply sender")
74
+ mark_as_read(service, email_id)
75
+ return
76
+
77
+ # ── Guard 4: Job alert / digest ───────────────────────────────────────────
78
+ if is_job_alert_email(sender, subject):
79
+ logger.info(f"πŸ“‹ Job alert email, skipping reply: {subject}")
80
+ save_to_db(db, email, {"category": "Job Alert", "priority": "Low"}, "skipped", False, "Job alert digest")
81
+ mark_as_read(service, email_id)
82
+ return
83
+
84
+ # ── AI Analysis ───────────────────────────────────────────────────────────
85
+ analysis = analyze_email(sender, subject, email["body"])
86
+
87
+ if analysis.get("is_spam"):
88
+ logger.info(f"πŸ€– AI flagged as spam: {subject}")
89
+ save_to_db(db, email, analysis, "skipped", False, "AI detected spam")
90
+ mark_as_read(service, email_id)
91
+ return
92
+
93
+ if not analysis.get("requires_reply", False):
94
+ logger.info(f"πŸ“­ No reply needed: {subject} ({analysis.get('reason', '')})")
95
+ save_to_db(db, email, analysis, "skipped", False, analysis.get("reason", ""))
96
+ mark_as_read(service, email_id)
97
+ return
98
+
99
+ # ── Generate reply ────────────────────────────────────────────────────────
100
+ reply_body = generate_reply(sender, subject, email["body"])
101
+ if not reply_body:
102
+ logger.warning(f"⚠️ Could not generate reply for: {subject}")
103
+ save_to_db(db, email, analysis, "skipped", False, "Reply generation failed")
104
+ return
105
+
106
+ # ── HIGH priority β†’ Auto send ─────────────────────────────────────────────
107
+ if analysis.get("priority") == "High":
108
+ sent = send_email(service, sender, subject, reply_body)
109
+ action = "auto_sent" if sent else "send_failed"
110
+ save_to_db(db, email, analysis, action, sent, "Auto-sent: high priority")
111
+ mark_as_read(service, email_id)
112
+
113
+ # ── Medium/Low β†’ Save as draft ──────────���─────────────────────────────────
114
+ else:
115
+ save_draft(service, sender, subject, reply_body)
116
+ save_to_db(db, email, analysis, "draft_saved", False, "Draft saved: medium/low priority")
117
+ mark_as_read(service, email_id)
118
+
119
+
120
+ async def email_loop():
121
+ logger.info("πŸš€ Email worker started")
122
+ service = get_gmail_service()
123
+
124
+ while True:
125
+ try:
126
+ logger.info("πŸ“¬ Checking Gmail for new emails...")
127
+ db = SessionLocal()
128
+ emails = fetch_unread_emails(service, max_results=10)
129
+ logger.info(f"πŸ“© Found {len(emails)} unread emails")
130
+
131
+ for email in emails:
132
+ decide_and_act(service, db, email)
133
+
134
+ db.close()
135
+
136
+ except Exception as e:
137
+ logger.error(f"πŸ’₯ Worker error: {e}")
138
+
139
+ await asyncio.sleep(POLL_INTERVAL)
config.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dotenv import load_dotenv
2
+ import os
3
+
4
+ load_dotenv()
5
+
6
+ DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./emails.db")
7
+ GROQ_API_KEY = os.getenv("GROQ_API_KEY")
8
+ POLL_INTERVAL = int(os.getenv("POLL_INTERVAL", 60))
9
+ GMAIL_CREDENTIALS_FILE = os.getenv("GMAIL_CREDENTIALS_FILE", "credentials.json")
10
+ GMAIL_TOKEN_FILE = os.getenv("GMAIL_TOKEN_FILE", "token.json")
11
+ GMAIL_TOKEN_JSON = os.getenv("GMAIL_TOKEN_JSON")
12
+ GMAIL_CREDENTIALS_JSON = os.getenv("GMAIL_CREDENTIALS_JSON")
13
+
index.html ADDED
@@ -0,0 +1,808 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>AI Gmail Agent - Dashboard</title>
7
+ <!-- Premium Google Fonts: Inter & Outfit -->
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
11
+ <!-- FontAwesome for Premium Icons -->
12
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
13
+
14
+ <style>
15
+ /* Modern CSS Custom Properties with harmonious HSL colors */
16
+ :root {
17
+ --bg-base: hsl(224, 71%, 4%);
18
+ --bg-canvas: hsl(224, 71%, 6%);
19
+ --glass-bg: hsla(224, 71%, 10%, 0.45);
20
+ --glass-border: hsla(224, 71%, 20%, 0.4);
21
+ --glass-highlight: hsla(224, 71%, 30%, 0.15);
22
+
23
+ --text-primary: hsl(210, 40%, 98%);
24
+ --text-secondary: hsl(215, 20%, 65%);
25
+ --text-muted: hsl(215, 16%, 47%);
26
+
27
+ --primary: hsl(263, 90%, 62%);
28
+ --primary-glow: hsla(263, 90%, 62%, 0.5);
29
+ --secondary: hsl(190, 95%, 50%);
30
+ --secondary-glow: hsla(190, 95%, 50%, 0.5);
31
+
32
+ --priority-high: hsl(346, 84%, 61%);
33
+ --priority-high-glow: hsla(346, 84%, 61%, 0.25);
34
+ --priority-medium: hsl(40, 96%, 53%);
35
+ --priority-medium-glow: hsla(40, 96%, 53%, 0.25);
36
+ --priority-low: hsl(142, 70%, 45%);
37
+ --priority-low-glow: hsla(142, 70%, 45%, 0.25);
38
+
39
+ --action-sent: hsl(263, 90%, 62%);
40
+ --action-draft: hsl(190, 95%, 50%);
41
+ --action-skipped: hsl(215, 16%, 47%);
42
+ }
43
+
44
+ * {
45
+ box-sizing: border-box;
46
+ margin: 0;
47
+ padding: 0;
48
+ scrollbar-width: thin;
49
+ scrollbar-color: var(--glass-border) transparent;
50
+ }
51
+
52
+ body {
53
+ font-family: 'Inter', sans-serif;
54
+ background-color: var(--bg-base);
55
+ background-image:
56
+ radial-gradient(at 0% 0%, hsla(263, 90%, 15%, 0.3) 0px, transparent 50%),
57
+ radial-gradient(at 100% 100%, hsla(190, 95%, 15%, 0.2) 0px, transparent 50%),
58
+ radial-gradient(at 50% 50%, hsla(224, 71%, 4%, 1) 0%, hsla(224, 71%, 6%, 0.9) 100%);
59
+ background-attachment: fixed;
60
+ color: var(--text-primary);
61
+ min-height: 100vh;
62
+ padding: 2.5rem 1.5rem;
63
+ display: flex;
64
+ justify-content: center;
65
+ align-items: flex-start;
66
+ }
67
+
68
+ /* Container Layout */
69
+ .dashboard-container {
70
+ width: 100%;
71
+ max-width: 1200px;
72
+ display: flex;
73
+ flex-direction: column;
74
+ gap: 2rem;
75
+ }
76
+
77
+ /* Breathtaking Glowing Header */
78
+ header {
79
+ display: flex;
80
+ justify-content: space-between;
81
+ align-items: center;
82
+ padding: 1.5rem 2rem;
83
+ background: var(--glass-bg);
84
+ border: 1px solid var(--glass-border);
85
+ backdrop-filter: blur(16px);
86
+ -webkit-backdrop-filter: blur(16px);
87
+ border-radius: 16px;
88
+ box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
89
+ position: relative;
90
+ overflow: hidden;
91
+ }
92
+
93
+ header::before {
94
+ content: '';
95
+ position: absolute;
96
+ top: 0;
97
+ left: 0;
98
+ width: 100%;
99
+ height: 2px;
100
+ background: linear-gradient(90deg, transparent, var(--primary), var(--secondary), transparent);
101
+ }
102
+
103
+ .logo-section {
104
+ display: flex;
105
+ align-items: center;
106
+ gap: 1rem;
107
+ }
108
+
109
+ .logo-icon {
110
+ font-size: 2rem;
111
+ background: linear-gradient(135deg, var(--primary), var(--secondary));
112
+ -webkit-background-clip: text;
113
+ -webkit-text-fill-color: transparent;
114
+ filter: drop-shadow(0 0 10px var(--primary-glow));
115
+ animation: pulse-glow 3s infinite ease-in-out;
116
+ }
117
+
118
+ h1 {
119
+ font-family: 'Outfit', sans-serif;
120
+ font-size: 1.75rem;
121
+ font-weight: 700;
122
+ letter-spacing: -0.02em;
123
+ background: linear-gradient(to right, var(--text-primary), var(--text-secondary));
124
+ -webkit-background-clip: text;
125
+ -webkit-text-fill-color: transparent;
126
+ }
127
+
128
+ .status-badge {
129
+ display: flex;
130
+ align-items: center;
131
+ gap: 0.75rem;
132
+ background: rgba(16, 185, 129, 0.1);
133
+ border: 1px solid rgba(16, 185, 129, 0.2);
134
+ color: hsl(142, 70%, 55%);
135
+ padding: 0.5rem 1rem;
136
+ border-radius: 9999px;
137
+ font-size: 0.875rem;
138
+ font-weight: 500;
139
+ box-shadow: 0 0 15px rgba(16, 185, 129, 0.1);
140
+ }
141
+
142
+ .status-dot {
143
+ width: 8px;
144
+ height: 8px;
145
+ background-color: hsl(142, 70%, 50%);
146
+ border-radius: 50%;
147
+ position: relative;
148
+ animation: blink 1.5s infinite ease-in-out;
149
+ }
150
+
151
+ .status-dot::after {
152
+ content: '';
153
+ position: absolute;
154
+ top: -4px;
155
+ left: -4px;
156
+ width: 16px;
157
+ height: 16px;
158
+ border: 2px solid hsl(142, 70%, 50%);
159
+ border-radius: 50%;
160
+ animation: ripple 1.5s infinite ease-in-out;
161
+ }
162
+
163
+ /* Stats Grid Section */
164
+ .stats-grid {
165
+ display: grid;
166
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
167
+ gap: 1.5rem;
168
+ }
169
+
170
+ .stat-card {
171
+ background: var(--glass-bg);
172
+ border: 1px solid var(--glass-border);
173
+ backdrop-filter: blur(16px);
174
+ -webkit-backdrop-filter: blur(16px);
175
+ border-radius: 16px;
176
+ padding: 1.5rem;
177
+ display: flex;
178
+ align-items: center;
179
+ gap: 1.25rem;
180
+ transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
181
+ position: relative;
182
+ overflow: hidden;
183
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
184
+ }
185
+
186
+ .stat-card::after {
187
+ content: '';
188
+ position: absolute;
189
+ bottom: 0;
190
+ left: 0;
191
+ width: 100%;
192
+ height: 3px;
193
+ background: transparent;
194
+ transition: background 0.3s ease;
195
+ }
196
+
197
+ .stat-card:hover {
198
+ transform: translateY(-5px);
199
+ border-color: var(--glass-highlight);
200
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
201
+ }
202
+
203
+ .stat-card.total:hover::after { background: linear-gradient(90deg, var(--primary), var(--secondary)); }
204
+ .stat-card.sent:hover::after { background: var(--priority-low); }
205
+ .stat-card.draft:hover::after { background: var(--secondary); }
206
+ .stat-card.skipped:hover::after { background: var(--text-muted); }
207
+
208
+ .stat-icon {
209
+ width: 50px;
210
+ height: 50px;
211
+ border-radius: 12px;
212
+ display: flex;
213
+ align-items: center;
214
+ justify-content: center;
215
+ font-size: 1.5rem;
216
+ background: var(--glass-highlight);
217
+ border: 1px solid var(--glass-border);
218
+ color: var(--text-primary);
219
+ transition: all 0.3s ease;
220
+ }
221
+
222
+ .stat-card:hover .stat-icon {
223
+ transform: scale(1.1);
224
+ }
225
+
226
+ .stat-card.total .stat-icon { color: var(--primary); background: hsla(263, 90%, 62%, 0.1); border-color: hsla(263, 90%, 62%, 0.2); }
227
+ .stat-card.sent .stat-icon { color: var(--priority-low); background: hsla(142, 70%, 45%, 0.1); border-color: hsla(142, 70%, 45%, 0.2); }
228
+ .stat-card.draft .stat-icon { color: var(--secondary); background: hsla(190, 95%, 50%, 0.1); border-color: hsla(190, 95%, 50%, 0.2); }
229
+ .stat-card.skipped .stat-icon { color: var(--text-muted); background: hsla(215, 16%, 47%, 0.1); border-color: hsla(215, 16%, 47%, 0.2); }
230
+
231
+ .stat-info {
232
+ display: flex;
233
+ flex-direction: column;
234
+ gap: 0.25rem;
235
+ }
236
+
237
+ .stat-label {
238
+ font-size: 0.875rem;
239
+ color: var(--text-secondary);
240
+ font-weight: 500;
241
+ }
242
+
243
+ .stat-value {
244
+ font-family: 'Outfit', sans-serif;
245
+ font-size: 1.85rem;
246
+ font-weight: 700;
247
+ }
248
+
249
+ /* Logs Dashboard Table Section */
250
+ .logs-section {
251
+ background: var(--glass-bg);
252
+ border: 1px solid var(--glass-border);
253
+ backdrop-filter: blur(16px);
254
+ -webkit-backdrop-filter: blur(16px);
255
+ border-radius: 16px;
256
+ padding: 2rem;
257
+ box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.25);
258
+ display: flex;
259
+ flex-direction: column;
260
+ gap: 1.5rem;
261
+ }
262
+
263
+ .logs-header {
264
+ display: flex;
265
+ justify-content: space-between;
266
+ align-items: center;
267
+ }
268
+
269
+ .logs-title {
270
+ display: flex;
271
+ align-items: center;
272
+ gap: 0.75rem;
273
+ font-family: 'Outfit', sans-serif;
274
+ font-size: 1.25rem;
275
+ font-weight: 600;
276
+ }
277
+
278
+ .logs-title i {
279
+ color: var(--primary);
280
+ }
281
+
282
+ .refresh-btn {
283
+ background: var(--glass-highlight);
284
+ border: 1px solid var(--glass-border);
285
+ color: var(--text-primary);
286
+ padding: 0.5rem 1rem;
287
+ border-radius: 8px;
288
+ font-size: 0.875rem;
289
+ font-weight: 500;
290
+ cursor: pointer;
291
+ display: flex;
292
+ align-items: center;
293
+ gap: 0.5rem;
294
+ transition: all 0.2s ease;
295
+ }
296
+
297
+ .refresh-btn:hover {
298
+ background: var(--glass-border);
299
+ border-color: var(--text-muted);
300
+ }
301
+
302
+ .refresh-btn:active {
303
+ transform: scale(0.95);
304
+ }
305
+
306
+ /* Live Table Styling */
307
+ .table-wrapper {
308
+ width: 100%;
309
+ overflow-x: auto;
310
+ border-radius: 12px;
311
+ border: 1px solid var(--glass-border);
312
+ }
313
+
314
+ table {
315
+ width: 100%;
316
+ border-collapse: collapse;
317
+ text-align: left;
318
+ font-size: 0.875rem;
319
+ }
320
+
321
+ th {
322
+ background: rgba(224, 71%, 10%, 0.8);
323
+ color: var(--text-secondary);
324
+ font-weight: 600;
325
+ padding: 1rem 1.25rem;
326
+ border-bottom: 1px solid var(--glass-border);
327
+ text-transform: uppercase;
328
+ font-size: 0.75rem;
329
+ letter-spacing: 0.05em;
330
+ }
331
+
332
+ td {
333
+ padding: 1.15rem 1.25rem;
334
+ border-bottom: 1px solid rgba(224, 71%, 20%, 0.2);
335
+ color: var(--text-primary);
336
+ white-space: nowrap;
337
+ }
338
+
339
+ tr:last-child td {
340
+ border-bottom: none;
341
+ }
342
+
343
+ tr {
344
+ transition: background 0.2s ease;
345
+ }
346
+
347
+ tr:hover td {
348
+ background: rgba(263, 90%, 62%, 0.02);
349
+ }
350
+
351
+ .sender-col {
352
+ font-weight: 500;
353
+ max-width: 180px;
354
+ overflow: hidden;
355
+ text-overflow: ellipsis;
356
+ }
357
+
358
+ .subject-col {
359
+ max-width: 320px;
360
+ overflow: hidden;
361
+ text-overflow: ellipsis;
362
+ color: var(--text-primary);
363
+ }
364
+
365
+ /* Pill Badges */
366
+ .pill {
367
+ display: inline-flex;
368
+ align-items: center;
369
+ padding: 0.25rem 0.75rem;
370
+ border-radius: 9999px;
371
+ font-size: 0.75rem;
372
+ font-weight: 600;
373
+ text-transform: capitalize;
374
+ letter-spacing: 0.02em;
375
+ }
376
+
377
+ /* Priority Pills */
378
+ .pill.priority-high {
379
+ background: rgba(244, 63, 94, 0.1);
380
+ color: var(--priority-high);
381
+ border: 1px solid rgba(244, 63, 94, 0.2);
382
+ box-shadow: 0 0 10px var(--priority-high-glow);
383
+ }
384
+
385
+ .pill.priority-medium {
386
+ background: rgba(245, 158, 11, 0.1);
387
+ color: var(--priority-medium);
388
+ border: 1px solid rgba(245, 158, 11, 0.2);
389
+ box-shadow: 0 0 10px var(--priority-medium-glow);
390
+ }
391
+
392
+ .pill.priority-low {
393
+ background: rgba(16, 185, 129, 0.1);
394
+ color: var(--priority-low);
395
+ border: 1px solid rgba(16, 185, 129, 0.2);
396
+ box-shadow: 0 0 10px var(--priority-low-glow);
397
+ }
398
+
399
+ /* Action Pills */
400
+ .pill.action-sent {
401
+ background: rgba(139, 92, 246, 0.15);
402
+ color: var(--action-sent);
403
+ border: 1px solid rgba(139, 92, 246, 0.3);
404
+ }
405
+
406
+ .pill.action-draft {
407
+ background: rgba(6, 182, 212, 0.15);
408
+ color: var(--action-draft);
409
+ border: 1px solid rgba(6, 182, 212, 0.3);
410
+ }
411
+
412
+ .pill.action-skipped {
413
+ background: rgba(100, 116, 139, 0.15);
414
+ color: var(--action-skipped);
415
+ border: 1px solid rgba(100, 116, 139, 0.3);
416
+ }
417
+
418
+ .time-col {
419
+ color: var(--text-muted);
420
+ font-size: 0.8rem;
421
+ }
422
+
423
+ .empty-state {
424
+ text-align: center;
425
+ padding: 3rem 1.5rem;
426
+ color: var(--text-secondary);
427
+ display: flex;
428
+ flex-direction: column;
429
+ align-items: center;
430
+ gap: 1rem;
431
+ }
432
+
433
+ .empty-icon {
434
+ font-size: 2.5rem;
435
+ color: var(--text-muted);
436
+ opacity: 0.6;
437
+ }
438
+
439
+ /* Config Card Panel - Elegant Cloud integration guide */
440
+ .config-guide-panel {
441
+ background: var(--glass-bg);
442
+ border: 1px solid var(--glass-border);
443
+ backdrop-filter: blur(16px);
444
+ -webkit-backdrop-filter: blur(16px);
445
+ border-radius: 16px;
446
+ padding: 2rem;
447
+ box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.25);
448
+ display: flex;
449
+ flex-direction: column;
450
+ gap: 1.25rem;
451
+ }
452
+
453
+ .config-header {
454
+ display: flex;
455
+ align-items: center;
456
+ gap: 0.75rem;
457
+ font-family: 'Outfit', sans-serif;
458
+ font-size: 1.25rem;
459
+ font-weight: 600;
460
+ color: var(--secondary);
461
+ }
462
+
463
+ .config-body {
464
+ color: var(--text-secondary);
465
+ font-size: 0.9rem;
466
+ line-height: 1.6;
467
+ display: flex;
468
+ flex-direction: column;
469
+ gap: 1rem;
470
+ }
471
+
472
+ .step-list {
473
+ list-style: none;
474
+ display: flex;
475
+ flex-direction: column;
476
+ gap: 0.75rem;
477
+ margin-top: 0.5rem;
478
+ }
479
+
480
+ .step-item {
481
+ display: flex;
482
+ align-items: flex-start;
483
+ gap: 0.75rem;
484
+ }
485
+
486
+ .step-num {
487
+ background: var(--secondary);
488
+ color: var(--bg-base);
489
+ width: 22px;
490
+ height: 22px;
491
+ border-radius: 50%;
492
+ display: inline-flex;
493
+ align-items: center;
494
+ justify-content: center;
495
+ font-weight: 700;
496
+ font-size: 0.75rem;
497
+ flex-shrink: 0;
498
+ margin-top: 2px;
499
+ }
500
+
501
+ .code-container {
502
+ background: rgba(0, 0, 0, 0.3);
503
+ border: 1px solid var(--glass-border);
504
+ border-radius: 8px;
505
+ padding: 1rem;
506
+ font-family: 'Courier New', Courier, monospace;
507
+ font-size: 0.825rem;
508
+ overflow-x: auto;
509
+ position: relative;
510
+ color: var(--text-primary);
511
+ }
512
+
513
+ .copy-btn {
514
+ position: absolute;
515
+ top: 8px;
516
+ right: 8px;
517
+ background: var(--glass-highlight);
518
+ border: 1px solid var(--glass-border);
519
+ color: var(--text-secondary);
520
+ width: 32px;
521
+ height: 32px;
522
+ border-radius: 6px;
523
+ display: flex;
524
+ align-items: center;
525
+ justify-content: center;
526
+ cursor: pointer;
527
+ transition: all 0.2s ease;
528
+ }
529
+
530
+ .copy-btn:hover {
531
+ color: var(--text-primary);
532
+ background: var(--glass-border);
533
+ }
534
+
535
+ /* Beautiful Dynamic Animations */
536
+ @keyframes pulse-glow {
537
+ 0%, 100% { filter: drop-shadow(0 0 5px var(--primary-glow)); }
538
+ 50% { filter: drop-shadow(0 0 15px var(--primary-glow)); }
539
+ }
540
+
541
+ @keyframes blink {
542
+ 0%, 100% { opacity: 1; }
543
+ 50% { opacity: 0.4; }
544
+ }
545
+
546
+ @keyframes ripple {
547
+ 0% { transform: scale(1); opacity: 0.8; }
548
+ 100% { transform: scale(2.2); opacity: 0; }
549
+ }
550
+
551
+ @keyframes spin {
552
+ 100% { transform: rotate(360deg); }
553
+ }
554
+
555
+ .fa-spin-custom {
556
+ animation: spin 1s linear infinite;
557
+ }
558
+
559
+ /* Responsive Breakpoints */
560
+ @media (max-width: 768px) {
561
+ body {
562
+ padding: 1.5rem 1rem;
563
+ }
564
+ header {
565
+ flex-direction: column;
566
+ align-items: flex-start;
567
+ gap: 1rem;
568
+ }
569
+ .status-badge {
570
+ align-self: flex-end;
571
+ }
572
+ }
573
+ </style>
574
+ </head>
575
+ <body>
576
+
577
+ <div class="dashboard-container">
578
+ <!-- Dashboard Header -->
579
+ <header>
580
+ <div class="logo-section">
581
+ <i class="fa-solid fa-wand-magic-sparkles logo-icon"></i>
582
+ <div>
583
+ <h1>AI Gmail Agent</h1>
584
+ <p style="color: var(--text-secondary); font-size: 0.875rem;">Autonomously managing, categorizing & responding to Gmail</p>
585
+ </div>
586
+ </div>
587
+ <div class="status-badge">
588
+ <span class="status-dot"></span>
589
+ <span>Active Syncing</span>
590
+ </div>
591
+ </header>
592
+
593
+ <!-- Dynamic Statistics Cards -->
594
+ <div class="stats-grid">
595
+ <div class="stat-card total">
596
+ <div class="stat-icon"><i class="fa-solid fa-envelope-open-text"></i></div>
597
+ <div class="stat-info">
598
+ <span class="stat-label">Total Processed</span>
599
+ <span class="stat-value" id="stat-total">0</span>
600
+ </div>
601
+ </div>
602
+ <div class="stat-card sent">
603
+ <div class="stat-icon"><i class="fa-solid fa-paper-plane"></i></div>
604
+ <div class="stat-info">
605
+ <span class="stat-label">Auto Sent Replies</span>
606
+ <span class="stat-value" id="stat-sent">0</span>
607
+ </div>
608
+ </div>
609
+ <div class="stat-card draft">
610
+ <div class="stat-icon"><i class="fa-solid fa-file-pen"></i></div>
611
+ <div class="stat-info">
612
+ <span class="stat-label">Drafts Created</span>
613
+ <span class="stat-value" id="stat-drafts">0</span>
614
+ </div>
615
+ </div>
616
+ <div class="stat-card skipped">
617
+ <div class="stat-icon"><i class="fa-solid fa-forward"></i></div>
618
+ <div class="stat-info">
619
+ <span class="stat-label">Spam/Skipped</span>
620
+ <span class="stat-value" id="stat-skipped">0</span>
621
+ </div>
622
+ </div>
623
+ </div>
624
+
625
+ <!-- Live Activity Log -->
626
+ <div class="logs-section">
627
+ <div class="logs-header">
628
+ <div class="logs-title">
629
+ <i class="fa-solid fa-clock-rotate-left"></i>
630
+ <h2>Live Decision Log</h2>
631
+ </div>
632
+ <button class="refresh-btn" id="refresh-trigger">
633
+ <i class="fa-solid fa-rotate" id="refresh-icon"></i>
634
+ <span>Refresh</span>
635
+ </button>
636
+ </div>
637
+
638
+ <div class="table-wrapper">
639
+ <table>
640
+ <thead>
641
+ <tr>
642
+ <th>Sender</th>
643
+ <th>Subject</th>
644
+ <th>Category</th>
645
+ <th>Priority</th>
646
+ <th>Action Taken</th>
647
+ <th>Processed Time</th>
648
+ </tr>
649
+ </thead>
650
+ <tbody id="logs-tbody">
651
+ <tr>
652
+ <td colspan="6">
653
+ <div class="empty-state">
654
+ <i class="fa-solid fa-circle-notch fa-spin empty-icon"></i>
655
+ <p>Loading agent telemetry...</p>
656
+ </div>
657
+ </td>
658
+ </tr>
659
+ </tbody>
660
+ </table>
661
+ </div>
662
+ </div>
663
+
664
+ <!-- Cloud Configuration Guide Panel -->
665
+ <div class="config-guide-panel">
666
+ <div class="config-header">
667
+ <i class="fa-solid fa-cloud-arrow-up"></i>
668
+ <h2>Cloud Deployment & Hugging Face Guide</h2>
669
+ </div>
670
+ <div class="config-body">
671
+ <p>This Gmail Agent runs entirely autonomously in the cloud. Since you are deploying to a secure, public or private Hugging Face Space, you <strong>do not</strong> need to expose your sensitive credential files in the repository. Instead, configure them as <strong>Repository Secrets</strong> in the Space settings:</p>
672
+
673
+ <ul class="step-list">
674
+ <li class="step-item">
675
+ <span class="step-num">1</span>
676
+ <div>
677
+ <strong>GROQ_API_KEY</strong>: Generate an API key on Groq Console and add it as a secret.
678
+ </div>
679
+ </li>
680
+ <li class="step-item">
681
+ <span class="step-num">2</span>
682
+ <div>
683
+ <strong>GMAIL_TOKEN_JSON</strong>: Copy the exact contents of your local <code>token.json</code> and paste it as a secret. The agent will handle session refresh token rotation automatically!
684
+ </div>
685
+ </li>
686
+ </ul>
687
+
688
+ <p style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--text-muted);">
689
+ <i class="fa-solid fa-circle-info"></i> Note: For security, never commit <code>token.json</code>, <code>credentials.json</code>, or <code>.env</code> files to the public Git repository. They have been automatically added to the <code>.gitignore</code> and <code>.dockerignore</code>.
690
+ </p>
691
+ </div>
692
+ </div>
693
+ </div>
694
+
695
+ <!-- Script for Dynamic Operations -->
696
+ <script>
697
+ const statTotal = document.getElementById('stat-total');
698
+ const statSent = document.getElementById('stat-sent');
699
+ const statDrafts = document.getElementById('stat-drafts');
700
+ const statSkipped = document.getElementById('stat-skipped');
701
+ const logsTbody = document.getElementById('logs-tbody');
702
+ const refreshBtn = document.getElementById('refresh-trigger');
703
+ const refreshIcon = document.getElementById('refresh-icon');
704
+
705
+ // Format raw date strings neatly
706
+ function formatTime(isoString) {
707
+ if (!isoString) return 'N/A';
708
+ const date = new Date(isoString);
709
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) +
710
+ ' ' + date.toLocaleDateString([], { month: 'short', day: 'numeric' });
711
+ }
712
+
713
+ // Fetch Stats & Logs from backend
714
+ async function fetchTelemetry() {
715
+ refreshIcon.classList.add('fa-spin-custom');
716
+
717
+ try {
718
+ // 1. Fetch Stats
719
+ const statsResponse = await fetch('/api/stats');
720
+ if (statsResponse.ok) {
721
+ const stats = await statsResponse.json();
722
+ statTotal.textContent = stats.total_processed || 0;
723
+ statSent.textContent = stats.auto_sent || 0;
724
+ statDrafts.textContent = stats.drafts_saved || 0;
725
+ statSkipped.textContent = stats.skipped || 0;
726
+ }
727
+
728
+ // 2. Fetch Logs
729
+ const logsResponse = await fetch('/api/logs?limit=15');
730
+ if (logsResponse.ok) {
731
+ const logs = await logsResponse.json();
732
+
733
+ if (logs.length === 0) {
734
+ logsTbody.innerHTML = `
735
+ <tr>
736
+ <td colspan="6">
737
+ <div class="empty-state">
738
+ <i class="fa-solid fa-inbox empty-icon"></i>
739
+ <p>No processed emails found in the tracking database yet.</p>
740
+ </div>
741
+ </td>
742
+ </tr>
743
+ `;
744
+ } else {
745
+ logsTbody.innerHTML = logs.map(log => {
746
+ // Map Priority Pill
747
+ let priorityClass = 'priority-low';
748
+ if (log.priority === 'High') priorityClass = 'priority-high';
749
+ else if (log.priority === 'Medium') priorityClass = 'priority-medium';
750
+
751
+ // Map Action Pill
752
+ let actionClass = 'action-skipped';
753
+ let actionLabel = 'Skipped';
754
+ if (log.action_taken === 'auto_sent') {
755
+ actionClass = 'action-sent';
756
+ actionLabel = 'Replied';
757
+ } else if (log.action_taken === 'draft_saved') {
758
+ actionClass = 'action-draft';
759
+ actionLabel = 'Drafted';
760
+ }
761
+
762
+ // Clean subject/sender
763
+ const sender = log.sender ? log.sender.replace(/<.*>/, '').trim() : 'Unknown';
764
+ const subject = log.subject || '(No Subject)';
765
+
766
+ return `
767
+ <tr>
768
+ <td class="sender-col" title="${log.sender}">${sender}</td>
769
+ <td class="subject-col" title="${subject}">${subject}</td>
770
+ <td><span style="font-weight: 500;">${log.category || 'Other'}</span></td>
771
+ <td><span class="pill ${priorityClass}">${log.priority || 'Low'}</span></td>
772
+ <td><span class="pill ${actionClass}" title="${log.notes || ''}">${actionLabel}</span></td>
773
+ <td class="time-col">${formatTime(log.processed_at)}</td>
774
+ </tr>
775
+ `;
776
+ }).join('');
777
+ }
778
+ }
779
+ } catch (error) {
780
+ console.error('Error loading agent telemetry:', error);
781
+ logsTbody.innerHTML = `
782
+ <tr>
783
+ <td colspan="6">
784
+ <div class="empty-state" style="color: var(--priority-high);">
785
+ <i class="fa-solid fa-triangle-exclamation empty-icon" style="color: var(--priority-high);"></i>
786
+ <p>Failed to connect to agent API backend. Check if service is active.</p>
787
+ </div>
788
+ </td>
789
+ </tr>
790
+ `;
791
+ } finally {
792
+ setTimeout(() => {
793
+ refreshIcon.classList.remove('fa-spin-custom');
794
+ }, 400);
795
+ }
796
+ }
797
+
798
+ // Setup manual and auto refresh loops
799
+ refreshBtn.addEventListener('click', fetchTelemetry);
800
+
801
+ // Initial Fetch
802
+ fetchTelemetry();
803
+
804
+ // Auto Refresh every 10 seconds
805
+ setInterval(fetchTelemetry, 10000);
806
+ </script>
807
+ </body>
808
+ </html>
main.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from fastapi import FastAPI
3
+ from fastapi.middleware.cors import CORSMiddleware
4
+ from fastapi.responses import HTMLResponse
5
+ from loguru import logger
6
+ import sys
7
+ import os
8
+
9
+ if sys.platform == "win32":
10
+ try:
11
+ sys.stdout.reconfigure(encoding='utf-8')
12
+ sys.stderr.reconfigure(encoding='utf-8')
13
+ except AttributeError:
14
+ pass
15
+
16
+ from app.routes.health import router
17
+ from app.workers.email_worker import email_loop
18
+ from app.models.database import init_db
19
+
20
+
21
+ # ── Logging setup ─────────────────────────────────────────────────────────────
22
+ logger.remove()
23
+ logger.add(sys.stdout, colorize=True, format="<green>{time:HH:mm:ss}</green> | <level>{level}</level> | {message}")
24
+ logger.add("logs/agent.log", rotation="1 day", retention="7 days", level="INFO")
25
+
26
+ # ── App ───────────────────────────────────────────────────────────────────────
27
+ app = FastAPI(
28
+ title="AI Gmail Agent",
29
+ description="Automatically categorizes, filters, and replies to emails using Groq LLM",
30
+ version="2.0.0",
31
+ )
32
+
33
+ app.add_middleware(
34
+ CORSMiddleware,
35
+ allow_origins=["*"],
36
+ allow_methods=["*"],
37
+ allow_headers=["*"],
38
+ )
39
+
40
+ app.include_router(router)
41
+
42
+
43
+ @app.on_event("startup")
44
+ async def startup_event():
45
+ init_db()
46
+ logger.info("βœ… Database initialized")
47
+ asyncio.create_task(email_loop())
48
+
49
+
50
+ @app.get("/", response_class=HTMLResponse)
51
+ async def root():
52
+ if os.path.exists("index.html"):
53
+ try:
54
+ with open("index.html", "r", encoding="utf-8") as f:
55
+ return HTMLResponse(content=f.read(), status_code=200)
56
+ except Exception as e:
57
+ logger.error(f"Error reading index.html: {e}")
58
+
59
+ return """
60
+ <html>
61
+ <head>
62
+ <title>AI Gmail Agent</title>
63
+ <style>
64
+ body { font-family: sans-serif; text-align: center; padding-top: 50px; background-color: #0f172a; color: #f8fafc; }
65
+ h1 { color: #818cf8; }
66
+ p { color: #94a3b8; }
67
+ </style>
68
+ </head>
69
+ <body>
70
+ <h1>AI Gmail Agent is Running πŸš€</h1>
71
+ <p>Ready to categorize, filter, and reply to your emails!</p>
72
+ <p>View stats at <a href="/api/stats" style="color: #38bdf8;">/api/stats</a></p>
73
+ </body>
74
+ </html>
75
+ """
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.111.0
2
+ uvicorn==0.29.0
3
+ groq==0.9.0
4
+ google-auth==2.29.0
5
+ google-auth-oauthlib==1.2.0
6
+ google-auth-httplib2==0.2.0
7
+ google-api-python-client==2.126.0
8
+ sqlalchemy==2.0.30
9
+ loguru==0.7.2
10
+ python-dotenv==1.0.1