diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index e69de29..e64ddef 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -0,0 +1,1764 @@ + +# 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 +    from gui.main_window import MainWindow +  File "/Users/stephenlohning/Documents/01_Projects/144-python-email-extractor/src/gui/main_window.py", line 6, in +    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) + +``` + diff --git a/doc/ARCHITECTURE.pdf b/doc/ARCHITECTURE.pdf new file mode 100644 index 0000000..a15fc30 Binary files /dev/null and b/doc/ARCHITECTURE.pdf differ diff --git a/src/gui/components/log_viewer.py b/src/gui/components/log_viewer.py index e69de29..ab3f006 100644 --- a/src/gui/components/log_viewer.py +++ b/src/gui/components/log_viewer.py @@ -0,0 +1,23 @@ +# src/gui/components/log_viewer.py + +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() diff --git a/src/gui/main_window.py b/src/gui/main_window.py index e69de29..22a4a42 100644 --- a/src/gui/main_window.py +++ b/src/gui/main_window.py @@ -0,0 +1,51 @@ +# src/gui/main_window.py + +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() + diff --git a/src/gui/tabs/database_tab.py b/src/gui/tabs/database_tab.py index e69de29..6cee678 100644 --- a/src/gui/tabs/database_tab.py +++ b/src/gui/tabs/database_tab.py @@ -0,0 +1,72 @@ +# src/gui/tabs/database_tab.py + +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 "{}") \ No newline at end of file diff --git a/src/gui/tabs/extraction_tab.py b/src/gui/tabs/extraction_tab.py index e69de29..cbca0bb 100644 --- a/src/gui/tabs/extraction_tab.py +++ b/src/gui/tabs/extraction_tab.py @@ -0,0 +1,46 @@ +# src/gui/tabs/extraction_tab.py +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) \ No newline at end of file diff --git a/src/main.py b/src/main.py index e69de29..110a1ba 100644 --- a/src/main.py +++ b/src/main.py @@ -0,0 +1,23 @@ +# src/main.py + +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() \ No newline at end of file diff --git a/src/models/database.py b/src/models/database.py index e69de29..dddaa25 100644 --- a/src/models/database.py +++ b/src/models/database.py @@ -0,0 +1,59 @@ +# src/models/database.py + +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 \ No newline at end of file diff --git a/src/services/gmail_api.py b/src/services/gmail_api.py index e69de29..b511bf3 100644 --- a/src/services/gmail_api.py +++ b/src/services/gmail_api.py @@ -0,0 +1,84 @@ +# src/services/gmail_api.py +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 + \ No newline at end of file diff --git a/src/services/ms_graph.py b/src/services/ms_graph.py index e69de29..ea3571b 100644 --- a/src/services/ms_graph.py +++ b/src/services/ms_graph.py @@ -0,0 +1,80 @@ +# src/services/ms_graph.py +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 + \ No newline at end of file diff --git a/src/services/sync_worker.py b/src/services/sync_worker.py index e69de29..1e7c609 100644 --- a/src/services/sync_worker.py +++ b/src/services/sync_worker.py @@ -0,0 +1,65 @@ +# src/services/sync_worker.py + +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() \ No newline at end of file