Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Python
venv/
.venv/
__pycache__/
*.pyc
*.pyo
Expand Down
40 changes: 22 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,17 +142,18 @@ http://localhost:8766
```

3. Click **"Sign In"** button in the web UI

4. Check logs for the OAuth URL (only after clicking Sign In!):
- A new tab/window opens for Google authorization
- If the popup is blocked, check container logs for the OAuth URL:
```bash
docker logs $(docker ps -q --filter ancestor=ghcr.io/gururagavendra/gmail-cleaner)
docker logs $(docker ps -q --filter name=gmail-cleaner)
```
Or if you built locally:
- If you are using the published image:
```bash
docker logs $(docker ps -q --filter name=gmail-cleaner)
docker logs $(docker ps -q --filter ancestor=ghcr.io/gururagavendra/gmail-cleaner)
```
- Open the URL in your browser to continue

5. Copy the Google OAuth URL from logs, open in browser, and authorize:
4. Complete Google authorization:
- Choose your Google account
- "Google hasn't verified this app" → Click **Continue**
> This warning appears because you created your own OAuth app (not published to Google). This is expected and safe - you control the app!
Expand All @@ -161,22 +162,23 @@ docker logs $(docker ps -q --filter name=gmail-cleaner)

> **🌐 Using a custom domain, remote server, or custom port mapping?** See [Advanced Configuration](#advanced-configuration) for setup instructions.

#### Persisting Authentication (Data Directory)
#### Authentication Storage (Web UI)

For the web UI, authentication is stored **per browser** (localStorage) and sent with each request. This enables multi-user usage and avoids a shared server-side token.

The `docker-compose.yml` includes a `data` directory volume mount that automatically persists your authentication token.
- Each browser/device has its own session and token
- Clearing browser storage will require sign-in again

#### Token File Persistence (Optional)

If you are not using the web UI session storage, `token.json` can be stored on disk. The `docker-compose.yml` includes a `data` directory volume mount for this case.

**How it works:**

- The `./data` directory on your host is mounted to `/app/data` in the container
- When you authenticate, `token.json` is automatically saved to `/app/data/token.json` inside the container
- When you authenticate, `token.json` is saved to `/app/data/token.json` inside the container
- This file is persisted to `./data/token.json` on your host filesystem
- On subsequent container restarts, your authentication persists automatically

**No manual steps required!**

- ✅ First-time setup: Just run `docker compose up` - the `data` directory is created automatically
- ✅ Authentication persists: Your token is saved to `./data/token.json` on the host
- ✅ Container restarts: Your authentication is automatically loaded from the persisted file
- On subsequent container restarts, your authentication can be loaded from the persisted file

**To reset authentication:**

Expand Down Expand Up @@ -267,6 +269,8 @@ If you're using **custom port mappings** in Docker (e.g., mapping `18766:8766` a

If you're accessing via a **custom domain** (e.g., `gmail.example.com`) instead of `localhost`:

By default, the app uses the hostname from your browser request (Host or X-Forwarded-Host) to build the OAuth redirect URL. Set `OAUTH_HOST` only if you need to override that value.

> **⚠️ Important**:
> - Use **Web application** credentials (not Desktop app) for remote server setups. See [Step 7 in Get Google OAuth Credentials](#1-get-google-oauth-credentials).
> - **IP addresses are NOT allowed** in Google OAuth redirect URIs. You must use a domain name (e.g., `gmail.example.com`), not an IP address (e.g., `192.168.1.100`).
Expand All @@ -289,7 +293,7 @@ If you're accessing via a **custom domain** (e.g., `gmail.example.com`) instead
- Add: `http://YOUR_DOMAIN:8767/` (or external port if using custom mapping)
- **Must be a domain name, not an IP address**

2. **Update docker-compose.yml**:
2. **Optional: Override the redirect host in docker-compose.yml** (only if your proxy does not forward the host correctly):

```yaml
environment:
Expand Down Expand Up @@ -358,7 +362,7 @@ If you see `OAuth error: (mismatching_state) CSRF Warning`:

#### Docker: "Where do I find the OAuth URL?"

Check the container logs:
If the browser popup is blocked, check the container logs:

```bash
docker logs $(docker ps -q --filter name=gmail-cleaner)
Expand Down
129 changes: 100 additions & 29 deletions app/api/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@

import logging
from functools import partial
from fastapi import APIRouter, BackgroundTasks, HTTPException, status
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status

