forked from cartsnitch/cartsnitch
Merge pull request #107 from cartsnitch/fix/inbound-email-500
fix: move email-in-address endpoint from /auth to /api/v1 prefix
This commit is contained in:
@@ -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.scraping import router as scraping_router
|
||||||
from cartsnitch_api.routes.shopping import router as shopping_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.stores import router as stores_router
|
||||||
|
from cartsnitch_api.routes.user import router as user_router
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -49,6 +50,7 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
# Data endpoints mounted under /api/v1
|
# Data endpoints mounted under /api/v1
|
||||||
v1_router = APIRouter(prefix="/api/v1")
|
v1_router = APIRouter(prefix="/api/v1")
|
||||||
|
v1_router.include_router(user_router)
|
||||||
v1_router.include_router(stores_router)
|
v1_router.include_router(stores_router)
|
||||||
v1_router.include_router(purchases_router)
|
v1_router.include_router(purchases_router)
|
||||||
v1_router.include_router(products_router)
|
v1_router.include_router(products_router)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Pydantic v2 request/response schemas for all API endpoints."""
|
"""Pydantic v2 request/response schemas for all API endpoints."""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel, EmailStr, Field
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
@@ -21,6 +22,10 @@ class UserResponse(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class EmailInAddressResponse(BaseModel):
|
||||||
|
email_address: str
|
||||||
|
|
||||||
|
|
||||||
# ---------- Stores ----------
|
# ---------- Stores ----------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -66,3 +66,14 @@ class AuthService:
|
|||||||
|
|
||||||
await self.db.delete(user)
|
await self.db.delete(user)
|
||||||
await self.db.commit()
|
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"
|
||||||
|
|||||||
@@ -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:
|
def verify_mailgun_signature(token: str, timestamp: str, signature: str) -> bool:
|
||||||
"""Verify Mailgun webhook signature."""
|
"""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
|
return False
|
||||||
key = settings.mailgun_webhook_signing_key.encode()
|
key = settings.mailgun_webhook_signing_key.encode()
|
||||||
hmac_digest = hmac.new(key, f"{timestamp}{token}".encode(), hashlib.sha256).hexdigest()
|
hmac_digest = hmac.new(key, f"{timestamp}{token}".encode(), hashlib.sha256).hexdigest()
|
||||||
|
|||||||
@@ -99,3 +99,27 @@ def test_stale_timestamp(client, mock_redis):
|
|||||||
assert response.status_code == 406
|
assert response.status_code == 406
|
||||||
assert response.json()["detail"] == "Invalid signature"
|
assert response.json()["detail"] == "Invalid signature"
|
||||||
mock_redis["enqueue"].assert_not_awaited()
|
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
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function Settings() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session?.user) return
|
if (!session?.user) return
|
||||||
fetch('/auth/me/email-in-address', {
|
fetch('/api/v1/me/email-in-address', {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
|
|||||||
Reference in New Issue
Block a user