Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
3f7ef6f
refactor: use platformdirs for data storage and allow setup mode
karan-vk Feb 12, 2026
371c446
ci: add github actions build workflow
karan-vk Feb 12, 2026
38c3446
chore: update gitignore for test and tool artifacts
karan-vk Feb 12, 2026
72decff
feat: implement setup api endpoint for credentials upload
karan-vk Feb 12, 2026
ca2cd94
feat: add frontend setup view for missing credentials
karan-vk Feb 12, 2026
9a5a0e8
docs: add agent documentation and analysis files
karan-vk Feb 12, 2026
518c4e1
ci: remove macos-13 runner due to configuration error
karan-vk Feb 12, 2026
58adc1f
ci: add auto-release on tag push
karan-vk Feb 12, 2026
2dac02c
docs: update README with standalone app capabilities
karan-vk Feb 12, 2026
6fafb57
ci: optimize build workflow and update release action
karan-vk Feb 12, 2026
68fa67b
docs: fix formatting and language identifiers in local AGENTS.md guides
karan-vk Feb 12, 2026
aa9dc1c
docs: update main AGENTS.md with platformdirs and setup route
karan-vk Feb 12, 2026
3bd2214
refactor: sort API router exports alphabetically
karan-vk Feb 12, 2026
ecedcf4
fix(backend): security improvements and robust setup logic
karan-vk Feb 12, 2026
f319145
fix(build): enhance cross-platform packaging
karan-vk Feb 12, 2026
0eaa416
fix(frontend): improve file upload UX and validation
karan-vk Feb 12, 2026
497f57a
docs: update README based on CodeRabbit feedback
karan-vk Feb 12, 2026
0499370
docs: add community governance and improve documentation
karan-vk Feb 12, 2026
7590827
chore: remove AGENTS.md from git tracking
karan-vk Feb 12, 2026
735ccbc
feat(ui): redesign setup flow with multi-step wizard
karan-vk Feb 12, 2026
bb87e8f
feat(core): add custom exception hierarchy and error handling utilities
karan-vk Feb 12, 2026
7202818
feat(config): add performance settings for large inbox optimization
karan-vk Feb 12, 2026
1e05128
feat(scan): implement streaming mode for large inbox support
karan-vk Feb 12, 2026
a27f36c
refactor(gmail): integrate error handling decorators across services
karan-vk Feb 12, 2026
f97fbd1
refactor(api): improve error responses with structured error codes
karan-vk Feb 12, 2026
e0e1d82
feat(ui): add toast notification system for user feedback
karan-vk Feb 12, 2026
2371c45
refactor(ui): integrate notifications into existing UI components
karan-vk Feb 12, 2026
7559984
test(integration): add comprehensive integration test suite
karan-vk Feb 12, 2026
321a2ce
docs(roadmap): mark in-progress items as completed
karan-vk Feb 12, 2026
33cec6b
docs(readme): update release artifact filenames to match CI output
karan-vk Feb 13, 2026
66f53ac
chore: move GMAIL_SERVICES_ANALYSIS.md to .github/agent/reference/
karan-vk Feb 13, 2026
79922a0
fix(build): correct python_multipart module name in hiddenimports
karan-vk Feb 13, 2026
e00e186
feat(ui): improve setup wizard with missing credential steps
karan-vk Feb 13, 2026
3327cf2
docs(ui): add setup video tutorial link to wizard
karan-vk Feb 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Build

on:
push:
branches: [ main ]
tags: [ 'v*' ]
pull_request:
branches: [ main ]

jobs:
build:
name: Build on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
# macos-13 is the last Intel runner
# macos-14 is Apple Silicon (M1)
os: [ubuntu-latest, windows-latest, macos-14]

steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5

- name: Set up Python
run: uv python install 3.12

- name: Install dependencies
run: uv sync

- name: Build with PyInstaller
run: uv run python build_app.py

- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
name: gmail-cleaner-${{ matrix.os }}
path: dist/gmail-cleaner/
5 changes: 3 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,6 @@ IMPROVEMENTS.md
htmlcov/
coverage.xml

# GitHub Copilot instructions (user specific)
.github/copilot-instructions.md
# PyInstaller
build/
dist/
81 changes: 81 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# AGENTS.md - Development Guide

