Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
6 changes: 4 additions & 2 deletions app/api/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ async def api_delete_emails(request: DeleteEmailsRequest):
detail="Sender email is required",
)
try:
return delete_emails_by_sender(request.sender)
return delete_emails_by_sender(request.sender, request.mail_scope)
except Exception as e:
logger.exception("Error deleting emails")
raise HTTPException(
Expand All @@ -132,7 +132,9 @@ async def api_delete_emails_bulk(
request: DeleteBulkRequest, background_tasks: BackgroundTasks
):
"""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, request.senders, request.mail_scope
)
return {"status": "started"}


Expand Down
32 changes: 32 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""

import hashlib
import os
import subprocess
import time
from contextlib import asynccontextmanager
Expand All @@ -19,6 +20,32 @@
templates = Jinja2Templates(directory="templates")


def get_asset_hash() -> str | None:
"""Hash served frontend assets so Docker builds without git still bust caches."""
asset_paths = []
for directory in ("static", "templates"):
if not os.path.isdir(directory):
continue
for root, _dirs, files in os.walk(directory):
for file_name in files:
if file_name.endswith((".css", ".html", ".js")):
asset_paths.append(os.path.join(root, file_name))

if not asset_paths:
return None

hasher = hashlib.sha256()
for file_path in sorted(asset_paths):
hasher.update(file_path.encode())
try:
with open(file_path, "rb") as f:
hasher.update(f.read())
except OSError:
pass

return hasher.hexdigest()[:8]


def get_cache_bust_value() -> str:
"""
Get cache-busting value using a robust strategy:
Expand Down Expand Up @@ -100,6 +127,11 @@ def get_cache_bust_value() -> str:
if base_value:
return base_value

# Docker images may not contain .git, so use frontend asset content.
asset_hash = get_asset_hash()
if asset_hash:
return asset_hash

# Fall back to app version
if settings.app_version:
return settings.app_version
Expand Down
35 changes: 35 additions & 0 deletions app/models/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ class FiltersModel(BaseModel):
description="Filter emails from specific sender (email address or domain)",
)
label: Optional[str] = Field(default=None, description="Gmail label filter")
mail_scope: Optional[str] = Field(
default="inbox", description="Mail scope: inbox or all"
)

@field_validator("older_than")
@classmethod
Expand Down Expand Up @@ -95,6 +98,16 @@ def validate_sender(cls, v) -> Optional[str]:
raise ValueError("sender must be a valid email address or domain")
return sender

@field_validator("mail_scope")
@classmethod
def validate_mail_scope(cls, v) -> str:
if v is None or v == "":
return "inbox"
value = v.strip().lower()
if value not in ("inbox", "all"):
raise ValueError('mail_scope must be either "inbox" or "all"')
return value


# ----- Request Models -----

Expand Down Expand Up @@ -142,12 +155,34 @@ class DeleteEmailsRequest(BaseModel):
"""Request to delete emails from a sender."""

sender: str = Field(default="", description="Sender email address")
mail_scope: str = Field(default="inbox", description="Mail scope: inbox or all")

@field_validator("mail_scope")
@classmethod
def validate_mail_scope(cls, v) -> str:
if v is None or v == "":
return "inbox"
value = v.strip().lower()
if value not in ("inbox", "all"):
raise ValueError('mail_scope must be either "inbox" or "all"')
return value


class DeleteBulkRequest(BaseModel):
"""Request to delete emails from multiple senders."""

senders: list[str] = Field(default=[], description="List of sender addresses")
mail_scope: str = Field(default="inbox", description="Mail scope: inbox or all")

@field_validator("mail_scope")
@classmethod
def validate_mail_scope(cls, v) -> str:
if v is None or v == "":
return "inbox"
value = v.strip().lower()
if value not in ("inbox", "all"):
raise ValueError('mail_scope must be either "inbox" or "all"')
return value


class DownloadEmailsRequest(BaseModel):
Expand Down
107 changes: 89 additions & 18 deletions app/services/gmail/delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,26 @@
logger = logging.getLogger(__name__)


def _get_mail_scope(filters: Optional[dict] = None) -> str:
if not filters:
return "inbox"
return "all" if filters.get("mail_scope") == "all" else "inbox"