from app.api.deps import SessionContext, get_session_context
from app.models import (
ScanRequest,
MarkReadRequest,
Expand Down Expand Up @@ -45,27 +46,52 @@


@router.post("/scan")
async def api_scan(request: ScanRequest, background_tasks: BackgroundTasks):
async def api_scan(
request: ScanRequest,
background_tasks: BackgroundTasks,
ctx: SessionContext = Depends(get_session_context),
):
"""Start email scan for unsubscribe links."""
filters_dict = (
request.filters.model_dump(exclude_none=True) if request.filters else None
)
background_tasks.add_task(scan_emails, request.limit, filters_dict)
background_tasks.add_task(scan_emails, ctx.session, request.limit, filters_dict)
return {"status": "started"}


def _get_request_host_and_scheme(request: Request) -> tuple[str, str]:
forwarded_host = request.headers.get("x-forwarded-host")
forwarded_proto = request.headers.get("x-forwarded-proto")
host = forwarded_host or request.url.hostname or "localhost"
if host and ":" in host and not host.startswith("["):
host = host.split(":", 1)[0]
scheme = forwarded_proto or request.url.scheme or "http"
if scheme not in ("http", "https"):
scheme = "http"
if scheme == "https":
scheme = "http"
return host, scheme
Comment thread
daddyparodz marked this conversation as resolved.


@router.post("/sign-in")
async def api_sign_in(background_tasks: BackgroundTasks):
async def api_sign_in(
request: Request,
background_tasks: BackgroundTasks,
ctx: SessionContext = Depends(get_session_context),
):
"""Trigger OAuth sign-in flow."""
background_tasks.add_task(get_gmail_service)
host, scheme = _get_request_host_and_scheme(request)
ctx.session.oauth_host = host
ctx.session.oauth_scheme = scheme
background_tasks.add_task(get_gmail_service, ctx.session, ctx.token_json, host, scheme)
return {"status": "signing_in"}


@router.post("/sign-out")
async def api_sign_out():
async def api_sign_out(ctx: SessionContext = Depends(get_session_context)):
"""Sign out and clear credentials."""
try:
return sign_out()
return sign_out(ctx.session)
except Exception as e:
logger.exception("Error during sign-out")
raise HTTPException(
Expand All @@ -88,37 +114,50 @@ async def api_unsubscribe(request: UnsubscribeRequest):


@router.post("/mark-read")
async def api_mark_read(request: MarkReadRequest, background_tasks: BackgroundTasks):
async def api_mark_read(
request: MarkReadRequest,
background_tasks: BackgroundTasks,
ctx: SessionContext = Depends(get_session_context),
):
"""Mark emails as read."""
filters_dict = (
request.filters.model_dump(exclude_none=True) if request.filters else None
)
background_tasks.add_task(mark_emails_as_read, request.count, filters_dict)
background_tasks.add_task(
mark_emails_as_read, ctx.session, request.count, filters_dict
)
return {"status": "started"}


@router.post("/delete-scan")
async def api_delete_scan(
request: DeleteScanRequest, background_tasks: BackgroundTasks
request: DeleteScanRequest,
background_tasks: BackgroundTasks,
ctx: SessionContext = Depends(get_session_context),
):
"""Scan senders for bulk delete."""
filters_dict = (
request.filters.model_dump(exclude_none=True) if request.filters else None
)
background_tasks.add_task(scan_senders_for_delete, request.limit, filters_dict)
background_tasks.add_task(
scan_senders_for_delete, ctx.session, request.limit, filters_dict
)
return {"status": "started"}


@router.post("/delete-emails")
async def api_delete_emails(request: DeleteEmailsRequest):
async def api_delete_emails(
request: DeleteEmailsRequest,
ctx: SessionContext = Depends(get_session_context),
):
"""Delete emails from a specific sender."""
if not request.sender or not request.sender.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Sender email is required",
)
try:
return delete_emails_by_sender(request.sender)
return delete_emails_by_sender(ctx.session, request.sender)
except Exception as e:
logger.exception("Error deleting emails")
raise HTTPException(
Expand All @@ -129,31 +168,40 @@ async def api_delete_emails(request: DeleteEmailsRequest):

@router.post("/delete-emails-bulk")
async def api_delete_emails_bulk(
request: DeleteBulkRequest, background_tasks: BackgroundTasks
request: DeleteBulkRequest,
background_tasks: BackgroundTasks,
ctx: SessionContext = Depends(get_session_context),
):
"""Delete emails from multiple senders (background task with progress)."""
background_tasks.add_task(delete_emails_bulk_background, request.senders)
background_tasks.add_task(
delete_emails_bulk_background, ctx.session, request.senders
)
return {"status": "started"}


@router.post("/download-emails")
async def api_download_emails(
request: DownloadEmailsRequest, background_tasks: BackgroundTasks
request: DownloadEmailsRequest,
background_tasks: BackgroundTasks,
ctx: SessionContext = Depends(get_session_context),
):
"""Start downloading email metadata for selected senders."""
# Note: Empty list is allowed - service function will handle it gracefully
background_tasks.add_task(download_emails_background, request.senders)
background_tasks.add_task(download_emails_background, ctx.session, request.senders)
return {"status": "started"}


# ----- Label Management Endpoints -----


@router.post("/labels")
async def api_create_label(request: CreateLabelRequest):
async def api_create_label(
request: CreateLabelRequest,
ctx: SessionContext = Depends(get_session_context),
):
"""Create a new Gmail label."""
try:
return create_label(request.name)
return create_label(ctx.session, request.name)
except Exception as e:
logger.exception("Error creating label")
raise HTTPException(
Expand All @@ -163,15 +211,17 @@ async def api_create_label(request: CreateLabelRequest):


@router.delete("/labels/{label_id}")
async def api_delete_label(label_id: str):
async def api_delete_label(
label_id: str, ctx: SessionContext = Depends(get_session_context)
):
"""Delete a Gmail label."""
if not label_id or not label_id.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Label ID is required",
)
try:
return delete_label(label_id)
return delete_label(ctx.session, label_id)
except Exception as e:
logger.exception("Error deleting label")
raise HTTPException(
Expand All @@ -182,7 +232,9 @@ async def api_delete_label(label_id: str):

@router.post("/apply-label")
async def api_apply_label(
request: ApplyLabelRequest, background_tasks: BackgroundTasks
request: ApplyLabelRequest,
background_tasks: BackgroundTasks,
ctx: SessionContext = Depends(get_session_context),
):
"""Apply a label to emails from selected senders."""
if not request.label_id or not request.label_id.strip():
Expand All @@ -196,14 +248,19 @@ async def api_apply_label(
detail="At least one sender is required",
)
background_tasks.add_task(
apply_label_to_senders_background, request.label_id, request.senders
apply_label_to_senders_background,
ctx.session,
request.label_id,
request.senders,
)
return {"status": "started"}


@router.post("/remove-label")
async def api_remove_label(
request: RemoveLabelRequest, background_tasks: BackgroundTasks
request: RemoveLabelRequest,
background_tasks: BackgroundTasks,
ctx: SessionContext = Depends(get_session_context),
):
"""Remove a label from emails from selected senders."""
if not request.label_id or not request.label_id.strip():
Expand All @@ -217,26 +274,35 @@ async def api_remove_label(
detail="At least one sender is required",
)
background_tasks.add_task(
remove_label_from_senders_background, request.label_id, request.senders
remove_label_from_senders_background,
ctx.session,
request.label_id,
request.senders,
)
return {"status": "started"}


@router.post("/archive")
async def api_archive(request: ArchiveRequest, background_tasks: BackgroundTasks):
async def api_archive(
request: ArchiveRequest,
background_tasks: BackgroundTasks,
ctx: SessionContext = Depends(get_session_context),
):
"""Archive emails from selected senders (remove from inbox)."""
if not request.senders:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="At least one sender is required",
)
background_tasks.add_task(archive_emails_background, request.senders)
background_tasks.add_task(archive_emails_background, ctx.session, request.senders)
return {"status": "started"}


@router.post("/mark-important")
async def api_mark_important(
request: MarkImportantRequest, background_tasks: BackgroundTasks
request: MarkImportantRequest,
background_tasks: BackgroundTasks,
ctx: SessionContext = Depends(get_session_context),
):
"""Mark/unmark emails from selected senders as important."""
if not request.senders:
Expand All @@ -245,6 +311,11 @@ async def api_mark_important(
detail="At least one sender is required",
)
background_tasks.add_task(
partial(mark_important_background, request.senders, important=request.important)
partial(
mark_important_background,
ctx.session,
request.senders,
important=request.important,
)
)
return {"status": "started"}
Loading
Loading