**Generated:** Thu, Feb 12, 2026
**Commit:** {DYNAMIC}
**Branch:** main
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

## OVERVIEW
Privacy-focused Gmail cleanup tool using **FastAPI** (Python 3.9+) and **Vanilla JS**. Runs locally/Docker, no external database.

## STRUCTURE
```
.
├── app/ # FastAPI backend
│ ├── api/ # Routes (actions.py, status.py)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
│ ├── core/ # Config & State (Global variables)
│ ├── models/ # Pydantic schemas
│ └── services/ # Business logic
│ ├── auth.py # OAuth flow & Credential mgmt (God Object)
│ └── gmail/ # Gmail API operations (Scan, Delete, Archive)
├── static/ # Frontend (Vanilla JS)
│ ├── js/ # Logic (Module pattern, global namespace)
│ └── css/ # Styles
├── templates/ # HTML (Jinja2)
└── tests/ # Pytest suite
├── unit/ # Isolated tests (Mocked)
└── integration/ # [EMPTY] Missing integration tests
```

## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| **Auth/Login** | `app/services/auth.py` | Handles OAuth, tokens, local server. Complex. |
| **Email Logic** | `app/services/gmail/` | Modular operations (scan, delete, etc.). |
| **Frontend UI** | `static/js/` | Ad-hoc framework. `main.js` orchestrates. |
| **State** | `app/core/state.py` | Global thread-safe state for async tasks. |
| **API Routes** | `app/api/` | `actions.py` (POST), `status.py` (GET). |
| **Config** | `app/core/config.py` | Settings via Pydantic (`.env`). |

## CODE MAP (Key Symbols)
| Symbol | Type | Location | Role |
|--------|------|----------|------|
| `GmailService` | Class | `app/services/auth.py` | Wrapper for Gmail API resource. |
| `get_gmail_service` | Func | `app/services/auth.py` | **CRITICAL**. Retrieves/refreshes auth. |
| `process_scan` | Func | `app/services/gmail/scan.py` | Core scanning logic. |
| `GmailCleaner` | Obj | `static/js/main.js` | Global frontend namespace (State/UI). |
| `app` | Var | `app/main.py` | FastAPI application instance. |

## CONVENTIONS
- **State**: Backend uses `app.core.state` global variables for progress tracking.
- **Frontend**: "Module Pattern" with global `GmailCleaner`. No build step.
- **Async**: Backend is async (FastAPI), but uses `threading` for background tasks.
- **Testing**:
- `pytest` with `pytest-asyncio`.
- **Mocks**: Aggressive mocking of `credentials.json` in `conftest.py`.
- **Fixtures**: `mock_gmail_auth` (autouse) prevents real auth in tests.

## ANTI-PATTERNS (THIS PROJECT)
- **NO SECRETS**: NEVER commit `credentials.json`, `token.json`, or `.env`.
- **No `try/except`**: Use `HTTPException` or custom exceptions.
- **Frontend Coupling**: Logic/UI tightly coupled in `labels.js`/`delete.js`.
- **Global State**: Frontend relies on global `window.GmailCleaner`.

## COMMANDS
```bash
# Dev
uv sync
uv run python main.py

# Test
uv run pytest
uv run pytest tests/unit/services/auth/

# Lint
uv run ruff check .
uv run pyright
```

## NOTES
- **Auth Flow**: Uses a local server callback. Docker requires port mapping.
- **Data Persistence**: `token.json` stored in `./data/` (Docker volume).
- **Concurrency**: `app/services/gmail` uses batch requests (performance).
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
38 changes: 38 additions & 0 deletions GMAIL_SERVICES_ANALYSIS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Deep Dive Analysis: `app/services/gmail`

## 1. Overview
The `app/services/gmail` directory is well-structured and modular. It follows a clear separation of concerns, with each file handling a specific aspect of Gmail operations (scanning, deleting, archiving, etc.). The `__init__.py` file acts as a clean facade, exposing necessary functions while hiding internal implementation details.

## 2. Pattern Consistency
The codebase demonstrates high consistency in the following areas:

