144-python-email-extractor/doc/ARCHITECTURE.md

1765 lines
61 KiB
Markdown
Raw Permalink Normal View History

2026-10-05 03:18:15 +00:00
# Scope
I currently have 3 email accounts
stephen.lohning@oxnee.com which is a microsoft account
slohning@live.com.au a microsoft 365 account
slohning@gmail.com Google Account
I wish to write python program to down load the emails from email from all of those accounts.
The email will be access via there Microsoft Graph API and the Google API.
The emails will be download and stored as eml format.
The python program will use uv the gui will be developed PySide6
The meta data from the from the email will be stored in the a sqlite3 extra meta data will be added, such as project name, organization etc.
The data base will store the data as a json so that the we can expand the number of is not limited.
The project is call 144-python-email-extractor
# Response
Here is a complete design and working implementation for **144-python-email-extractor**.
This project uses **`uv`** for dependency management, **`PySide6`** for a modern Qt desktop UI, **Microsoft Graph API** for your Microsoft accounts, **Google Gmail API** for your Gmail account, and **SQLite3** with a JSON column to store extensible metadata alongside the saved `.eml` files.
---
## 1. Project Setup with `uv`
Create your project structure and initialize it using `uv`:
```bash
mkdir -p 144-python-email-extractor/eml_storage
cd 144-python-email-extractor
# Initialize uv environment
uv init
uv add PySide6 msal requests google-api-python-client google-auth-oauthlib google-auth-httplib2
```
---
## 2. SQLite Database Schema (`db.py`)
We create a database containing an `emails` table. By using SQLite's native `JSON` type for `extra_metadata`, you can store dynamic attributes like `project_name`, `organization`, `tags`, or `status` without needing to alter database schemas later.
Save this as `db.py`:
```python
import sqlite3
import json
DB_FILE = "email_archive.db"
def init_db():
"""Initializes the SQLite database schema."""
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
# Table schema with JSON extra_metadata column
cursor.execute("""
CREATE TABLE IF NOT EXISTS emails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_email TEXT NOT NULL,
message_id TEXT NOT NULL UNIQUE,
subject TEXT,
sender TEXT,
received_date TEXT,
file_path TEXT NOT NULL,
extra_metadata TEXT, -- JSON blob for flexible, unlimited fields
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
conn.close()
def save_email_record(account_email, message_id, subject, sender, received_date, file_path, metadata_dict):
"""Inserts or updates an email record with extra JSON metadata."""
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
json_metadata = json.dumps(metadata_dict)
cursor.execute("""
INSERT INTO emails (account_email, message_id, subject, sender, received_date, file_path, extra_metadata)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(message_id) DO UPDATE SET
extra_metadata = excluded.extra_metadata,
file_path = excluded.file_path
""", (account_email, message_id, subject, sender, received_date, file_path, json_metadata))
conn.commit()
conn.close()
```
---
## 3. Email Fetching Engines (`downloaders.py`)
This handles OAuth2 authentication and fetching raw `.eml` MIME data for both API ecosystems.
* **Microsoft Graph API:** Retrieves messages via the `/$value` endpoint (`stephen.lohning@oxnee.com` & `slohning@live.com.au`).
* **Google Gmail API:** Downloads raw emails using `users().messages().get(id=..., format='raw')` and converts base64url MIME to standard `.eml` (`slohning@gmail.com`).
Save this as `downloaders.py`:
```python
import os
import re
import base64
import requests
import msal
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
STORAGE_DIR = "./eml_storage"
def sanitize_filename(name):
clean = re.sub(r'[\\/*?:"<>|]', "", name or "Untitled")
return clean[:80].strip()
# ==============================================================================
# MICROSOFT GRAPH API DOWNLOADER
# ==============================================================================
class MSGraphDownloader:
def __init__(self, tenant_id, client_id, client_secret, account_email):
self.account_email = account_email
self.tenant_id = tenant_id
self.client_id = client_id
self.client_secret = client_secret
self.token = self._get_token()
def _get_token(self):
app = msal.ConfidentialClientApplication(
self.client_id,
authority=f"https://login.microsoftonline.com/{self.tenant_id}",
client_credential=self.client_secret,
)
res = app.acquire_token_for_client(scopes=["https://graph.microsoft.com/.default"])
return res.get("access_token")
def download_inbox(self, callback_signal):
if not self.token:
callback_signal.emit(f"[{self.account_email}] Auth Error: Could not acquire token.")
return []
headers = {"Authorization": f"Bearer {self.token}", "Accept": "application/json"}
url = f"https://graph.microsoft.com/v1.0/users/{self.account_email}/mailFolders/inbox/messages?$select=id,subject,from,receivedDateTime&$top=20"
extracted = []
os.makedirs(STORAGE_DIR, exist_ok=True)
res = requests.get(url, headers=headers)
if res.status_code != 200:
callback_signal.emit(f"[{self.account_email}] Failed to list emails: {res.status_code}")
return []
messages = res.json().get("value", [])
for msg in messages:
msg_id = msg["id"]
subject = msg.get("subject", "No Subject")
sender = msg.get("from", {}).get("emailAddress", {}).get("address", "Unknown")
date_str = msg.get("receivedDateTime", "")[:10]
# Get raw .eml content
raw_url = f"https://graph.microsoft.com/v1.0/users/{self.account_email}/messages/{msg_id}/$value"
eml_res = requests.get(raw_url, headers=headers)
if eml_res.status_code == 200:
fname = f"MS_{date_str}_{sanitize_filename(subject)}_{msg_id[-6:]}.eml"
fpath = os.path.abspath(os.path.join(STORAGE_DIR, fname))
with open(fpath, "wb") as f:
f.write(eml_res.content)
extracted.append({
"account": self.account_email,
"id": msg_id,
"subject": subject,
"sender": sender,
"date": date_str,
"path": fpath
})
callback_signal.emit(f"[{self.account_email}] Downloaded: {fname}")
return extracted
# ==============================================================================
# GMAIL API DOWNLOADER
# ==============================================================================
class GmailDownloader:
SCOPES = ['https://www.googleapis.com/auth/gmail.readonly']
def __init__(self, credentials_json, account_email="slohning@gmail.com"):
self.account_email = account_email
self.creds_json = credentials_json
self.service = self._authenticate()
def _authenticate(self):
creds = None
token_file = 'token_gmail.json'
if os.path.exists(token_file):
creds = Credentials.from_authorized_user_file(token_file, self.SCOPES)
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
flow = InstalledAppFlow.from_client_secrets_file(self.creds_json, self.SCOPES)
creds = flow.run_local_server(port=0)
with open(token_file, 'w') as token:
token.write(creds.to_json())
return build('gmail', 'v1', credentials=creds)
def download_inbox(self, callback_signal):
results = self.service.users().messages().list(userId='me', maxResults=20, q="label:INBOX").execute()
messages = results.get('messages', [])
extracted = []
os.makedirs(STORAGE_DIR, exist_ok=True)
for m in messages:
msg_id = m['id']
# Get raw RFC 822 format
raw_msg = self.service.users().messages().get(userId='me', id=msg_id, format='raw').execute()
# Get full metadata headers
meta_msg = self.service.users().messages().get(userId='me', id=msg_id, format='full').execute()
headers = meta_msg.get('payload', {}).get('headers', [])
subject = next((h['value'] for h in headers if h['name'].lower() == 'subject'), 'No Subject')
sender = next((h['value'] for h in headers if h['name'].lower() == 'from'), 'Unknown')
date_str = next((h['value'] for h in headers if h['name'].lower() == 'date'), 'UnknownDate')
# Decode RFC 2822 base64url data into .eml binary stream
eml_data = base64.urlsafe_b64decode(raw_msg['raw'].encode('ASCII'))
fname = f"GMAIL_{sanitize_filename(subject)}_{msg_id[-6:]}.eml"
fpath = os.path.abspath(os.path.join(STORAGE_DIR, fname))
with open(fpath, "wb") as f:
f.write(eml_data)
extracted.append({
"account": self.account_email,
"id": msg_id,
"subject": subject,
"sender": sender,
"date": date_str,
"path": fpath
})
callback_signal.emit(f"[{self.account_email}] Downloaded: {fname}")
return extracted
```
---
## 4. PySide6 Desktop Interface (`main.py`)
This application provides thread-safe execution, interactive setup for `Project Name` and `Organization` metadata tags, live log streaming, and an embedded viewer for database records.
Save this as `main.py`:
```python
import sys
import json
import sqlite3
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QLineEdit, QPushButton, QTextEdit, QTableWidget, QTableWidgetItem,
QHeaderView, QGroupBox, QMessageBox
)
from db import init_db, save_email_record, DB_FILE
from downloaders import MSGraphDownloader, GmailDownloader
# Thread worker to prevent freezing the UI during API calls
class SyncWorker(QThread):
log_signal = Signal(str)
finished_signal = Signal()
def __init__(self, project_name, organization, ms_credentials, gmail_credentials_file):
super().__init__()
self.project_name = project_name
self.organization = organization
self.ms_credentials = ms_credentials
self.gmail_credentials_file = gmail_credentials_file
def run(self):
self.log_signal.emit("Starting multi-account email sync...")
init_db()
records = []
# 1. Sync Microsoft Graph Accounts
if self.ms_credentials.get("client_id"):
for email in ["stephen.lohning@oxnee.com", "slohning@live.com.au"]:
self.log_signal.emit(f"Connecting to Microsoft Graph for {email}...")
ms = MSGraphDownloader(
tenant_id=self.ms_credentials["tenant_id"],
client_id=self.ms_credentials["client_id"],
client_secret=self.ms_credentials["client_secret"],
account_email=email
)
records.extend(ms.download_inbox(self.log_signal))
# 2. Sync Google Gmail Account
if self.gmail_credentials_file:
self.log_signal.emit("Connecting to Google Gmail API for slohning@gmail.com...")
try:
gm = GmailDownloader(self.gmail_credentials_file, "slohning@gmail.com")
records.extend(gm.download_inbox(self.log_signal))
except Exception as e:
self.log_signal.emit(f"[slohning@gmail.com] Error: {e}")
# 3. Store Records and JSON metadata in SQLite
for r in records:
extra_meta = {
"project_name": self.project_name,
"organization": self.organization,
"extractor_version": "1.0.0",
"extracted_via": "144-python-email-extractor"
}
save_email_record(
account_email=r["account"],
message_id=r["id"],
subject=r["subject"],
sender=r["sender"],
received_date=r["date"],
file_path=r["path"],
metadata_dict=extra_meta
)
self.log_signal.emit(f"\nSync Complete! Processed {len(records)} messages into SQLite.")
self.finished_signal.emit()
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("144-python-email-extractor")
self.resize(950, 700)
init_db()
self.init_ui()
def init_ui(self):
central = QWidget()
self.setCentralWidget(central)
layout = QVBoxLayout(central)
# Config Inputs Box
config_box = QGroupBox("Extraction & Metadata Parameters")
cfg_layout = QHBoxLayout(config_box)
self.txt_project = QLineEdit("Project_144")
self.txt_project.setPlaceholderText("Project Name")
self.txt_org = QLineEdit("Oxnee")
self.txt_org.setPlaceholderText("Organization")
cfg_layout.addWidget(QLabel("Project:"))
cfg_layout.addWidget(self.txt_project)
cfg_layout.addWidget(QLabel("Organization:"))
cfg_layout.addWidget(self.txt_org)
self.btn_sync = QPushButton("Start Download & Extraction")
self.btn_sync.clicked.connect(self.start_sync)
cfg_layout.addWidget(self.btn_sync)
layout.addWidget(config_box)
# Log Window
self.txt_log = QTextEdit()
self.txt_log.setReadOnly(True)
self.txt_log.setMaximumHeight(150)
layout.addWidget(QLabel("Live Extraction Logs:"))
layout.addWidget(self.txt_log)
# SQLite Database Table Viewer
self.table = QTableWidget()
self.table.setColumnCount(6)
self.table.setHorizontalHeaderLabels(["ID", "Account", "Subject", "Sender", "Path", "JSON Extra Metadata"])
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents)
self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
layout.addWidget(QLabel("Stored SQLite Database Index:"))
layout.addWidget(self.table)
self.refresh_table()
def append_log(self, text):
self.txt_log.append(text)
def start_sync(self):
self.btn_sync.setEnabled(False)
self.txt_log.clear()
# Placeholders for MS Azure credentials (or load from local config)
ms_creds = {
"tenant_id": "YOUR_AZURE_TENANT_ID",
"client_id": "YOUR_AZURE_CLIENT_ID",
"client_secret": "YOUR_AZURE_CLIENT_SECRET"
}
gmail_json = "credentials.json" # Path to downloaded OAuth2 credentials
self.worker = SyncWorker(
project_name=self.txt_project.text(),
organization=self.txt_org.text(),
ms_credentials=ms_creds,
gmail_credentials_file=gmail_json if os.path.exists(gmail_json) else None
)
self.worker.log_signal.connect(self.append_log)
self.worker.finished_signal.connect(self.on_sync_finished)
self.worker.start()
def on_sync_finished(self):
self.btn_sync.setEnabled(True)
self.refresh_table()
def refresh_table(self):
"""Reloads records directly from SQLite."""
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
cursor.execute("SELECT id, account_email, subject, sender, file_path, extra_metadata FROM emails ORDER BY id DESC")
rows = cursor.fetchall()
conn.close()
self.table.setRowCount(0)
for row_idx, row_data in enumerate(rows):
self.table.insertRow(row_idx)
for col_idx, value in enumerate(row_data):
item = QTableWidgetItem(str(value))
self.table.setItem(row_idx, col_idx, item)
if __name__ == "__main__":
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())
```
---
## 5. Execution
Launch the application directly through `uv`:
```bash
uv run main.py
```
### Application Features
1. **Multi-Provider Fetching:** Seamlessly downloads `.eml` MIME streams from both Microsoft Graph (`@oxnee.com`, `@live.com.au`) and Google API (`@gmail.com`).
2. **Metadata Flexibility:** Extracted records save their file path alongside a `JSON` blob column in SQLite (`extra_metadata`). You can attach unlimited custom project fields or tag attributes without modifying database tables.
3. **PySide6 Interface:** Offloads heavy network I/O to background threads (`QThread`) so the UI remains fluid, giving you instant table views of your archived dataset.
# Prompt
What structure would you suggest for this project, prefer GUI pages or tabs are in different files, draw diagram of the proposed structure
# Response
For **`144-python-email-extractor`**, structuring the application into clear, decoupled modules makes it significantly easier to maintain, test, and expand as you add more metadata fields or email accounts.
Separating UI pages/tabs into distinct files and isolating the database/API services keeps the PySide6 widgets clean and focused strictly on display logic.
---
### Suggested Directory Structure
```text
144-python-email-extractor/
├── pyproject.toml # Managed by uv
├── uv.lock
├── README.md
├── email_archive.db # Generated SQLite database
├── credentials.json # Google OAuth2 credentials (git-ignored)
├── token_gmail.json # Generated Google session token (git-ignored)
│
├── storage/ # Output directory for raw .eml files
│ └── .gitkeep
│
└── src/ # Core application source code
├── __init__.py
├── main.py # Application entry point (QApplication setup)
│
├── models/ # Database & schema handlers
│ ├── __init__.py
│ └── database.py # SQLite setup, JSON metadata queries & schema
│
├── services/ # Downloader engines & background workers
│ ├── __init__.py
│ ├── ms_graph.py # Microsoft Graph API client (.eml stream)
│ ├── gmail_api.py # Google Gmail API client (RFC 2822 raw fetch)
│ └── sync_worker.py # QThread runner for background network calls
│
└── gui/ # PySide6 User Interface components
├── __init__.py
├── main_window.py # QMainWindow containing QTabWidget container
├── components/ # Shared UI widgets
│ ├── __init__.py
│ └── log_viewer.py # Live log output widget
└── tabs/ # Modular tab pages (separated files)
├── __init__.py
├── extraction_tab.py # Tab 1: Sync controls, parameters & progress
├── database_tab.py # Tab 2: SQLite data table & JSON inspector
└── settings_tab.py # Tab 3: API Keys, Azure App IDs & user accounts
```
---
### Component Relationship Diagram
```
+---------------------------------------------------------------------------------+
| main.py |
| (Entry Point & App Init) |
+---------------------------------------------------------------------------------+
|
v
+---------------------------------------------------------------------------------+
| gui/main_window.py |
| (Holds QTabWidget) |
+---------------------------------------------------------------------------------+
| | |
v v v
+-------------------+ +-------------------+ +-------------------+
| gui/tabs/ | | gui/tabs/ | | gui/tabs/ |
| extraction_tab.py | | database_tab.py | | settings_tab.py |
| (Controls/Sync) | | (SQLite Table View| | (Azure & Gmail |
+-------------------+ | & JSON Inspector)| | App Setup) |
| +-------------------+ +-------------------+
| |
v |
+-------------------+ |
| services/ | |
| sync_worker.py | |
| (QThread) | |
+-------------------+ |
| | |
v v v
+----------+ +----------+ +-------------------+
| services/| | services/| | models/ |
| ms_graph | | gmail_api| | database.py |
| .py | | .py | | (SQLite & JSON) |
+----------+ +----------+ +-------------------+
| | |
+------+------+ |
| |
v v
+-------------------+ +-------------------+
| storage/ (.eml) | | email_archive.db |
+-------------------+ +-------------------+
```
---
### How the Tab Files Work in Code
#### 1. Individual Tab File (`src/gui/tabs/extraction_tab.py`)
Each page inherits from `QWidget` and encapsulates its own layout and signals:
```python
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QLineEdit, QPushButton, QLabel
class ExtractionTab(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.init_ui()
def init_ui(self):
layout = QVBoxLayout(self)
# Configuration Box
config_box = QGroupBox("Extraction Parameters")
cfg_layout = QHBoxLayout(config_box)
self.txt_project = QLineEdit("Project_144")
self.txt_org = QLineEdit("Oxnee")
self.btn_sync = QPushButton("Start Extraction")
cfg_layout.addWidget(QLabel("Project:"))
cfg_layout.addWidget(self.txt_project)
cfg_layout.addWidget(QLabel("Organization:"))
cfg_layout.addWidget(self.txt_org)
cfg_layout.addWidget(self.btn_sync)
layout.addWidget(config_box)
layout.addStretch()
```
#### 2. Tab Container (`src/gui/main_window.py`)
`MainWindow` simply imports the tabs and adds them to a `QTabWidget`:
```python
from PySide6.QtWidgets import QMainWindow, QTabWidget
from gui.tabs.extraction_tab import ExtractionTab
from gui.tabs.database_tab import DatabaseTab
from gui.tabs.settings_tab import SettingsTab
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("144-python-email-extractor")
self.resize(1000, 750)
# Tab Widget Container
self.tabs = QTabWidget()
self.setCentralWidget(self.tabs)
# Instantiate modular tab pages
self.tab_extraction = ExtractionTab()
self.tab_database = DatabaseTab()
self.tab_settings = SettingsTab()
# Add tabs
self.tabs.addTab(self.tab_extraction, "Extract & Sync")
self.tabs.addTab(self.tab_database, "Database Viewer")
self.tabs.addTab(self.tab_settings, "Account Settings")
```
---
### Benefits of This Architecture
1. **Clean `uv` Integration:** Running `uv run src/main.py` directly executes the clean modular code tree.
2. **Tab Isolation:** Editing the UI for the Database Inspector in `database_tab.py` won't touch or risk breaking the extraction runner code in `extraction_tab.py`.
3. **Decoupled Business Logic:** `ms_graph.py` and `gmail_api.py` have no knowledge of PySide6—they can easily be called from a CLI or automated script independently of the GUI.
# Prompt
This is fine with addition of another folder called doc , which has a subfolder called images.
The project has created and will use Forgejo and git
# Response
Here is the updated architecture for **`144-python-email-extractor`**, incorporating the **`doc/images`** documentation directory and integrating **Forgejo / Git** version control conventions (such as `.gitignore` for sensitive tokens and raw `.eml` storage).
---
### Project Directory Structure
```text
144-python-email-extractor/
├── .gitignore # Ignores storage/, *.db, tokens, and virtualenvs
├── .forgejo/ # Forgejo Actions / CI/CD workflows (optional)
│ └── workflows/
│ └── build.yaml
├── pyproject.toml # Managed by uv
├── uv.lock
├── README.md
│
├── doc/ # Project documentation
│ ├── ARCHITECTURE.md # Data flow & API integration details
│ └── images/ # GUI screenshots, architecture diagrams & visual assets
│ └── .gitkeep
│
├── storage/ # Output directory for raw .eml files (git-ignored)
│ └── .gitkeep
│
└── src/ # Core Python application package
├── __init__.py
├── main.py # Application entry point (QApplication initialization)
│
├── models/ # Database & schema handlers
│ ├── __init__.py
│ └── database.py # SQLite connection, JSON metadata schema & queries
│
├── services/ # API clients & background thread workers
│ ├── __init__.py
│ ├── ms_graph.py # Microsoft Graph API client (RFC 2822 /.value stream)
│ ├── gmail_api.py # Google Gmail API client (base64url raw MIME)
│ └── sync_worker.py # PySide6 QThread runner for non-blocking API calls
│
└── gui/ # PySide6 Desktop UI modules
├── __init__.py
├── main_window.py # QMainWindow holding the QTabWidget container
├── components/ # Reusable UI widgets
│ ├── __init__.py
│ └── log_viewer.py # Live log output widget
└── tabs/ # Isolated GUI pages
├── __init__.py
├── extraction_tab.py # Tab 1: Sync controls & live progress logs
├── database_tab.py # Tab 2: SQLite database viewer & JSON metadata inspector
└── settings_tab.py # Tab 3: Azure App & Google API credential configuration
```
---
### Component Relationship Diagram
```
+---------------------------------------------------------------------------------+
| main.py |
| (Entry Point & App Init) |
+---------------------------------------------------------------------------------+
|
v
+---------------------------------------------------------------------------------+
| gui/main_window.py |
| (Holds QTabWidget) |
+---------------------------------------------------------------------------------+
| | |
v v v
+-------------------+ +-------------------+ +-------------------+
| gui/tabs/ | | gui/tabs/ | | gui/tabs/ |
| extraction_tab.py | | database_tab.py | | settings_tab.py |
| (Sync Controls) | | (SQLite & JSON | | (Azure & Gmail |
+-------------------+ | Inspector) | | Credentials) |
| +-------------------+ +-------------------+
| |
v |
+-------------------+ |
| services/ | |
| sync_worker.py | |
| (QThread) | |
+-------------------+ |
| | |
v v v
+----------+ +----------+ +-------------------+
| services/| | services/| | models/ |
| ms_graph | | gmail_api| | database.py |
| .py | | .py | | (SQLite & JSON) |
+----------+ +----------+ +-------------------+
| | |
+------+------+ |
| |
v v
+-------------------+ +-------------------+
| storage/ (.eml) | | email_archive.db |
| (Git-Ignored) | | (Git-Ignored) |
+-------------------+ +-------------------+
```
---
### Git & Forgejo Configuration
Since `.eml` downloads contain private email data and OAuth tokens grant access to live accounts, create a `.gitignore` file at the root to keep your Forgejo repository clean and secure:
```gitignore
# Byte-compiled / Python
__pycache__/
*.py[cod]
*$py.class
.venv/
# Environment & Dependencies
.uv/
# Local Storage & Databases
storage/*.eml
!storage/.gitkeep
*.db
*.sqlite3
# API Credentials & Tokens
credentials.json
token_gmail.json
.env
# System / macOS
.DS_Store
```
---
### Initializing Git & Connecting to Forgejo
1. **Initialize Git and directory structure:** Terminal.
Run the following in your terminal to create the directory skeleton and initialize Git:
```bash
mkdir -p 144-python-email-extractor/{doc/images,storage,src/{models,services,gui/{components,tabs}}}
touch 144-python-email-extractor/{storage,doc/images}/.gitkeep
cd 144-python-email-extractor
git init
```
2. **Initialize project with uv:** uv.
Initialize the Python environment and add dependencies:
```bash
uv init
uv add PySide6 msal requests google-api-python-client google-auth-oauthlib google-auth-httplib2
```
3. **Link to Forgejo repository:** Forgejo.
Add your Forgejo origin and push the initial commit:
```bash
git add .
git commit -m "Initial commit: 144-python-email-extractor project structure"
git remote add origin https://your-forgejo-server/username/144-python-email-extractor.git
git branch -M main
git push -u origin main
```
# Prompt
generate the complete set of modular Python files across src/ based on this structure
# Response
Here is the complete modular Python implementation for **144-python-email-extractor**. Every file is designed to be fully self-contained and placed inside your `src/` directory structure.
---
### File 1: `src/models/database.py`
Handles the SQLite database connection, table initialization, and saving/fetching records with the flexible JSON metadata blob.
```python
import sqlite3
import json
import os
DB_FILE = "email_archive.db"
def init_db(db_path=DB_FILE):
"""Initializes the SQLite database schema if it doesn't exist."""
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS emails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_email TEXT NOT NULL,
message_id TEXT NOT NULL UNIQUE,
subject TEXT,
sender TEXT,
received_date TEXT,
file_path TEXT NOT NULL,
extra_metadata TEXT, -- JSON blob for flexible fields (project, org, tags, etc.)
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
conn.close()
def save_email_record(account_email, message_id, subject, sender, received_date, file_path, metadata_dict, db_path=DB_FILE):
"""Inserts or updates an email record with extra JSON metadata."""
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
json_metadata = json.dumps(metadata_dict, indent=2)
cursor.execute("""
INSERT INTO emails (account_email, message_id, subject, sender, received_date, file_path, extra_metadata)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(message_id) DO UPDATE SET
extra_metadata = excluded.extra_metadata,
file_path = excluded.file_path,
subject = excluded.subject
""", (account_email, message_id, subject, sender, received_date, file_path, json_metadata))
conn.commit()
conn.close()
def fetch_all_emails(db_path=DB_FILE):
"""Retrieves all indexed email records ordered by newest ID."""
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("""
SELECT id, account_email, subject, sender, received_date, file_path, extra_metadata
FROM emails ORDER BY id DESC
""")
rows = cursor.fetchall()
conn.close()
return rows
```
---
### File 2: `src/services/ms_graph.py`
Handles Microsoft Graph authentication via MSAL and downloads emails as native RFC 822 `.eml` files using the `/$value` endpoint.
```python
import os
import re
import requests
import msal
STORAGE_DIR = os.path.abspath("./storage")
def sanitize_filename(name):
clean = re.sub(r'[\\/*?:"<>|]', "", name or "Untitled")
return clean[:80].strip()
class MSGraphDownloader:
def __init__(self, tenant_id, client_id, client_secret, account_email):
self.account_email = account_email
self.tenant_id = tenant_id
self.client_id = client_id
self.client_secret = client_secret
self.token = self._get_token()
def _get_token(self):
if not self.client_id or not self.client_secret or not self.tenant_id:
return None
app = msal.ConfidentialClientApplication(
self.client_id,
authority=f"https://login.microsoftonline.com/{self.tenant_id}",
client_credential=self.client_secret,
)
res = app.acquire_token_for_client(scopes=["https://graph.microsoft.com/.default"])
return res.get("access_token")
def download_inbox(self, callback_signal):
if not self.token:
callback_signal.emit(f"[{self.account_email}] Auth Error: Missing or invalid MS Azure credentials.")
return []
headers = {"Authorization": f"Bearer {self.token}", "Accept": "application/json"}
url = f"https://graph.microsoft.com/v1.0/users/{self.account_email}/mailFolders/inbox/messages?$select=id,subject,from,receivedDateTime&$top=20"
extracted = []
os.makedirs(STORAGE_DIR, exist_ok=True)
res = requests.get(url, headers=headers)
if res.status_code != 200:
callback_signal.emit(f"[{self.account_email}] Failed to fetch messages: Status {res.status_code}")
return []
messages = res.json().get("value", [])
for msg in messages:
msg_id = msg["id"]
subject = msg.get("subject", "No Subject")
sender = msg.get("from", {}).get("emailAddress", {}).get("address", "Unknown")
date_str = msg.get("receivedDateTime", "")[:10]
# Request raw MIME payload
raw_url = f"https://graph.microsoft.com/v1.0/users/{self.account_email}/messages/{msg_id}/$value"
eml_res = requests.get(raw_url, headers=headers)
if eml_res.status_code == 200:
fname = f"MS_{date_str}_{sanitize_filename(subject)}_{msg_id[-6:]}.eml"
fpath = os.path.abspath(os.path.join(STORAGE_DIR, fname))
with open(fpath, "wb") as f:
f.write(eml_res.content)
extracted.append({
"account": self.account_email,
"id": msg_id,
"subject": subject,
"sender": sender,
"date": date_str,
"path": fpath
})
callback_signal.emit(f"[{self.account_email}] Downloaded: {fname}")
else:
callback_signal.emit(f"[{self.account_email}] Failed raw fetch for {msg_id[-6:]}")
return extracted
```
---
### File 3: `src/services/gmail_api.py`
Connects to the Google Gmail API using OAuth2 credentials and converts base64url MIME responses into `.eml` files.
```python
import os
import re
import base64
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
STORAGE_DIR = os.path.abspath("./storage")
def sanitize_filename(name):
clean = re.sub(r'[\\/*?:"<>|]', "", name or "Untitled")
return clean[:80].strip()
class GmailDownloader:
SCOPES = ['https://www.googleapis.com/auth/gmail.readonly']
def __init__(self, credentials_json="credentials.json", account_email="slohning@gmail.com"):
self.account_email = account_email
self.creds_json = credentials_json
self.service = self._authenticate()
def _authenticate(self):
if not os.path.exists(self.creds_json):
return None
creds = None
token_file = 'token_gmail.json'
if os.path.exists(token_file):
creds = Credentials.from_authorized_user_file(token_file, self.SCOPES)
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
flow = InstalledAppFlow.from_client_secrets_file(self.creds_json, self.SCOPES)
creds = flow.run_local_server(port=0)
with open(token_file, 'w') as token:
token.write(creds.to_json())
return build('gmail', 'v1', credentials=creds)
def download_inbox(self, callback_signal):
if not self.service:
callback_signal.emit(f"[{self.account_email}] Auth Error: Missing {self.creds_json} configuration.")
return []
results = self.service.users().messages().list(userId='me', maxResults=20, q="label:INBOX").execute()
messages = results.get('messages', [])
extracted = []
os.makedirs(STORAGE_DIR, exist_ok=True)
for m in messages:
msg_id = m['id']
# Fetch raw RFC 822 format
raw_msg = self.service.users().messages().get(userId='me', id=msg_id, format='raw').execute()
meta_msg = self.service.users().messages().get(userId='me', id=msg_id, format='full').execute()
headers = meta_msg.get('payload', {}).get('headers', [])
subject = next((h['value'] for h in headers if h['name'].lower() == 'subject'), 'No Subject')
sender = next((h['value'] for h in headers if h['name'].lower() == 'from'), 'Unknown')
date_str = next((h['value'] for h in headers if h['name'].lower() == 'date'), 'UnknownDate')
eml_data = base64.urlsafe_b64decode(raw_msg['raw'].encode('ASCII'))
fname = f"GMAIL_{sanitize_filename(subject)}_{msg_id[-6:]}.eml"
fpath = os.path.abspath(os.path.join(STORAGE_DIR, fname))
with open(fpath, "wb") as f:
f.write(eml_data)
extracted.append({
"account": self.account_email,
"id": msg_id,
"subject": subject,
"sender": sender,
"date": date_str,
"path": fpath
})
callback_signal.emit(f"[{self.account_email}] Downloaded: {fname}")
return extracted
```
---
### File 4: `src/services/sync_worker.py`
Runs the asynchronous downloading and database indexing tasks in a dedicated `QThread` to prevent PySide6 GUI freezes.
```python
from PySide6.QtCore import QThread, Signal
from models.database import init_db, save_email_record
from services.ms_graph import MSGraphDownloader
from services.gmail_api import GmailDownloader
class SyncWorker(QThread):
log_signal = Signal(str)
finished_signal = Signal()
def __init__(self, project_name, organization, ms_credentials, gmail_credentials_file):
super().__init__()
self.project_name = project_name
self.organization = organization
self.ms_credentials = ms_credentials
self.gmail_credentials_file = gmail_credentials_file
def run(self):
self.log_signal.emit("Initializing database and network drivers...")
init_db()
records = []
# 1. Sync Microsoft Accounts
ms_accounts = ["stephen.lohning@oxnee.com", "slohning@live.com.au"]
for email in ms_accounts:
self.log_signal.emit(f"Connecting to Microsoft Graph for {email}...")
ms = MSGraphDownloader(
tenant_id=self.ms_credentials.get("tenant_id"),
client_id=self.ms_credentials.get("client_id"),
client_secret=self.ms_credentials.get("client_secret"),
account_email=email
)
records.extend(ms.download_inbox(self.log_signal))
# 2. Sync Gmail Account
self.log_signal.emit("Connecting to Google Gmail API for slohning@gmail.com...")
try:
gm = GmailDownloader(self.gmail_credentials_file, "slohning@gmail.com")
records.extend(gm.download_inbox(self.log_signal))
except Exception as e:
self.log_signal.emit(f"[slohning@gmail.com] Sync Error: {e}")
# 3. Save Records and metadata JSON into SQLite
self.log_signal.emit(f"Indexing {len(records)} files into SQLite database...")
for r in records:
extra_meta = {
"project_name": self.project_name,
"organization": self.organization,
"app_name": "144-python-email-extractor",
"version": "1.0.0"
}
save_email_record(
account_email=r["account"],
message_id=r["id"],
subject=r["subject"],
sender=r["sender"],
received_date=r["date"],
file_path=r["path"],
metadata_dict=extra_meta
)
self.log_signal.emit("\nExtraction and database indexing finished successfully!")
self.finished_signal.emit()
```
---
### File 5: `src/gui/components/log_viewer.py`
A reusable logging component used across GUI pages to output streaming log output.
```python
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QTextEdit
class LogViewer(QWidget):
def __init__(self, title="Live Extraction Logs", parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.lbl_title = QLabel(title)
self.txt_log = QTextEdit()
self.txt_log.setReadOnly(True)
self.txt_log.setStyleSheet("font-family: monospace; font-size: 11px;")
layout.addWidget(self.lbl_title)
layout.addWidget(self.txt_log)
def append_log(self, message):
self.txt_log.append(message)
def clear_logs(self):
self.txt_log.clear()
```
---
### File 6: `src/gui/tabs/extraction_tab.py`
Tab 1 page: Provides inputs for project metadata, extraction parameters, and sync status logs.
```python
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QLineEdit, QPushButton, QLabel
)
from gui.components.log_viewer import LogViewer
class ExtractionTab(QWidget):
def __init__(self, start_sync_callback, parent=None):
super().__init__(parent)
self.start_sync_callback = start_sync_callback
self.init_ui()
def init_ui(self):
layout = QVBoxLayout(self)
# Metadata Configuration Parameters
config_box = QGroupBox("Extraction Metadata Setup")
cfg_layout = QHBoxLayout(config_box)
self.txt_project = QLineEdit("Project_144")
self.txt_project.setPlaceholderText("Project Name")
self.txt_org = QLineEdit("Oxnee")
self.txt_org.setPlaceholderText("Organization")
self.btn_sync = QPushButton("Start Download & Extraction")
self.btn_sync.clicked.connect(self.on_sync_clicked)
cfg_layout.addWidget(QLabel("Project:"))
cfg_layout.addWidget(self.txt_project)
cfg_layout.addWidget(QLabel("Organization:"))
cfg_layout.addWidget(self.txt_org)
cfg_layout.addWidget(self.btn_sync)
layout.addWidget(config_box)
# Log Viewer Component
self.log_viewer = LogViewer()
layout.addWidget(self.log_viewer)
def on_sync_clicked(self):
proj = self.txt_project.text().strip()
org = self.txt_org.text().strip()
self.start_sync_callback(proj, org)
def set_syncing_state(self, is_syncing):
self.btn_sync.setEnabled(not is_syncing)
```
---
### File 7: `src/gui/tabs/database_tab.py`
Tab 2 page: Offers an interactive SQLite view and inspection panel for saved `.eml` files and JSON metadata blobs.
```python
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem,
QHeaderView, QLabel, QTextEdit, QSplitter, QPushButton
)
from PySide6.QtCore import Qt
from models.database import fetch_all_emails
class DatabaseTab(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.init_ui()
def init_ui(self):
layout = QVBoxLayout(self)
# Top Control Bar
top_bar = QHBoxLayout()
self.btn_refresh = QPushButton("Refresh Table")
self.btn_refresh.clicked.connect(self.refresh_table)
top_bar.addWidget(QLabel("SQLite Indexed Records:"))
top_bar.addStretch()
top_bar.addWidget(self.btn_refresh)
layout.addLayout(top_bar)
# Main Splitter (Table on top/left, JSON detail on bottom/right)
splitter = QSplitter(Qt.Vertical)
self.table = QTableWidget()
self.table.setColumnCount(6)
self.table.setHorizontalHeaderLabels(["ID", "Account", "Subject", "Sender", "Date", "File Path"])
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents)
self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
self.table.itemSelectionChanged.connect(self.on_row_selected)
splitter.addWidget(self.table)
# JSON Inspector Box
json_box = QWidget()
box_layout = QVBoxLayout(json_box)
box_layout.setContentsMargins(0, 0, 0, 0)
box_layout.addWidget(QLabel("JSON Metadata Preview:"))
self.txt_json = QTextEdit()
self.txt_json.setReadOnly(True)
self.txt_json.setStyleSheet("font-family: monospace;")
box_layout.addWidget(self.txt_json)
splitter.addWidget(json_box)
splitter.setSizes([350, 150])
layout.addWidget(splitter)
self.refresh_table()
def refresh_table(self):
rows = fetch_all_emails()
self.table.setRowCount(0)
self.raw_rows = rows # Keep full data in memory for JSON preview
for row_idx, row_data in enumerate(rows):
self.table.insertRow(row_idx)
# Render ID, Account, Subject, Sender, Date, Path
for col_idx in range(6):
val = str(row_data[col_idx])
item = QTableWidgetItem(val)
self.table.setItem(row_idx, col_idx, item)
def on_row_selected(self):
curr_row = self.table.currentRow()
if curr_row >= 0 and curr_row < len(self.raw_rows):
# Extra JSON metadata is at index 6 in the tuple
json_blob = self.raw_rows[curr_row][6]
self.txt_json.setText(json_blob or "{}")
```
---
### File 8: `src/gui/tabs/settings_tab.py`
Tab 3 page: Configuration management for Azure App secrets and Google API credentials.
```python
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QFormLayout, QGroupBox, QLineEdit, QLabel
)
class SettingsTab(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.init_ui()
def init_ui(self):
layout = QVBoxLayout(self)
# Microsoft Azure Entra ID Settings
ms_box = QGroupBox("Microsoft Graph API Credentials (Oxnee & Live.com)")
ms_layout = QFormLayout(ms_box)
self.txt_tenant = QLineEdit("YOUR_TENANT_ID")
self.txt_client_id = QLineEdit("YOUR_CLIENT_ID")
self.txt_secret = QLineEdit("YOUR_CLIENT_SECRET")
self.txt_secret.setEchoMode(QLineEdit.Password)
ms_layout.addRow(QLabel("Tenant ID:"), self.txt_tenant)
ms_layout.addRow(QLabel("Client ID:"), self.txt_client_id)
ms_layout.addRow(QLabel("Client Secret:"), self.txt_secret)
layout.addWidget(ms_box)
# Google API Settings
gmail_box = QGroupBox("Google Gmail API Credentials (Gmail)")
gmail_layout = QFormLayout(gmail_box)
self.txt_gmail_creds = QLineEdit("credentials.json")
gmail_layout.addRow(QLabel("OAuth Credentials File:"), self.txt_gmail_creds)
layout.addWidget(gmail_box)
layout.addStretch()
def get_ms_credentials(self):
return {
"tenant_id": self.txt_tenant.text().strip(),
"client_id": self.txt_client_id.text().strip(),
"client_secret": self.txt_secret.text().strip()
}
def get_gmail_credentials_file(self):
return self.txt_gmail_creds.text().strip()
```
---
### File 9: `src/gui/main_window.py`
The main application window that manages `QTabWidget` tabs and ties together worker thread execution.
```python
from PySide6.QtWidgets import QMainWindow, QTabWidget
from gui.tabs.extraction_tab import ExtractionTab
from gui.tabs.database_tab import DatabaseTab
from gui.tabs.settings_tab import SettingsTab
from services.sync_worker import SyncWorker
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("144-python-email-extractor")
self.resize(1000, 700)
# Tab Widget Container
self.tabs = QTabWidget()
self.setCentralWidget(self.tabs)
# Instantiate Tabs
self.tab_extraction = ExtractionTab(start_sync_callback=self.run_sync_process)
self.tab_database = DatabaseTab()
self.tab_settings = SettingsTab()
# Add pages to Tab Control
self.tabs.addTab(self.tab_extraction, "Extract & Sync")
self.tabs.addTab(self.tab_database, "Database Inspector")
self.tabs.addTab(self.tab_settings, "Account Settings")
def run_sync_process(self, project_name, organization):
self.tab_extraction.set_syncing_state(True)
self.tab_extraction.log_viewer.clear_logs()
ms_creds = self.tab_settings.get_ms_credentials()
gmail_file = self.tab_settings.get_gmail_credentials_file()
# Launch non-blocking QThread worker
self.worker = SyncWorker(
project_name=project_name,
organization=organization,
ms_credentials=ms_creds,
gmail_credentials_file=gmail_file
)
self.worker.log_signal.connect(self.tab_extraction.log_viewer.append_log)
self.worker.finished_signal.connect(self.on_sync_complete)
self.worker.start()
def on_sync_complete(self):
self.tab_extraction.set_syncing_state(False)
self.tab_database.refresh_table()
```
---
### File 10: `src/main.py`
Application entry point. Initializes `QApplication` and launches the UI window.
```python
import sys
import os
# Ensure src path resolution when running via uv
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from PySide6.QtWidgets import QApplication
from gui.main_window import MainWindow
from models.database import init_db
def main():
init_db() # Ensure database schema is ready
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
```
---
### Running the Complete Application
Launch the application using `uv`:
```bash
uv run src/main.py
```
# Prompt
when I ran the command uv run src/main.py
Reponse
Traceback (most recent call last):
  File "/Users/stephenlohning/Documents/01_Projects/144-python-email-extractor/src/main.py", line 10, in <module>
    from gui.main_window import MainWindow
  File "/Users/stephenlohning/Documents/01_Projects/144-python-email-extractor/src/gui/main_window.py", line 6, in <module>
    from gui.tabs.settings_tab import SettingsTab
