Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.worktrees
secret_fixtures/
16 changes: 16 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI(title="AI Email Client API")

app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

@app.get("/")
def read_root() -> dict[str, str]:
return {"status": "ok", "message": "AI Email Client API"}
4 changes: 4 additions & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
fastapi==0.109.0
uvicorn==0.27.0
pytest==8.0.0
httpx==0.26.0
Empty file added backend/services/__init__.py
Empty file.
82 changes: 82 additions & 0 deletions backend/services/archive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import asyncio
import zipfile
from pathlib import Path

from .exceptions import InvalidArchiveError, ArchiveSizeExceededError, ArchiveFileCountExceededError

MAX_EXTRACT_SIZE = 10 * 1024 * 1024 * 1024 # 10 GB
MAX_FILE_COUNT = 100000

def extract_backup(zip_path: str | Path, output_dir: str | Path) -> list[Path]:
"""
Extracts a zip archive to the specified output directory.

WARNING: This function performs blocking I/O operations. When called from
an async web layer (e.g. FastAPI), it must be executed in a thread pool using
`fastapi.concurrency.run_in_threadpool` or `asyncio.to_thread()`.

Args:
zip_path: The path to the zip archive to extract.
output_dir: The directory where the contents should be extracted.

Returns:
A list of Path objects for the extracted files (excluding directories).

Raises:
InvalidArchiveError: If the zip file is not found or corrupted.
ArchiveSizeExceededError: If the uncompressed size exceeds the maximum allowed limit.
ArchiveFileCountExceededError: If the archive exceeds the maximum allowed file count.
"""
zip_path = Path(zip_path)
output_dir = Path(output_dir).resolve()

output_dir.mkdir(parents=True, exist_ok=True)
extracted_paths = []

try:
with zipfile.ZipFile(zip_path, 'r') as z:
total_size = 0
file_count = 0

for info in z.infolist():
if info.is_dir():
continue

file_count += 1
if file_count > MAX_FILE_COUNT:
raise ArchiveFileCountExceededError(f"Archive exceeds maximum allowed file count of {MAX_FILE_COUNT}.")

# Sanitize path to prevent traversal
parts = [p for p in info.filename.replace('\\', '/').split('/') if p not in ('', '.', '..')]
if not parts:
continue
safe_name = "/".join(parts)

target_path = (output_dir / safe_name).resolve()

# Double-check that it is within output_dir
if not str(target_path).startswith(str(output_dir)):
continue

target_path.parent.mkdir(parents=True, exist_ok=True)

with z.open(info) as source, open(target_path, "wb") as target:
while chunk := source.read(8192):
total_size += len(chunk)
if total_size > MAX_EXTRACT_SIZE:
raise ArchiveSizeExceededError(f"Archive exceeds maximum allowed extraction size of {MAX_EXTRACT_SIZE} bytes.")
target.write(chunk)

extracted_paths.append(target_path)

except (zipfile.BadZipFile, FileNotFoundError) as e:
raise InvalidArchiveError(f"Failed to extract archive: {e}") from e

return extracted_paths

async def extract_backup_async(zip_path: str | Path, output_dir: str | Path) -> list[Path]:
"""
Async wrapper for extract_backup to be used safely in async contexts.
"""
return await asyncio.to_thread(extract_backup, zip_path, output_dir)

15 changes: 15 additions & 0 deletions backend/services/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
class ArchiveError(Exception):
"""Base exception for archive operations."""
pass

class InvalidArchiveError(ArchiveError):
"""Raised when an archive is invalid, corrupted, or not found."""
pass

class ArchiveSizeExceededError(ArchiveError):
"""Raised when an archive exceeds the maximum allowed extracted size."""
pass

class ArchiveFileCountExceededError(ArchiveError):
"""Raised when an archive exceeds the maximum allowed number of files."""
pass
Empty file added backend/tests/__init__.py
Empty file.
92 changes: 92 additions & 0 deletions backend/tests/test_archive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import os
import zipfile
import pytest
import asyncio
from pathlib import Path