* **State Management**: All modules consistently use the global `state` object from `app.core.state` to track progress, store results, and handle errors. This ensures a unified way of communicating status to the frontend.
* **Service Retrieval**: Every module retrieves the Gmail service using `get_gmail_service()` from `app.services.auth`, handling potential errors in a uniform way.
* **Batch Processing**: Operations like scanning and deleting consistently use Gmail API's batch capabilities (`new_batch_http_request` or `batchModify`) to optimize performance and respect rate limits.
* **Status Reporting**: Each module provides a `get_<operation>_status()` function that returns a copy of the relevant state, maintaining a consistent interface for status polling.
* **Error Handling**: `try-except` blocks are used consistently to catch exceptions, update the state with the error message, and mark the operation as done.

## 3. Shared Helpers
`app/services/gmail/helpers.py` is effectively used to share common logic, preventing code duplication. Key helpers include:
* `build_gmail_query`: Centralizes query construction logic.
* `get_sender_info` & `get_subject`: Standardizes header parsing.
* `get_unsubscribe_from_headers`: Encapsulates the logic for finding unsubscribe links.
* `validate_unsafe_url`: Provides shared security validation.

## 4. "God Objects" & Complexity
While most files are focused, a few areas show signs of high complexity:

* **`app/services/auth.py` (External to `gmail` dir but critical)**: This file is a "God Object" candidate (685 lines). The `get_gmail_service` function contains a massive nested `run_oauth` function that handles the entire OAuth flow, including starting a local HTTP server. This logic should ideally be extracted into a separate `OAuthManager` class or module.
* **`app/services/gmail/delete.py`**: This file (439 lines) handles both the *scanning* of senders for deletion and the *actual deletion* (both single sender and bulk). Splitting this into `delete_scan.py` and `delete_action.py` would improve maintainability and separation of concerns.

## 5. Circular Dependencies
**Status: Clean.**
No circular dependencies were found. The dependency graph is unidirectional and healthy:
* `app/services/gmail/*` imports `app.core.state`, `app.services.auth`, and `app.services.gmail.helpers`.
* `app/services/auth` imports `app.core.state` and `app.core.settings`.
* `app/core/state` has no internal dependencies.
* `app/services/gmail/__init__.py` imports from submodules but submodules do not import back from `__init__`.

## 6. Recommendations
1. **Refactor `app/services/auth.py`**: Extract the OAuth flow logic into a dedicated handler to reduce the size and complexity of the auth service.
2. **Split `app/services/gmail/delete.py`**: Separate the scanning logic (`scan_senders_for_delete`) from the deletion logic (`delete_emails_by_sender`, `delete_emails_bulk`) to align with the single-responsibility principle.
3 changes: 2 additions & 1 deletion app/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@

from .status import router as status_router
from .actions import router as actions_router
from .setup import router as setup_router

