Compare commits

..

12 Commits

Author SHA1 Message Date
cartsnitch-cto[bot] 5cc2bb78e9 Merge pull request #111 from cartsnitch/fix/ci-api-docker-context
fix(ci): correct API Docker build context to api/ directory
2026-04-03 17:12:38 +00:00
cartsnitch-engineer[bot] c9075be6e0 fix(ci): correct API Docker build context to api/ directory 2026-04-03 17:07:03 +00:00
cartsnitch-engineer[bot] 6c297b5e81 fix: correct email-in-address format, remove dead code, update tests (#110)
- Fix email format in AuthService.get_email_in_address to use
  receipts+{token}@receipts.cartsnitch.com (was broken: @email.cartsnitch.com)
- Remove dead EmailInAddressResponse class and GET /auth/me/email-in-address
  endpoint from auth/routes.py (endpoint moved to routes/user.py)
- Add instructions field to EmailInAddressResponse schema
- Update routes/user.py to include instructions in the response
- Update test URLs from /auth/me/email-in-address to /api/v1/me/email-in-address

Co-authored-by: CartSnitch Engineer Bot <cartnoreply@cartsnitch.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-03 13:34:21 +00:00
cartsnitch-cto[bot] 80004e4285 feat(ci): add deploy-uat job for UAT environment (#109)
Mirrors deploy-dev job but targets apps/overlays/uat. Both deploy-dev
and deploy-uat run in parallel after all build jobs complete.

Co-authored-by: CartSnitch Engineer Bot <cartnoreply@cartsnitch.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-03 13:27:47 +00:00
cartsnitch-cto[bot] 94f99595fc fix(deps): resolve npm audit vulnerabilities (brace-expansion, lodash) (#108)
- Override brace-expansion to >=1.1.13 to resolve GHSA-f886-m6hf-6m8v
- Override lodash to >=4.17.24 to resolve GHSA-r5fr-rjxr-66jc and GHSA-f23m-r3pf-42rh
- Override minimatch to ^10.2.4 to maintain compatibility with brace-expansion@5.x

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: CartSnitch Engineer Bot <cartnoreply@cartsnitch.com>
2026-04-03 13:23:20 +00:00
cartsnitch-qa[bot] c8de30ec6e Merge pull request #107 from cartsnitch/fix/inbound-email-500
fix: move email-in-address endpoint from /auth to /api/v1 prefix
2026-04-03 12:39:22 +00:00
CartSnitch Engineer Bot c1dc3e77e0 fix(receiptwitness): handle invalid timestamp in Mailgun webhook verification
Wrap int(timestamp) in try/except to return False instead of raising
ValueError on empty/invalid timestamp, which was causing a 500 error
instead of the intended 406.

Also add tests for empty timestamp (→ 406) and GET /inbound/email (→ 405).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-03 12:09:51 +00:00
CartSnitch Engineer Bot 1af98c40ab fix: move email-in-address endpoint from /auth to /api/v1 prefix
The GET /me/email-in-address endpoint was unreachable because the Gateway
HTTPRoute routes all /auth/* traffic to Better-Auth (port 3001), not the
API service. This change:
- Moves the endpoint from the /auth router to a new /api/v1/me/ router
- Adds EmailInAddressResponse schema and get_email_in_address service method
- Updates Settings.tsx to call /api/v1/me/email-in-address

Fixes CAR-445.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-03 11:44:31 +00:00
cartsnitch-ceo[bot] 1aaa8e78fd feat(frontend): show email-in address on Settings page (#103)
feat(frontend): show email-in address on Settings page
2026-04-03 11:27:58 +00:00
cartsnitch-qa[bot] c3bfd3560b Merge branch 'main' into feat/email-in-settings 2026-04-03 11:25:04 +00:00
CartSnitch Engineer Bot d52fb83296 fix(frontend): correct email-in-address fetch URL to /auth prefix
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-03 10:32:32 +00:00
CartSnitch Engineer Bot 7c45b04dce feat(frontend): show email-in address on Settings page with copy button
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-03 09:45:45 +00:00
12 changed files with 745 additions and 684 deletions
+63 -2
View File
@@ -334,8 +334,8 @@ jobs:
- name: Build and push API Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./api/Dockerfile
context: ./api
file: ./Dockerfile
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -400,3 +400,64 @@ jobs:
git add apps/overlays/dev/kustomization.yaml
git commit -m "ci(dev): update cartsnitch, auth, receiptwitness, and api images"
git push origin main
deploy-uat:
runs-on: runners-cartsnitch
needs: [build-and-push, build-and-push-auth, build-and-push-receiptwitness, build-and-push-api]
if: always() && !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CARTSNITCH_APP_ID }}
private-key: ${{ secrets.CARTSNITCH_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: infra
- name: Checkout infra repo
uses: actions/checkout@v4
with:
repository: cartsnitch/infra
token: ${{ steps.app-token.outputs.token }}
ref: main
path: infra
- name: Install kubectl
uses: azure/setup-kubectl@v4
- name: Install kustomize
uses: imranismail/setup-kustomize@v2
- name: Update frontend image tag
if: needs.build-and-push.result == 'success'
run: |
cd infra/apps/overlays/uat
kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ needs.build-and-push.outputs.calver_tag }}
- name: Update auth image tag
if: needs.build-and-push-auth.result == 'success'
run: |
cd infra/apps/overlays/uat
kustomize edit set image ghcr.io/cartsnitch/auth:${{ needs.build-and-push-auth.outputs.calver_tag }}
- name: Update receiptwitness image tag
if: needs.build-and-push-receiptwitness.result == 'success'
run: |
cd infra/apps/overlays/uat
kustomize edit set image ghcr.io/cartsnitch/receiptwitness:${{ needs.build-and-push-receiptwitness.outputs.calver_tag }}
- name: Update api image tag
if: needs.build-and-push-api.result == 'success'
run: |
cd infra/apps/overlays/uat
kustomize edit set image ghcr.io/cartsnitch/api:${{ needs.build-and-push-api.outputs.calver_tag }}
- name: Commit and push to infra
run: |
cd infra
git config user.name "cartsnitch-ci[bot]"
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
git add apps/overlays/uat/kustomization.yaml
git commit -m "ci(uat): update cartsnitch, auth, receiptwitness, and api images"
git push origin main
-25
View File
@@ -22,11 +22,6 @@ from cartsnitch_api.services.auth import AuthService
router = APIRouter(prefix="/auth", tags=["auth"])
class EmailInAddressResponse(BaseModel):
email_address: str
instructions: str
@router.get("/me", response_model=UserResponse)
async def get_me(
user_id: str = Depends(get_current_user),
@@ -70,23 +65,3 @@ async def delete_me(
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
) from None
@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),
):
result = await db.execute(select(User.email_inbound_token).where(User.id == user_id))
token = result.scalar_one_or_none()
if not token:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Email inbound token not found"
) from None
return EmailInAddressResponse(
email_address=f"receipts+{token}@receipts.cartsnitch.com",
instructions=(
"Forward your digital receipt emails to this address. "
"We currently support Meijer, Kroger, and Target receipt emails."
),
)
+2
View File
@@ -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)
+32
View File
@@ -0,0 +1,32 @@
"""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,
instructions=(
"Forward your digital receipt emails to this address. "
"We currently support Meijer, Kroger, and Target receipt emails."
),
)
except LookupError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
) from None
+6
View File
@@ -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,11 @@ class UserResponse(BaseModel):
created_at: datetime
class EmailInAddressResponse(BaseModel):
email_address: str
instructions: str
# ---------- Stores ----------
+11
View File
@@ -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"receipts+{user.email_inbound_token}@receipts.cartsnitch.com"
+5 -5
View File
@@ -1,4 +1,4 @@
"""Tests for GET /auth/me/email-in-address endpoint."""
"""Tests for GET /api/v1/me/email-in-address endpoint."""
import pytest
from httpx import AsyncClient
@@ -8,7 +8,7 @@ from httpx import AsyncClient
async def test_get_email_in_address_authenticated(client: AsyncClient, auth_headers: dict):
"""Authenticated user gets their email-in address."""
response = await client.get(
"/auth/me/email-in-address",
"/api/v1/me/email-in-address",
headers=auth_headers,
)
@@ -27,7 +27,7 @@ async def test_get_email_in_address_authenticated(client: AsyncClient, auth_head
@pytest.mark.asyncio
async def test_get_email_in_address_unauthenticated(client: AsyncClient):
"""Unauthenticated request returns 401."""
response = await client.get("/auth/me/email-in-address")
response = await client.get("/api/v1/me/email-in-address")
assert response.status_code == 401
@@ -35,7 +35,7 @@ async def test_get_email_in_address_unauthenticated(client: AsyncClient):
async def test_get_email_in_address_invalid_token(client: AsyncClient):
"""Invalid JWT token returns 401."""
response = await client.get(
"/auth/me/email-in-address",
"/api/v1/me/email-in-address",
headers={"Authorization": "Bearer invalid-token-xyz"},
)
assert response.status_code == 401
@@ -45,7 +45,7 @@ async def test_get_email_in_address_invalid_token(client: AsyncClient):
async def test_email_address_format(client: AsyncClient, auth_headers: dict):
"""Email address format is receipts+{22-char-urlsafe-token}@receipts.cartsnitch.com."""
response = await client.get(
"/auth/me/email-in-address",
"/api/v1/me/email-in-address",
headers=auth_headers,
)
+548 -650
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -50,6 +50,9 @@
"overrides": {
"@rollup/pluginutils": "5.3.0",
"flatted": "^3.4.2",
"serialize-javascript": "7.0.5"
"serialize-javascript": "7.0.5",
"brace-expansion": ">=1.1.13",
"lodash": ">=4.17.24",
"minimatch": "^10.2.4"
}
}
@@ -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()
@@ -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
+45
View File
@@ -1,3 +1,4 @@
import { useState, useEffect } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { authClient } from '../lib/auth-client.ts'
import { useAuthStore } from '../stores/auth.ts'
@@ -9,6 +10,26 @@ export function Settings() {
const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
const navigate = useNavigate()
const { theme, setTheme } = useThemeStore()
const [emailInAddress, setEmailInAddress] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!session?.user) return
fetch('/api/v1/me/email-in-address', {
credentials: 'include',
})
.then((res) => res.json())
.then((data) => setEmailInAddress(data.email_address))
.catch(() => setEmailInAddress(null))
}, [session])
async function handleCopyEmail() {
if (emailInAddress) {
await navigator.clipboard.writeText(emailInAddress)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
const user = session?.user
const connectedStores: string[] = []
@@ -113,6 +134,30 @@ export function Settings() {
</button>
</div>
</section>
{/* Receipt Email section */}
<section className="mt-6">
<h2 className="mb-3 text-sm font-semibold text-gray-500">Receipt Email</h2>
<div className="rounded-xl bg-white p-4 shadow-sm">
<p className="mb-2 text-sm text-gray-600">
Forward your digital receipt emails to this address:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-lg bg-gray-100 px-3 py-2 text-sm font-mono text-gray-800 truncate">
{emailInAddress ?? 'Loading...'}
</code>
<button
onClick={handleCopyEmail}
className="rounded-lg bg-brand-blue px-3 py-2 text-sm font-medium text-white hover:bg-brand-blue/90 transition-colors"
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
<p className="mt-2 text-xs text-gray-400">
Supports Meijer, Kroger, and Target receipt emails.
</p>
</div>
</section>
</div>
)
}