1764 lines
61 KiB
Markdown
1764 lines
61 KiB
Markdown
|
||
# 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)
|
||
|
||
```
|
||
|