__all__ = ["status_router", "actions_router"]
__all__ = ["status_router", "actions_router", "setup_router"]
63 changes: 63 additions & 0 deletions app/api/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""
Setup API Routes
----------------
Endpoints for initial application setup (uploading credentials).
"""

import json
import logging
import os

from fastapi import APIRouter, File, HTTPException, UploadFile, status

from app.core import settings

router = APIRouter(prefix="/api", tags=["Setup"])
logger = logging.getLogger(__name__)


@router.post("/setup")
async def setup_credentials(file: UploadFile = File(...)):
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"""Upload credentials.json file."""
try:
content = await file.read()

# Validate JSON structure
try:
data = json.loads(content)
except json.JSONDecodeError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid JSON file",
)

# Validate content (must be Google OAuth credentials)
if "installed" not in data and "web" not in data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid credentials file. Must contain 'installed' or 'web' client configuration.",
)

# Ensure directory exists
os.makedirs(
os.path.dirname(os.path.abspath(settings.credentials_file)), exist_ok=True
)

# Save to settings.credentials_file
with open(settings.credentials_file, "wb") as f:
f.write(content)

logger.info(f"Credentials uploaded successfully to {settings.credentials_file}")
return {
"message": "Credentials uploaded successfully",
"path": settings.credentials_file,
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

except HTTPException:
raise
except Exception as e:
logger.exception("Error uploading credentials")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to upload credentials: {str(e)}",
)
69 changes: 27 additions & 42 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""

import os
import platformdirs
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

Expand Down Expand Up @@ -46,53 +47,37 @@ def validate_web_auth(cls, v) -> bool:

credentials_file: str = "credentials.json"
token_file: str = "token.json"
data_dir: str = ""

def __init__(self, **kwargs):
"""Initialize settings and auto-detect data directory for token persistence."""
super().__init__(**kwargs)
# Auto-detect /app/data directory in Docker and use it for token_file
# This allows token.json to persist across container restarts

# 1. Determine Data Directory
# Check for Docker environment first
if os.path.exists("/app/data") and os.path.isdir("/app/data"):
# Normalize the base directory path
base_dir = os.path.abspath(os.path.realpath("/app/data"))

if os.path.isabs(self.token_file):
# If token_file is absolute, verify it's within /app/data
resolved_path = os.path.abspath(os.path.realpath(self.token_file))
# Unsafe conditions: path traversal, points to base_dir, or is a directory
if (
not resolved_path.startswith(base_dir + os.sep)
or resolved_path == base_dir
or os.path.isdir(resolved_path)
):
# Absolute path unsafe - use safe fallback
name = os.path.basename(self.token_file)
if name in ("", "."):
name = "token.json"
self.token_file = os.path.join(base_dir, name)
else:
# Valid absolute path within /app/data (file, not directory)
self.token_file = resolved_path
else:
# Relative path - join and validate
candidate_path = os.path.join(base_dir, self.token_file)
resolved_path = os.path.abspath(os.path.realpath(candidate_path))

# Verify resolved path is within base_dir and is a file (prevents path traversal)
# Unsafe conditions: path traversal, points to base_dir, or is a directory
if (
not resolved_path.startswith(base_dir + os.sep)
or resolved_path == base_dir
or os.path.isdir(resolved_path)
):
# Path traversal detected or directory path - use safe fallback with basename only
name = os.path.basename(self.token_file)
if name in ("", "."):
name = "token.json"
self.token_file = os.path.join(base_dir, name)
else:
# Safe path - use resolved path (file, not directory)
self.token_file = resolved_path
self.data_dir = "/app/data"
else:
# Local environment - use platform-specific user data dir
self.data_dir = platformdirs.user_data_dir(
"gmail-cleaner", "Gururagavendra"
)

# 2. Ensure directory exists
try:
os.makedirs(self.data_dir, exist_ok=True)
except OSError:
# Fallback to current directory if we can't create the data dir
# This might happen in some restricted environments
self.data_dir = os.getcwd()
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# 3. Resolve file paths
# If the file paths are just filenames (default), join with data_dir
if not os.path.isabs(self.credentials_file):
self.credentials_file = os.path.join(self.data_dir, self.credentials_file)

if not os.path.isabs(self.token_file):
self.token_file = os.path.join(self.data_dir, self.token_file)

# Gmail API
scopes: list[str] = [
Expand Down
25 changes: 22 additions & 3 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,30 @@
import hashlib
import subprocess
import time
import sys
import os
from contextlib import asynccontextmanager

from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates

from app.core import settings
from app.api import status_router, actions_router
from app.api import status_router, actions_router, setup_router

templates = Jinja2Templates(directory="templates")

def resource_path(relative_path):
"""Get absolute path to resource, works for dev and for PyInstaller"""
try:
# PyInstaller creates a temp folder and stores path in _MEIPASS
base_path = sys._MEIPASS
except Exception:
base_path = os.path.abspath(".")

return os.path.join(base_path, relative_path)


templates = Jinja2Templates(directory=resource_path("templates"))


def get_cache_bust_value() -> str:
Expand All @@ -27,6 +41,10 @@ def get_cache_bust_value() -> str:
3. Fall back to app version from settings
4. Fall back to timestamp if both unavailable
"""
# Skip git checks if frozen (PyInstaller)
if getattr(sys, "frozen", False):
return settings.app_version or str(int(time.time()))

base_value = None

# Try git commit hash first
Expand Down Expand Up @@ -135,11 +153,12 @@ def create_app() -> FastAPI:
)

# Mount static files
app.mount("/static", StaticFiles(directory="static"), name="static")
app.mount("/static", StaticFiles(directory=resource_path("static")), name="static")

# Include API routers
app.include_router(status_router)
app.include_router(actions_router)
app.include_router(setup_router)

# HTML routes
@app.get("/", include_in_schema=False)
Expand Down
Loading