- docs: fix email address format to receipts+<token>@receipts.cartsnitch.com (per Settings → Receipt Email UI, not the old farh.net domain format) - docs: fix UI section label from 'Account' to 'Receipt Email' - scripts/seed-env.sh: fix --env flag parser when called as './seed-env.sh --env uat' positional form was already correct; flag form was consuming --env as ENV value Co-Authored-By: Paperclip <noreply@paperclip.ing>
7.7 KiB
UAT Receipt Submission Path
Issue: CAR-812 Author: Barcode Betty Date: 2026-05-04
Overview
The UAT environment supports receipt submission via inbound email. This is the only supported submission method in UAT — there is no public REST API surface for receipt ingestion.
How It Works
Architecture
User composes email
↓
Email sent to <user_token>@cartsnitch.<env>.farh.net
↓
Mailgun webhook receives the email
↓
Email job enqueued to DragonflyDB stream: email:receipts
↓
email-worker (ReceiptWitness) consumes the job
↓
Worker resolves user via email_inbound_token lookup in DB
↓
Retailer detected from email content (meijer / kroger / target)
↓
Email parsed into Purchase + PurchaseItem records
↓
receipt.ingested event published to Redis
↓
MatchResult created with method=upc, confidence=1.0 for known UPCs
Key Components
| Component | Location | Role |
|---|---|---|
users.email_inbound_token |
DB (migration 001_add_email_inbound_token) |
22-char unique token per user; used as email routing identifier |
email:receipts stream |
DragonflyDB | Queue holding pending email jobs |
email-worker |
receiptwitness/src/receiptwitness/worker/email_worker.py |
Async worker consuming the stream |
BaseEmailParser |
receiptwitness/src/receiptwitness/parsers/email/base.py |
Abstract parser; subclasses for meijer/kroger/target |
| Retailer detectors | receiptwitness/src/receiptwitness/parsers/email/detector.py |
Sifts sender/subject to pick the right parser |
Email Address Format
Each user is assigned a unique inbound token. The receipt submission email address is shown in Settings → Receipt Email on the UI:
Address: receipts+<email_inbound_token>@receipts.cartsnitch.com
To find a user's token in the UAT database (requires kubectl access to cartsnitch-uat):
kubectl exec -n cartsnitch-uat deployment/cartsnitch-api -- \\
python -c "from cartsnitch_common.database import get_sync_session; \\
from cartsnitch_common.models.user import User; \\
from sqlalchemy import select; \\
s = get_sync_session('postgresql://cartsnitch:cartsnitch@cartsnitch-pg-rw:5432/cartsnitch'); \\
u = s.execute(select(User).where(User.email=='dottie@example.com')).scalar_one(); \\
print(u.email_inbound_token)"
Submitting a Test Receipt (Step-by-Step)
Prerequisites
- A test user account in UAT with a known
email_inbound_token - A sample receipt email with a known UPC from the seeded
normalized_productstable
Steps
-
Obtain the test user's inbound token. Use the UAT Settings → Receipt Email page in the UI to see the full address
receipts+<token>@receipts.cartsnitch.com, or query the DB directly (see above). -
Compose the email. Send to: the address shown in Settings → Receipt Email Subject: anything Body: plain-text or HTML receipt content
-
Expected behavior after email is processed:
- A
Receiptrow is created inpurchases PurchaseItemrows are created withupcmatching the seeded product UPC- A
MatchResultis created withmethod='upc'andconfidence=1.0
- A
Sample Receipt for Dottie's Regression
TODO — to be filled in after running the seed against UAT (Step 3 of CAR-812):
id:name:sample UPC: <upc-from-upc_variants-jsonb>Paste these after running
bash scripts/seed-env.sh uatand querying the DB.
Meijer Sample Receipt (plain text)
Meijer
===================================
Purchase Date: 03/15/2026
Store: Meijer #127 - Ann Arbor, MI
-----------------------------------
1 x Organic Whole Milk 1gal $4.99
1 x Whole Wheat Bread $3.29
1 x Bananas (2 lb) $0.67
1 x Chicken Breast (3 lb) $12.47
1 x Cheddar Cheese Block 8oz $5.99
-----------------------------------
Subtotal: $27.41
Tax: $1.93
Total: $29.34
===================================
THANK YOU FOR SHOPPING MEIJER
===================================
Note: The
email-workerparses the email body and extracts line items by retailer. The exact format and field mapping depends on the retailer parser. For Meijer, the parser looks for item lines matching(\d+) x (.+?)\s+\$([\d.]+). UPCs in theupc_variantsJSONB of seeded products will be matched during the normalization step.
Kroger Sample Receipt (plain text)
KROGER
===================================
Purchase Date: 03/15/2026
Store: KROGER #412 - Ann Arbor MI
-----------------------------------
1 Organic Whole Milk 1gal $5.29
1 Whole Wheat Bread $3.49
1 Bananas (2 lb) $0.69
1 Chicken Breast (3 lb) $11.99
1 Sharp Cheddar Cheese 8oz $4.99
-----------------------------------
Subtotal: $26.45
Tax: $1.85
Total: $28.30
===================================
Target Sample Receipt (plain text)
TARGET
===================================
03/15/2026 14:32
Store: 0874 Ann Arbor, MI
===================================
1 Organic Whole Milk 1G $5.49
1 Whole Wheat Bread $3.29
1 Bananas LB 2 $0.68
1 Chicken Breast 3# $12.99
1 Cheddar Cheese 8OZ $5.79
-----------------------------------
Subtotal: $28.24
Tax (6%): $1.69
Total: $29.93
===================================
Troubleshooting
Email not processed
-
Check the
email:receiptsstream has messages:kubectl exec -n cartsnitch-uat deploy/email-worker -- python -c \\ "import asyncio; from receiptwitness.queue.email import get_redis; \\ async def chk(): c = await get_redis(); info = await c.xinfo_stream('email:receipts'); print(info); \\ asyncio.run(chk())" -
Check
email-workerlogs for retailer detection failures:kubectl logs -n cartsnitch-uat deploy/email-worker -f -
Verify the token resolves to a user in the DB:
kubectl exec -n cartsnitch-uat deploy/cartsnitch-api -- \\ python -c "from cartsnitch_common.database import get_sync_session; \\ from cartsnitch_common.models.user import User; \\ from sqlalchemy import select; \\ s = get_sync_session('postgresql://...'); \\ r = s.execute(select(User.email_inbound_token).limit(5)).all(); \\ print(r)"
No MatchResult created
The normalization pipeline requires a normalized_product row with the submitted UPC in upc_variants. If the seed was run, the product should be found. Check the match_results table after submission:
SELECT mr.*, np.canonical_name
FROM match_results mr
JOIN normalized_products np ON np.id = mr.normalized_product_id
WHERE mr.match_method = 'upc'
ORDER BY mr.created_at DESC
LIMIT 10;
Related Files
| File | Role |
|---|---|
common/alembic/versions/001_add_email_inbound_token.py |
Adds email_inbound_token column |
receiptwitness/src/receiptwitness/worker/email_worker.py |
Consumes email jobs from stream |
receiptwitness/src/receiptwitness/queue/email.py |
DragonflyDB stream consumer group |
receiptwitness/src/receiptwitness/parsers/email/detector.py |
Retailer detection |
receiptwitness/src/receiptwitness/parsers/email/meijer.py |
Meijer email parser |
receiptwitness/src/receiptwitness/parsers/email/kroger.py |
Kroger email parser |
receiptwitness/src/receiptwitness/parsers/email/target.py |
Target email parser |
docs/uat-runbook.md |
UAT runbook (defect classification, entry/exit criteria) |