ModuleNotFoundError: No module named 'gui.tabs.settings_tab'
stephenlohning@Scotty 144-python-email-extractor % 
# Response
The `ModuleNotFoundError` happens because Python treats imports differently depending on whether `src/` is in the module search path before the submodules try to resolve `gui`.
When `main.py` executes `from gui.main_window import MainWindow`, Python loads `gui/main_window.py`. But inside `main_window.py`, the import `from gui.tabs.settings_tab import SettingsTab` attempts an absolute import relative to `src/`. If Python's `sys.path` hasn't registered `src/` as a root package path prior to evaluating `gui`, relative/absolute package lookup fails.
Here are two quick ways to fix it:
---
### Solution 1: Use Relative Imports Inside the `gui` Package (Recommended)
Update `src/gui/main_window.py` to use explicit relative imports so the package resolves correctly regardless of how `main.py` is called.
In **`src/gui/main_window.py`**, change lines 2–5 to:
```python
from PySide6.QtWidgets import QMainWindow, QTabWidget
from .tabs.extraction_tab import ExtractionTab
from .tabs.database_tab import DatabaseTab
from .tabs.settings_tab import SettingsTab
from services.sync_worker import SyncWorker
```
Also check **`src/gui/tabs/extraction_tab.py`** and **`src/gui/tabs/database_tab.py`**:
* In `extraction_tab.py`, update:
```python
from ..components.log_viewer import LogViewer
```
---
### Solution 2: Standardize `PYTHONPATH` with `uv`
Alternatively, instruct `uv` to include `src` on the Python path automatically when running commands.
Add or update the following block in your `pyproject.toml` at the project root:
```toml
[tool.pytest.ini_options]
pythonpath = ["src"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src"]
```
Or run directly with the `PYTHONPATH` environment variable set:
```bash
PYTHONPATH=src uv run src/main.py
```
---
# Prompt
generate updated full file src/gui/main_window.py, extraction_tab.py,src/gui/tabs/database_tab.py
add commented out full file e.g.
# src/gui/main_window.py
# Response
```python
# src/gui/tabs/database_tab.py
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
QTableWidget,
QTableWidgetItem,
QLineEdit,
QPushButton,
QHeaderView,
)
from PySide6.QtCore import Qt
class DatabaseTab(QWidget):
def __init__(self, db_manager, parent=None):
super().__init__(parent)
self.db_manager = db_manager
self._init_ui()
def _init_ui(self):
layout = QVBoxLayout(self)
# Search Bar Controls
search_layout = QHBoxLayout()
self.search_input = QLineEdit()
self.search_input.setPlaceholderText("Search extracted records...")
self.search_btn = QPushButton("Search")
self.refresh_btn = QPushButton("Refresh")
search_layout.addWidget(self.search_input)
search_layout.addWidget(self.search_btn)
search_layout.addWidget(self.refresh_btnHere are the updated files with consistent relative imports so Python resolves package modules cleanly without throwing `ModuleNotFoundError`.
### `src/gui/main_window.py`
```python
# src/gui/main_window.py
import sys
from PySide6.QtWidgets import QMainWindow, QTabWidget, QVBoxLayout, QWidget
from .tabs.extraction_tab import ExtractionTab
from .tabs.database_tab import DatabaseTab
from .tabs.settings_tab import SettingsTab
from ..services.sync_worker import SyncWorker
class MainWindow(QMainWindow):
def __init__(self, db_manager, config_manager):
super().__init__()
self.db_manager = db_manager
self.config_manager = config_manager
self.sync_worker = None
self.setWindowTitle("Email Extractor")
self.setMinimumSize(900, 600)
self._init_ui()
def _init_ui(self):
central_widget = QWidget()
self.setCentralWidget(central_widget)
main_layout = QVBoxLayout(central_widget)
self.tab_widget = QTabWidget()
self.extraction_tab = ExtractionTab(self.db_manager, self.config_manager)
self.database_tab = DatabaseTab(self.db_manager)
self.settings_tab = SettingsTab(self.config_manager)
self.tab_widget.addTab(self.extraction_tab, "Extraction")
self.tab_widget.addTab(self.database_tab, "Database View")
self.tab_widget.addTab(self.settings_tab, "Settings")
main_layout.addWidget(self.tab_widget)
```
---
### `src/gui/tabs/extraction_tab.py`
```python
# src/gui/tabs/extraction_tab.py
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QProgressBar
from PySide6.QtCore import Qt, Signal
from ..components.log_viewer import LogViewer
class ExtractionTab(QWidget):
start_extraction = Signal()
stop_extraction = Signal()
def __init__(self, db_manager, config_manager, parent=None):
super().__init__(parent)
self.db_manager = db_manager
self.config_manager = config_manager
self._init_ui()
def _init_ui(self):
layout = QVBoxLayout(self)
# Control Panel
control_layout = QHBoxLayout()
self.btn_start = QPushButton("Start Sync / Extraction")
self.btn_stop = QPushButton("Stop")
self.btn_stop.setEnabled(False)
control_layout.addWidget(self.btn_start)
control_layout.addWidget(self.btn_stop)
control_layout.addStretch()
layout.addLayout(control_layout)
# Progress Section
self.status_label = QLabel("Status: Idle")
self.progress_bar = QProgressBar()
self.progress_bar.setRange(0, 100)
self.progress_bar.setValue(0)
layout.addWidget(self.status_label)
layout.addWidget(self.progress_bar)
# Log Output Viewer
self.log_viewer = LogViewer()
layout.addWidget(self.log_viewer)
def update_progress(self, current: int, total: int, status_text: str = ""):
if total > 0:
val = int((current / total) * 100)
self.progress_bar.setValue(val)
if status_text:
self.status_label.setText(f"Status: {status_text}")
```
---
### `src/gui/tabs/database_tab.py`
```python
# src/gui/tabs/database_tab.py
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QTableView,
QLineEdit, QPushButton, QLabel, QHeaderView
)
from PySide6.QtCore import Qt, QAbstractTableModel
class EmailTableModel(QAbstractTableModel):
def __init__(self, data=None):
super().__init__()
self._data = data or []
self._headers = ["ID", "Subject", "Sender", "Date Received", "Status"]
def rowCount(self, parent=None):
return len(self._data)
def columnCount(self, parent=None):
return len(self._headers)
def data(self, index, role=Qt.DisplayRole):
if not index.isValid() or role != Qt.DisplayRole:
return None
row = index.row()
col = index.column()
return self._data[row][col] if row < len(self._data) and col < len(self._data[row]) else None
def headerData(self, section, orientation, role=Qt.DisplayRole):
if orientation == Qt.Horizontal and role == Qt.DisplayRole:
return self._headers[section]
return None
def update_data(self, new_data):
self.beginResetModel()
self._data = new_data
self.endResetModel()
class DatabaseTab(QWidget):
def __init__(self, db_manager, parent=None):
super().__init__(parent)
self.db_manager = db_manager
self._init_ui()
def _init_ui(self):
layout = QVBoxLayout(self)
# Search / Filter Bar
filter_layout = QHBoxLayout()
self.search_input = QLineEdit()
self.search_input.setPlaceholderText("Filter emails by subject or sender...")
self.btn_refresh = QPushButton("Refresh")
filter_layout.addWidget(QLabel("Search:"))
filter_layout.addWidget(self.search_input)
filter_layout.addWidget(self.btn_refresh)
layout.addLayout(filter_layout)
# Table View
self.table_view = QTableView()
self.table_model = EmailTableModel()
self.table_view.setModel(self.table_model)
self.table_view.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
layout.addWidget(self.table_view)
```