def _build_scoped_query(query: str = "", mail_scope: str = "inbox") -> str:
"""Build a Gmail search query for the selected mail scope."""
query = (query or "").strip()
if mail_scope == "all":
return query
if not query:
return "in:inbox"
return f"in:inbox {query}"


def _list_params_for_scope(mail_scope: str) -> dict:
return {"labelIds": ["INBOX"]} if mail_scope == "inbox" else {}


def scan_senders_for_delete(limit: int = 1000, filters: Optional[dict] = None):
"""Scan emails and group by sender for bulk delete."""
# Validate input
Expand All @@ -38,12 +58,19 @@ def scan_senders_for_delete(limit: int = 1000, filters: Optional[dict] = None):
try:
state.delete_scan_status["message"] = "Fetching emails..."

query = build_gmail_query(filters)
mail_scope = _get_mail_scope(filters)
query = _build_scoped_query(build_gmail_query(filters), mail_scope)
scope_params = _list_params_for_scope(mail_scope)

results = (
service.users()
.messages()
.list(userId="me", maxResults=min(limit, 500), q=query or None)
.list(
userId="me",
maxResults=min(limit, 500),
q=query or None,
**scope_params,
)
.execute()
)

Expand All @@ -58,6 +85,7 @@ def scan_senders_for_delete(limit: int = 1000, filters: Optional[dict] = None):
maxResults=min(limit - len(messages), 500),
pageToken=results["nextPageToken"],
q=query or None,
**scope_params,
)
.execute()
)
Expand Down Expand Up @@ -87,15 +115,9 @@ def scan_senders_for_delete(limit: int = 1000, filters: Optional[dict] = None):
}
)
processed = 0
batch_size = 100

def process_message(request_id, response, exception) -> None:
nonlocal processed
processed += 1

if exception:
return
batch_size = 25

def process_message_response(response) -> None:
headers = response.get("payload", {}).get("headers", [])
sender_name, sender_email = get_sender_info(headers)
subject = get_subject(headers)
Expand Down Expand Up @@ -124,9 +146,47 @@ def process_message(request_id, response, exception) -> None:
sender_counts[sender_email]["first_date"] = email_date
sender_counts[sender_email]["last_date"] = email_date

def retry_message_get(msg_id: str) -> None:
nonlocal processed

for attempt in range(3):
try:
if attempt > 0:
time.sleep(0.5 * attempt)
response = (
service.users()
.messages()
.get(
userId="me",
id=msg_id,
format="metadata",
metadataHeaders=["From", "Subject", "Date"],
)
.execute()
)
process_message_response(response)
processed += 1
return
except Exception as e:
if attempt == 2:
logger.warning("Failed to fetch message %s: %s", msg_id, e)
processed += 1

Comment on lines +149 to +174

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Surface exhausted metadata retries; current flow still drops messages silently.

When retries fail, the message is skipped from aggregation and only logged, but status still looks fully successful. That can produce incomplete sender counts without user-visible signal.

Proposed fix
         processed = 0
         batch_size = 25
+        failed_metadata_ids: list[str] = []
@@
         def retry_message_get(msg_id: str) -> None:
             nonlocal processed
@@
                 except Exception as e:
                     if attempt == 2:
                         logger.warning("Failed to fetch message %s: %s", msg_id, e)
+                        failed_metadata_ids.append(msg_id)
             processed += 1
@@
             state.delete_scan_status["progress"] = progress
             state.delete_scan_status["message"] = f"Scanned {processed}/{total} emails"
@@
+        if failed_metadata_ids:
+            state.delete_scan_status["error"] = (
+                f"Skipped metadata for {len(failed_metadata_ids)} message(s) after retries"
+            )
+
         # Sort by count

Also applies to: 208-214

🧰 Tools
🪛 Ruff (0.15.17)