from services.archive import extract_backup, extract_backup_async
from services.exceptions import InvalidArchiveError, ArchiveSizeExceededError, ArchiveFileCountExceededError

def test_extract_backup_success(tmp_path):
zip_path = tmp_path / "test.zip"
with zipfile.ZipFile(zip_path, 'w') as z:
z.writestr("test.eml", b"Subject: Test Email")
z.writestr("folder/", b"")
z.writestr("folder/test2.eml", b"Subject: Test Email 2")

out_dir = tmp_path / "output"
extracted_files = extract_backup(zip_path, out_dir)

# Check only files are returned, not directories
assert len(extracted_files) == 2
assert any(f.name == "test.eml" for f in extracted_files)
assert any(f.name == "test2.eml" for f in extracted_files)
assert not any(f.name == "folder" for f in extracted_files)

for f in extracted_files:
assert f.exists()
assert f.is_file()

def test_extract_backup_file_not_found(tmp_path):
with pytest.raises(InvalidArchiveError, match="Failed to extract archive"):
extract_backup(tmp_path / "missing.zip", tmp_path / "output")

def test_extract_backup_bad_zip_file(tmp_path):
bad_zip = tmp_path / "bad.zip"
bad_zip.write_text("This is not a zip file")

with pytest.raises(InvalidArchiveError, match="Failed to extract archive"):
extract_backup(bad_zip, tmp_path / "output")

def test_extract_backup_size_exceeded(tmp_path, monkeypatch):
import services.archive
monkeypatch.setattr(services.archive, "MAX_EXTRACT_SIZE", 10) # 10 bytes limit

zip_path = tmp_path / "test.zip"
with zipfile.ZipFile(zip_path, 'w') as z:
z.writestr("large.txt", b"A" * 20) # 20 bytes

with pytest.raises(ArchiveSizeExceededError, match="Archive exceeds maximum allowed extraction size"):
extract_backup(zip_path, tmp_path / "output")

def test_extract_backup_malformed_path(tmp_path):
zip_path = tmp_path / "malformed.zip"
with zipfile.ZipFile(zip_path, 'w') as z:
# Create a file with a relative path trying to escape directory
z.writestr("../malformed.eml", b"Subject: Malformed Email")

out_dir = tmp_path / "output"
extracted_files = extract_backup(zip_path, out_dir)

assert len(extracted_files) == 1
assert extracted_files[0].name == "malformed.eml"
assert ".." not in str(extracted_files[0])
for f in extracted_files:
assert f.exists()
assert f.is_file()

def test_extract_backup_file_count_exceeded(tmp_path, monkeypatch):
import services.archive
monkeypatch.setattr(services.archive, "MAX_FILE_COUNT", 2)

zip_path = tmp_path / "test_count.zip"
with zipfile.ZipFile(zip_path, 'w') as z:
z.writestr("file1.txt", b"1")
z.writestr("file2.txt", b"2")
z.writestr("file3.txt", b"3")

with pytest.raises(ArchiveFileCountExceededError, match="Archive exceeds maximum allowed file count"):
extract_backup(zip_path, tmp_path / "output")

def test_extract_backup_async(tmp_path):
zip_path = tmp_path / "test_async.zip"
with zipfile.ZipFile(zip_path, 'w') as z:
z.writestr("test.eml", b"Subject: Test Email")

out_dir = tmp_path / "output"
extracted_files = asyncio.run(extract_backup_async(zip_path, out_dir))

assert len(extracted_files) == 1
assert extracted_files[0].name == "test.eml"
assert extracted_files[0].exists()

9 changes: 9 additions & 0 deletions backend/tests/test_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from fastapi.testclient import TestClient
from main import app

client = TestClient(app)

def test_read_root():
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"status": "ok", "message": "AI Email Client API"}