diff --git a/api/src/cartsnitch_api/main.py b/api/src/cartsnitch_api/main.py index 4df6f09..6db5a0c 100644 --- a/api/src/cartsnitch_api/main.py +++ b/api/src/cartsnitch_api/main.py @@ -18,6 +18,7 @@ from cartsnitch_api.routes.purchases import router as purchases_router from cartsnitch_api.routes.scraping import router as scraping_router from cartsnitch_api.routes.shopping import router as shopping_router from cartsnitch_api.routes.stores import router as stores_router +from cartsnitch_api.routes.user import router as user_router @asynccontextmanager @@ -49,6 +50,7 @@ def create_app() -> FastAPI: # Data endpoints mounted under /api/v1 v1_router = APIRouter(prefix="/api/v1") + v1_router.include_router(user_router) v1_router.include_router(stores_router) v1_router.include_router(purchases_router) v1_router.include_router(products_router) diff --git a/api/src/cartsnitch_api/routes/user.py b/api/src/cartsnitch_api/routes/user.py new file mode 100644 index 0000000..18da0f1 --- /dev/null +++ b/api/src/cartsnitch_api/routes/user.py @@ -0,0 +1,26 @@ +"""User routes: per-user account endpoints (email-in address, etc.).""" + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from cartsnitch_api.auth.dependencies import get_current_user +from cartsnitch_api.database import get_db +from cartsnitch_api.schemas import EmailInAddressResponse +from cartsnitch_api.services.auth import AuthService + +router = APIRouter(tags=["user"]) + + +@router.get("/me/email-in-address", response_model=EmailInAddressResponse) +async def get_email_in_address( + user_id: str = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + svc = AuthService(db) + try: + email_address = await svc.get_email_in_address(user_id) + return EmailInAddressResponse(email_address=email_address) + except LookupError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="User not found" + ) from None diff --git a/api/src/cartsnitch_api/schemas.py b/api/src/cartsnitch_api/schemas.py index 68e1dbe..703b10c 100644 --- a/api/src/cartsnitch_api/schemas.py +++ b/api/src/cartsnitch_api/schemas.py @@ -1,6 +1,7 @@ """Pydantic v2 request/response schemas for all API endpoints.""" from datetime import datetime +from uuid import UUID from pydantic import BaseModel, EmailStr, Field @@ -21,6 +22,10 @@ class UserResponse(BaseModel): created_at: datetime +class EmailInAddressResponse(BaseModel): + email_address: str + + # ---------- Stores ---------- diff --git a/api/src/cartsnitch_api/services/auth.py b/api/src/cartsnitch_api/services/auth.py index 4894150..6cbf3d3 100644 --- a/api/src/cartsnitch_api/services/auth.py +++ b/api/src/cartsnitch_api/services/auth.py @@ -66,3 +66,14 @@ class AuthService: await self.db.delete(user) await self.db.commit() + + async def get_email_in_address(self, user_id: str) -> str: + """Return the per-user email-in address for receipt forwarding.""" + from cartsnitch_api.models import User + + result = await self.db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise LookupError("User not found") + + return f"{user.email_inbound_token}@email.cartsnitch.com" diff --git a/receiptwitness/src/receiptwitness/api/routes.py b/receiptwitness/src/receiptwitness/api/routes.py index 483cdcc..2437bcc 100644 --- a/receiptwitness/src/receiptwitness/api/routes.py +++ b/receiptwitness/src/receiptwitness/api/routes.py @@ -17,7 +17,11 @@ TOKEN_PATTERN = re.compile(r"receipts\+([A-Za-z0-9_-]+)@") def verify_mailgun_signature(token: str, timestamp: str, signature: str) -> bool: """Verify Mailgun webhook signature.""" - if abs(time.time() - int(timestamp)) > 300: # 5 min freshness + try: + ts = int(timestamp) + except (ValueError, TypeError): + return False + if abs(time.time() - ts) > 300: # 5 min freshness return False key = settings.mailgun_webhook_signing_key.encode() hmac_digest = hmac.new(key, f"{timestamp}{token}".encode(), hashlib.sha256).hexdigest() diff --git a/receiptwitness/tests/test_api/test_webhook.py b/receiptwitness/tests/test_api/test_webhook.py index 2b208de..164144a 100644 --- a/receiptwitness/tests/test_api/test_webhook.py +++ b/receiptwitness/tests/test_api/test_webhook.py @@ -99,3 +99,27 @@ def test_stale_timestamp(client, mock_redis): assert response.status_code == 406 assert response.json()["detail"] == "Invalid signature" mock_redis["enqueue"].assert_not_awaited() + + +def test_invalid_timestamp_returns_406(client, mock_redis): + """Empty timestamp should return 406, not 500.""" + with patch("receiptwitness.api.routes.settings") as mock_settings: + mock_settings.mailgun_webhook_signing_key = "test-secret" + form = { + "token": "test-token", + "timestamp": "", + "signature": "any-sig", + "sender": "sender@example.com", + "recipient": "receipts+user123@example.com", + "subject": "Receipt", + } + response = client.post("/inbound/email", data=form) + assert response.status_code == 406 + assert response.json()["detail"] == "Invalid signature" + mock_redis["enqueue"].assert_not_awaited() + + +def test_get_inbound_email_returns_405(client): + """GET /inbound/email is not allowed.""" + response = client.get("/inbound/email") + assert response.status_code == 405 diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 81fcb5e..8010491 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -15,7 +15,7 @@ export function Settings() { useEffect(() => { if (!session?.user) return - fetch('/auth/me/email-in-address', { + fetch('/api/v1/me/email-in-address', { credentials: 'include', }) .then((res) => res.json())