[warning] 170-170: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/services/gmail/delete.py` around lines 149 - 174, The retry_message_get
function currently increments the processed counter even when all retry attempts
are exhausted and the message fetch fails. This makes failed messages appear as
successful in the aggregation count, masking incomplete results. Modify the
retry_message_get function to only increment processed when
process_message_response successfully completes, and ensure that when all three
retry attempts fail, the function returns early without incrementing the counter
or signals the failure state so callers can detect that the message was not
successfully processed.

# Execute batch requests
for i in range(0, len(messages), batch_size):
batch_ids = messages[i : i + batch_size]
failed_ids = []

def process_message(request_id, response, exception) -> None:
nonlocal processed

if exception:
failed_ids.append(request_id)
return

process_message_response(response)
processed += 1

batch = service.new_batch_http_request(callback=process_message)

for msg_data in batch_ids:
Expand All @@ -139,10 +199,15 @@ def process_message(request_id, response, exception) -> None:
format="metadata",
metadataHeaders=["From", "Subject", "Date"],
)
,
request_id=msg_data["id"],
)

batch.execute()

for msg_id in failed_ids:
retry_message_get(msg_id)

progress = int((i + len(batch_ids)) / total * 100)
state.delete_scan_status["progress"] = progress
state.delete_scan_status["message"] = f"Scanned {processed}/{total} emails"
Expand Down Expand Up @@ -177,7 +242,7 @@ def get_delete_scan_results() -> list:
return state.delete_scan_results.copy()


def delete_emails_by_sender(sender: str) -> dict:
def delete_emails_by_sender(sender: str, mail_scope: str = "inbox") -> dict:
"""Delete all emails from a specific sender."""
if not sender or not sender.strip():
return {
Expand Down Expand Up @@ -215,11 +280,13 @@ def delete_emails_by_sender(sender: str) -> dict:

try:
# Find all emails from sender
query = f"from:{sender}"
mail_scope = "all" if mail_scope == "all" else "inbox"
query = _build_scoped_query(f"from:{sender}", mail_scope)
scope_params = _list_params_for_scope(mail_scope)
results = (
service.users()
.messages()
.list(userId="me", q=query, maxResults=500)
.list(userId="me", q=query or None, maxResults=500, **scope_params)
.execute()
)
messages = results.get("messages", [])
Expand All @@ -233,6 +300,7 @@ def delete_emails_by_sender(sender: str) -> dict:
q=query,
maxResults=500,
pageToken=results["nextPageToken"],
**scope_params,
)
.execute()
)
Expand Down Expand Up @@ -274,7 +342,7 @@ def delete_emails_by_sender(sender: str) -> dict:
return {"success": False, "deleted": 0, "size_freed": 0, "message": str(e)}


def delete_emails_bulk(senders: list[str]) -> dict:
def delete_emails_bulk(senders: list[str], mail_scope: str = "inbox") -> dict:
"""Delete emails from multiple senders."""
if not senders:
return {
Expand All @@ -289,7 +357,7 @@ def delete_emails_bulk(senders: list[str]) -> dict:
errors = []

for sender in senders:
result = delete_emails_by_sender(sender)
result = delete_emails_by_sender(sender, mail_scope)
if result["success"]:
total_deleted += result["deleted"]
total_size_freed += result.get("size_freed", 0)
Expand Down Expand Up @@ -321,7 +389,7 @@ def delete_emails_bulk(senders: list[str]) -> dict:
}


def delete_emails_bulk_background(senders: list[str]) -> None:
def delete_emails_bulk_background(senders: list[str], mail_scope: str = "inbox") -> None:
"""Delete emails from multiple senders with progress updates (background task).

Optimized to collect all message IDs first, then batch delete in larger chunks.
Expand All @@ -347,6 +415,8 @@ def delete_emails_bulk_background(senders: list[str]) -> None:
# Phase 1: Collect all message IDs from all senders
all_message_ids = []
errors = []
mail_scope = "all" if mail_scope == "all" else "inbox"
scope_params = _list_params_for_scope(mail_scope)

for i, sender in enumerate(senders):
state.delete_bulk_status["current_sender"] = i + 1
Expand All @@ -356,11 +426,11 @@ def delete_emails_bulk_background(senders: list[str]) -> None:
state.delete_bulk_status["message"] = f"Finding emails from {sender}..."

try:
query = f"from:{sender}"
query = _build_scoped_query(f"from:{sender}", mail_scope)
results = (
service.users()
.messages()
.list(userId="me", q=query, maxResults=500)
.list(userId="me", q=query or None, maxResults=500, **scope_params)
.execute()
)
messages = results.get("messages", [])
Expand All @@ -374,6 +444,7 @@ def delete_emails_bulk_background(senders: list[str]) -> None:
q=query,
maxResults=500,
pageToken=results["nextPageToken"],
**scope_params,
)
.execute()
)
Expand Down
Loading