diff --git a/bounty-4/DESIGN.md b/bounty-4/DESIGN.md
new file mode 100644
index 00000000..97c7ada5
--- /dev/null
+++ b/bounty-4/DESIGN.md
@@ -0,0 +1,259 @@
+# Email Threads API — Design Document
+
+## Overview
+
+A thread-first REST API for warpSpeed's email system. Threads group related messages (original + replies) into a single conversation view. The API supports listing, opening, replying, and creating threads.
+
+## Base URL
+
+```
+/api/v1/threads
+```
+
+## Data Model
+
+```
+Thread (1) ──── (N) Message
+ │ │
+ │ └── (N) Attachment
+ │
+ └── (N) Participant
+```
+
+- **Thread**: A conversation identified by an initial subject line. Collapses all messages sharing the same Message-ID / References header chain.
+- **Message**: An individual email within a thread. Ordered by `receivedAt`.
+- **Participant**: A person who sent or received messages in the thread. Deduplicated across messages.
+- **Attachment**: A file attached to a specific message.
+
+## Endpoints
+
+### `GET /threads` — List threads
+
+Returns a paginated, sorted list of threads for the authenticated user.
+
+**Query Parameters**
+
+| Param | Type | Default | Description |
+|-------------|---------|------------|--------------------------------------------------|
+| `page` | number | `1` | Page number (1-indexed) |
+| `limit` | number | `20` | Items per page (max 100) |
+| `sort` | string | `lastMessageAt` | Sort field: `lastMessageAt`, `createdAt`, `subject` |
+| `order` | `asc` / `desc` | `desc` | Sort direction |
+| `q` | string | — | Full-text search across subject & body |
+| `category` | string | — | Filter: `inbox`, `sent`, `drafts`, `archived`, `spam`, `trash` |
+| `read` | boolean | — | Filter by read/unread status |
+| `starred` | boolean | — | Filter by starred status |
+| `from` | string | — | Filter by sender email |
+| `label` | string | — | Filter by label |
+| `before` | ISO8601 | — | Threads with `lastMessageAt` before this time |
+| `after` | ISO8601 | — | Threads with `lastMessageAt` after this time |
+
+**Response `200 OK`**
+
+```json
+{
+ "data": [ /* EmailThread[] */ ],
+ "pagination": {
+ "page": 1,
+ "limit": 20,
+ "totalItems": 142,
+ "totalPages": 8,
+ "hasNext": true,
+ "hasPrev": false
+ }
+}
+```
+
+**Response `200 OK` (empty)**
+
+```json
+{
+ "data": [],
+ "pagination": {
+ "page": 1,
+ "limit": 20,
+ "totalItems": 0,
+ "totalPages": 0,
+ "hasNext": false,
+ "hasPrev": false
+ }
+}
+```
+
+---
+
+### `GET /threads/:id` — Open a thread
+
+Returns the thread metadata plus all messages ordered chronologically.
+
+**Path Parameters**
+
+| Param | Type | Description |
+|-------|--------|---------------------|
+| `id` | string | Thread UUID |
+
+**Response `200 OK`**
+
+```json
+{
+ "data": {
+ "thread": { /* EmailThread */ },
+ "messages": [ /* EmailMessage[] */ ]
+ }
+}
+```
+
+**Error Responses**
+
+| Status | Body |
+|--------|-----------------------------------------------|
+| 404 | `{ "error": "NOT_FOUND", "message": "Thread not found" }` |
+
+---
+
+### `POST /threads/:id/reply` — Reply within a thread
+
+Sends a reply message in an existing thread.
+
+**Path Parameters**
+
+| Param | Type | Description |
+|-------|--------|-------------|
+| `id` | string | Thread UUID |
+
+**Request Body**
+
+```json
+{
+ "body": "
Thanks for the update!
",
+ "bodyType": "html",
+ "to": [{ "name": "Alice", "email": "alice@example.com" }],
+ "cc": [{ "name": "Bob", "email": "bob@example.com" }],
+ "bcc": [],
+ "attachments": [
+ {
+ "filename": "report.pdf",
+ "mimeType": "application/pdf",
+ "size": 204800,
+ "content": ""
+ }
+ ]
+}
+```
+
+**Response `201 Created`**
+
+```json
+{
+ "data": {
+ "message": { /* EmailMessage */ },
+ "thread": { /* EmailThread (updated) */ }
+ }
+}
+```
+
+**Validation Errors**
+
+| Status | Body |
+|--------|------|
+| 400 | `{ "error": "VALIDATION_ERROR", "message": "body is required", "details": [...] }` |
+| 404 | `{ "error": "NOT_FOUND", "message": "Thread not found" }` |
+
+---
+
+### `POST /threads` — Create a new thread
+
+Sends the first message in a new thread.
+
+**Request Body**
+
+```json
+{
+ "subject": "Q4 Planning Meeting",
+ "body": "Let's schedule the Q4 planning session.
",
+ "bodyType": "html",
+ "to": [
+ { "name": "Alice", "email": "alice@example.com" },
+ { "name": "Bob", "email": "bob@example.com" }
+ ],
+ "cc": [{ "name": "Carol", "email": "carol@example.com" }],
+ "bcc": [],
+ "attachments": [
+ {
+ "filename": "agenda.md",
+ "mimeType": "text/markdown",
+ "size": 4096,
+ "content": ""
+ }
+ ]
+}
+```
+
+**Response `201 Created`**
+
+```json
+{
+ "data": {
+ "thread": { /* EmailThread */ },
+ "message": { /* EmailMessage */ }
+ }
+}
+```
+
+**Validation Errors**
+
+| Status | Body |
+|--------|------|
+| 400 | `{ "error": "VALIDATION_ERROR", "message": "subject is required", "details": [...] }` |
+
+---
+
+## Common Error Schema
+
+All errors follow a uniform shape:
+
+```json
+{
+ "error": "ERROR_CODE",
+ "message": "Human-readable description",
+ "details": [
+ { "field": "subject", "code": "REQUIRED", "message": "subject is required" }
+ ]
+}
+```
+
+| HTTP Status | Error Code | Typical Use Case |
+|-------------|---------------------|---------------------------------|
+| 400 | VALIDATION_ERROR | Malformed request body |
+| 404 | NOT_FOUND | Thread or message doesn't exist |
+| 409 | CONFLICT | Duplicate thread (Message-ID) |
+| 500 | INTERNAL_ERROR | Unexpected server failure |
+
+## Thread Collation Strategy
+
+warpSpeed groups messages into threads by:
+
+1. **Message-ID / References / In-Reply-To headers** — Standard RFC 5322 threading.
+2. **Subject normalization** — Strip `Re:`, `Fwd:`, `[prefix]` and match normalized subjects.
+3. **Sender grouping** — Messages from the same sender within a time window on the same subject.
+
+The API always returns the thread-level view; clients never see raw ungrouped messages.
+
+## Security Considerations
+
+- All endpoints require `Authorization: Bearer `.
+- User isolation: every query scopes to `userId` extracted from the JWT.
+- Attachment uploads validate MIME type against an allow-list.
+- Body size limits: 1 MB per message body, 10 MB per attachment.
+
+## Rate Limiting
+
+| Limit | Window |
+|------------|------------|
+| 100 req/s | per user |
+
+Responds with `429 Too Many Requests` and a `Retry-After` header when exceeded.
+
+## Pagination Cursor Alternative
+
+For high-throughput clients, a cursor-based pagination variant is available via the `cursor` and `take` query params (instead of `page`/`limit`). The response includes `pagination.cursor` and `pagination.hasMore`.
diff --git a/bounty-4/README.md b/bounty-4/README.md
new file mode 100644
index 00000000..085f6517
--- /dev/null
+++ b/bounty-4/README.md
@@ -0,0 +1,113 @@
+# Email Threads API — Implementation Guide
+
+## Stack
+
+| Layer | Technology |
+|------------|------------------------------------|
+| Runtime | Node.js 20+ |
+| Framework | Express 4.x |
+| ORM | Prisma 5.x |
+| Database | PostgreSQL 15+ |
+| Auth | JWT (Bearer token via middleware) |
+| Storage | S3-compatible (for attachments) |
+
+## Project Structure
+
+```
+src/
+ routes/
+ threads.ts ← mount the exported router from api.ts
+ middleware/
+ auth.ts ← JWT verification, sets req.user
+ services/
+ storage.ts ← attachment upload / signed-URL generation
+ threading.ts ← thread collation logic (Message-ID / References)
+ types/
+ index.ts ← re-export from types.ts
+prisma/
+ schema.prisma
+```
+
+## Getting Started
+
+```bash
+# 1. Install dependencies
+npm install express @prisma/client uuid
+npm install -D typescript @types/express @types/node ts-node
+
+# 2. Set up database
+createdb warpspeed_email
+cp .env.example .env # fill in DATABASE_URL
+
+# 3. Run migrations
+npx prisma migrate dev --name init
+
+# 4. Generate Prisma client
+npx prisma generate
+
+# 5. Start dev server
+ts-node src/index.ts
+```
+
+## Environment Variables
+
+| Variable | Required | Default |
+|-----------------|----------|---------------------------|
+| `DATABASE_URL` | yes | — |
+| `JWT_SECRET` | yes | — |
+| `PORT` | no | `3000` |
+| `STORAGE_BUCKET`| yes | — |
+
+## Mounting the Router
+
+```ts
+// src/index.ts
+import express from 'express'
+import threadsRouter from './routes/threads'
+
+const app = express()
+app.use(express.json({ limit: '10mb' }))
+app.use('/api/v1/threads', threadsRouter)
+app.listen(3000)
+```
+
+## Testing with curl
+
+```bash
+# List threads
+curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/v1/threads
+
+# List threads with filters
+curl -H "Authorization: Bearer $TOKEN" \
+ "http://localhost:3000/api/v1/threads?category=inbox&page=1&limit=10"
+
+# Open a thread
+curl -H "Authorization: Bearer $TOKEN" \
+ http://localhost:3000/api/v1/threads/
+
+# Reply in a thread
+curl -X POST -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"body":"Got it, thanks!
","bodyType":"html","to":[{"name":"Alice","email":"alice@example.com"}]}' \
+ http://localhost:3000/api/v1/threads//reply
+
+# Create a new thread
+curl -X POST -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"subject":"Hello","body":"World
","bodyType":"html","to":[{"name":"Alice","email":"alice@example.com"}]}' \
+ http://localhost:3000/api/v1/threads
+```
+
+## Migration Workflow
+
+```bash
+npx prisma migrate dev --name add_label_index # create migration
+npx prisma migrate deploy # apply in production
+```
+
+## Key Design Decisions
+
+1. **Participants as a separate table** — Enables efficient search by participant email and avoids JSON bloat for indexed queries.
+2. **`to`/`cc`/`bcc` as JSON on Message** — These are write-once, read-often arrays; normalizing them into join tables adds complexity for marginal gain.
+3. **Thread collation at write time** — When a message arrives, the system checks for an existing thread by Message-ID or normalized subject and groups accordingly. The API surface is always thread-first.
+4. **`snippet` denormalized on Thread** — Avoids a full-text scan of all messages for the list view.
diff --git a/bounty-4/api.ts b/bounty-4/api.ts
new file mode 100644
index 00000000..ac4a4c5a
--- /dev/null
+++ b/bounty-4/api.ts
@@ -0,0 +1,405 @@
+import { Router, Request, Response } from 'express'
+import { PrismaClient } from '@prisma/client'
+import { v4 as uuid } from 'uuid'
+import {
+ EmailThread,
+ EmailMessage,
+ ThreadParticipant,
+ PaginatedResponse,
+ ThreadDetailResponse,
+ SentMessageResponse,
+ ListThreadsParams,
+ ReplyRequest,
+ CreateThreadRequest,
+ ApiError,
+} from './types'
+
+const prisma = new PrismaClient()
+const router = Router()
+
+// ──────────────────────────────────────────────
+// Helpers
+// ──────────────────────────────────────────────
+
+function currentUserId(req: Request): string {
+ return req.user!.id
+}
+
+function apiError(status: number, error: string, message: string, details?: ApiError['details']): { status: number; body: ApiError } {
+ return { status, body: { error, message, details } }
+}
+
+function paginated(
+ data: T[],
+ page: number,
+ limit: number,
+ totalItems: number,
+): PaginatedResponse {
+ const totalPages = Math.ceil(totalItems / limit)
+ return {
+ data,
+ pagination: {
+ page,
+ limit,
+ totalItems,
+ totalPages,
+ hasNext: page < totalPages,
+ hasPrev: page > 1,
+ },
+ }
+}
+
+function mapPrismaThread(thread: any): EmailThread {
+ return {
+ id: thread.id,
+ subject: thread.subject,
+ participants: thread.participants.map((p: any) => ({
+ name: p.name,
+ email: p.email,
+ role: p.role as ThreadParticipant['role'],
+ })),
+ lastMessageAt: thread.lastMessageAt.toISOString(),
+ createdAt: thread.createdAt.toISOString(),
+ messageCount: thread._count?.messages ?? thread.messages?.length ?? 0,
+ snippet: thread.snippet,
+ category: thread.category,
+ isStarred: thread.isStarred,
+ isRead: thread.isRead,
+ labels: thread.labels,
+ }
+}
+
+function mapPrismaMessage(msg: any): EmailMessage {
+ return {
+ id: msg.id,
+ threadId: msg.threadId,
+ from: { name: msg.fromName, email: msg.fromEmail, role: 'from' },
+ to: msg.to as ThreadParticipant[],
+ cc: (msg.cc ?? []) as ThreadParticipant[],
+ bcc: (msg.bcc ?? []) as ThreadParticipant[],
+ subject: msg.subject,
+ body: msg.body,
+ bodyType: msg.bodyType,
+ sentAt: msg.sentAt.toISOString(),
+ receivedAt: msg.receivedAt.toISOString(),
+ attachments: (msg.attachments ?? []).map((a: any) => ({
+ id: a.id,
+ filename: a.filename,
+ mimeType: a.mimeType,
+ size: a.size,
+ url: a.url,
+ })),
+ }
+}
+
+// ──────────────────────────────────────────────
+// GET /threads — List threads
+// ──────────────────────────────────────────────
+
+router.get('/', async (req: Request, res: Response) => {
+ try {
+ const {
+ page = '1',
+ limit = '20',
+ sort = 'lastMessageAt',
+ order = 'desc',
+ q,
+ category,
+ read,
+ starred,
+ from,
+ label,
+ before,
+ after,
+ } = req.query as Record
+
+ const pageNum = Math.max(1, parseInt(page ?? '1', 10) || 1)
+ const limitNum = Math.min(100, Math.max(1, parseInt(limit ?? '20', 10) || 20))
+ const orderDir = order === 'asc' ? 'asc' : 'desc'
+ const userId = currentUserId(req)
+
+ const allowedSorts = ['lastMessageAt', 'createdAt', 'subject'] as const
+ const sortField = allowedSorts.includes(sort as any) ? sort : 'lastMessageAt'
+
+ const where: any = { userId }
+
+ if (category) where.category = category
+ if (read !== undefined) where.isRead = read === 'true'
+ if (starred !== undefined) where.isStarred = starred === 'true'
+ if (from) where.participants = { some: { email: from } }
+ if (label) where.labels = { has: label }
+ if (before) where.lastMessageAt = { ...(where.lastMessageAt ?? {}), lt: new Date(before) }
+ if (after) where.lastMessageAt = { ...(where.lastMessageAt ?? {}), gt: new Date(after) }
+ if (q) {
+ where.OR = [
+ { subject: { contains: q, mode: 'insensitive' } },
+ { snippet: { contains: q, mode: 'insensitive' } },
+ { messages: { some: { body: { contains: q, mode: 'insensitive' } } } },
+ ]
+ }
+
+ const [threads, totalItems] = await Promise.all([
+ prisma.thread.findMany({
+ where,
+ orderBy: { [sortField]: orderDir },
+ skip: (pageNum - 1) * limitNum,
+ take: limitNum,
+ include: {
+ participants: true,
+ _count: { select: { messages: true } },
+ },
+ }),
+ prisma.thread.count({ where }),
+ ])
+
+ const data = threads.map(mapPrismaThread)
+ res.json(paginated(data, pageNum, limitNum, totalItems))
+ } catch (err) {
+ console.error('GET /threads error:', err)
+ res.status(500).json({ error: 'INTERNAL_ERROR', message: 'Failed to list threads' })
+ }
+})
+
+// ──────────────────────────────────────────────
+// GET /threads/:id — Open a thread
+// ──────────────────────────────────────────────
+
+router.get('/:id', async (req: Request, res: Response) => {
+ try {
+ const { id } = req.params
+ const userId = currentUserId(req)
+
+ const thread = await prisma.thread.findFirst({
+ where: { id, userId },
+ include: {
+ participants: true,
+ messages: {
+ orderBy: { receivedAt: 'asc' },
+ include: { attachments: true },
+ },
+ _count: { select: { messages: true } },
+ },
+ })
+
+ if (!thread) {
+ const { status, body } = apiError(404, 'NOT_FOUND', 'Thread not found')
+ return res.status(status).json(body)
+ }
+
+ const response: ThreadDetailResponse = {
+ data: {
+ thread: mapPrismaThread(thread),
+ messages: thread.messages.map(mapPrismaMessage),
+ },
+ }
+
+ res.json(response)
+ } catch (err) {
+ console.error('GET /threads/:id error:', err)
+ res.status(500).json({ error: 'INTERNAL_ERROR', message: 'Failed to fetch thread' })
+ }
+})
+
+// ──────────────────────────────────────────────
+// POST /threads/:id/reply — Reply in thread
+// ──────────────────────────────────────────────
+
+router.post('/:id/reply', async (req: Request, res: Response) => {
+ try {
+ const { id } = req.params
+ const userId = currentUserId(req)
+ const payload = req.body as ReplyRequest
+
+ const thread = await prisma.thread.findFirst({
+ where: { id, userId },
+ })
+
+ if (!thread) {
+ const { status, body } = apiError(404, 'NOT_FOUND', 'Thread not found')
+ return res.status(status).json(body)
+ }
+
+ if (!payload.body) {
+ const { status, body } = apiError(400, 'VALIDATION_ERROR', 'body is required', [
+ { field: 'body', code: 'REQUIRED', message: 'body is required' },
+ ])
+ return res.status(status).json(body)
+ }
+
+ const now = new Date()
+ const messageId = uuid()
+
+ const savedAttachments = payload.attachments
+ ? await Promise.all(
+ payload.attachments.map((a) =>
+ prisma.attachment.create({
+ data: {
+ id: uuid(),
+ messageId,
+ filename: a.filename,
+ mimeType: a.mimeType,
+ size: a.size,
+ url: `https://storage.warpspeed.app/attachments/${uuid()}/${a.filename}`,
+ },
+ }),
+ ),
+ )
+ : []
+
+ const message = await prisma.message.create({
+ data: {
+ id: messageId,
+ threadId: thread.id,
+ fromEmail: req.user!.email,
+ fromName: req.user!.name,
+ to: payload.to,
+ cc: payload.cc ?? [],
+ bcc: payload.bcc ?? [],
+ subject: thread.subject,
+ body: payload.body,
+ bodyType: payload.bodyType ?? 'plain',
+ sentAt: now,
+ receivedAt: now,
+ attachments: { connect: savedAttachments.map((a) => ({ id: a.id })) },
+ },
+ include: { attachments: true },
+ })
+
+ const updatedThread = await prisma.thread.update({
+ where: { id: thread.id },
+ data: {
+ lastMessageAt: now,
+ snippet: payload.body.slice(0, 200),
+ isRead: true,
+ },
+ include: {
+ participants: true,
+ _count: { select: { messages: true } },
+ },
+ })
+
+ const response: SentMessageResponse = {
+ data: {
+ message: mapPrismaMessage(message),
+ thread: mapPrismaThread(updatedThread),
+ },
+ }
+
+ res.status(201).json(response)
+ } catch (err) {
+ console.error('POST /threads/:id/reply error:', err)
+ res.status(500).json({ error: 'INTERNAL_ERROR', message: 'Failed to send reply' })
+ }
+})
+
+// ──────────────────────────────────────────────
+// POST /threads — Create a new thread
+// ──────────────────────────────────────────────
+
+router.post('/', async (req: Request, res: Response) => {
+ try {
+ const userId = currentUserId(req)
+ const payload = req.body as CreateThreadRequest
+
+ if (!payload.subject) {
+ const { status, body } = apiError(400, 'VALIDATION_ERROR', 'subject is required', [
+ { field: 'subject', code: 'REQUIRED', message: 'subject is required' },
+ ])
+ return res.status(status).json(body)
+ }
+
+ if (!payload.body) {
+ const { status, body } = apiError(400, 'VALIDATION_ERROR', 'body is required', [
+ { field: 'body', code: 'REQUIRED', message: 'body is required' },
+ ])
+ return res.status(status).json(body)
+ }
+
+ const now = new Date()
+ const threadId = uuid()
+ const messageId = uuid()
+
+ const allParticipants = [
+ { name: req.user!.name, email: req.user!.email, role: 'from' },
+ ...(payload.to ?? []).map((p) => ({ ...p, role: 'to' as const })),
+ ...(payload.cc ?? []).map((p) => ({ ...p, role: 'cc' as const })),
+ ...(payload.bcc ?? []).map((p) => ({ ...p, role: 'bcc' as const })),
+ ]
+
+ const savedAttachments = payload.attachments
+ ? await Promise.all(
+ payload.attachments.map((a) =>
+ prisma.attachment.create({
+ data: {
+ id: uuid(),
+ messageId,
+ filename: a.filename,
+ mimeType: a.mimeType,
+ size: a.size,
+ url: `https://storage.warpspeed.app/attachments/${uuid()}/${a.filename}`,
+ },
+ }),
+ ),
+ )
+ : []
+
+ const thread = await prisma.thread.create({
+ data: {
+ id: threadId,
+ userId,
+ subject: payload.subject,
+ snippet: payload.body.slice(0, 200),
+ category: 'sent',
+ isRead: true,
+ isStarred: false,
+ labels: [],
+ lastMessageAt: now,
+ participants: {
+ create: allParticipants.map((p) => ({
+ id: uuid(),
+ name: p.name,
+ email: p.email,
+ role: p.role,
+ })),
+ },
+ messages: {
+ create: {
+ id: messageId,
+ fromEmail: req.user!.email,
+ fromName: req.user!.name,
+ to: payload.to,
+ cc: payload.cc ?? [],
+ bcc: payload.bcc ?? [],
+ subject: payload.subject,
+ body: payload.body,
+ bodyType: payload.bodyType ?? 'plain',
+ sentAt: now,
+ receivedAt: now,
+ attachments: savedAttachments.length > 0
+ ? { connect: savedAttachments.map((a) => ({ id: a.id })) }
+ : undefined,
+ },
+ },
+ },
+ include: {
+ participants: true,
+ messages: { include: { attachments: true } },
+ _count: { select: { messages: true } },
+ },
+ })
+
+ const response: SentMessageResponse = {
+ data: {
+ thread: mapPrismaThread(thread),
+ message: mapPrismaMessage(thread.messages[0]),
+ },
+ }
+
+ res.status(201).json(response)
+ } catch (err) {
+ console.error('POST /threads error:', err)
+ res.status(500).json({ error: 'INTERNAL_ERROR', message: 'Failed to create thread' })
+ }
+})
+
+export default router
diff --git a/bounty-4/schema.prisma b/bounty-4/schema.prisma
new file mode 100644
index 00000000..d16be445
--- /dev/null
+++ b/bounty-4/schema.prisma
@@ -0,0 +1,109 @@
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "postgresql"
+ url = env("DATABASE_URL")
+}
+
+// ──────────────────────────────────────────────
+// Thread
+// ──────────────────────────────────────────────
+
+model Thread {
+ id String @id @default(uuid())
+ userId String
+ subject String
+ snippet String @default("")
+ category ThreadCategory @default(inbox)
+ isStarred Boolean @default(false)
+ isRead Boolean @default(false)
+ labels String[]
+ lastMessageAt DateTime
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ participants Participant[]
+ messages Message[]
+
+ @@index([userId, lastMessageAt(sort: Desc)])
+ @@index([userId, category])
+ @@index([userId, isRead])
+ @@index([userId, subject])
+}
+
+enum ThreadCategory {
+ inbox
+ sent
+ drafts
+ archived
+ spam
+ trash
+}
+
+// ──────────────────────────────────────────────
+// Participant
+// ──────────────────────────────────────────────
+
+model Participant {
+ id String @id @default(uuid())
+ threadId String
+ name String
+ email String
+ role ParticipantRole
+
+ thread Thread @relation(fields: [threadId], references: [id], onDelete: Cascade)
+
+ @@index([threadId])
+ @@index([email])
+}
+
+enum ParticipantRole {
+ from
+ to
+ cc
+ bcc
+}
+
+// ──────────────────────────────────────────────
+// Message
+// ──────────────────────────────────────────────
+
+model Message {
+ id String @id @default(uuid())
+ threadId String
+ fromEmail String
+ fromName String
+ to Json
+ cc Json @default("[]")
+ bcc Json @default("[]")
+ subject String
+ body String
+ bodyType String @default("plain")
+ sentAt DateTime
+ receivedAt DateTime
+
+ thread Thread @relation(fields: [threadId], references: [id], onDelete: Cascade)
+ attachments Attachment[]
+
+ @@index([threadId, receivedAt])
+ @@index([fromEmail])
+}
+
+// ──────────────────────────────────────────────
+// Attachment
+// ──────────────────────────────────────────────
+
+model Attachment {
+ id String @id @default(uuid())
+ messageId String
+ filename String
+ mimeType String
+ size Int
+ url String
+
+ message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
+
+ @@index([messageId])
+}
diff --git a/bounty-4/types.ts b/bounty-4/types.ts
new file mode 100644
index 00000000..8664379a
--- /dev/null
+++ b/bounty-4/types.ts
@@ -0,0 +1,138 @@
+// ──────────────────────────────────────────────
+// Core domain models
+// ──────────────────────────────────────────────
+
+export interface ThreadParticipant {
+ name: string
+ email: string
+ role: 'from' | 'to' | 'cc' | 'bcc'
+}
+
+export interface Attachment {
+ id: string
+ filename: string
+ mimeType: string
+ size: number
+ url: string
+}
+
+export type BodyType = 'plain' | 'html'
+
+export interface EmailMessage {
+ id: string
+ threadId: string
+ from: ThreadParticipant
+ to: ThreadParticipant[]
+ cc: ThreadParticipant[]
+ bcc: ThreadParticipant[]
+ subject: string
+ body: string
+ bodyType: BodyType
+ sentAt: string
+ receivedAt: string
+ attachments: Attachment[]
+}
+
+export type ThreadCategory = 'inbox' | 'sent' | 'drafts' | 'archived' | 'spam' | 'trash'
+
+export interface EmailThread {
+ id: string
+ subject: string
+ participants: ThreadParticipant[]
+ lastMessageAt: string
+ createdAt: string
+ messageCount: number
+ snippet: string
+ category: ThreadCategory
+ isStarred: boolean
+ isRead: boolean
+ labels: string[]
+}
+
+// ──────────────────────────────────────────────
+// API request / response types
+// ──────────────────────────────────────────────
+
+export interface PaginationParams {
+ page?: number
+ limit?: number
+ sort?: 'lastMessageAt' | 'createdAt' | 'subject'
+ order?: 'asc' | 'desc'
+}
+
+export interface ThreadFilterParams {
+ q?: string
+ category?: ThreadCategory
+ read?: boolean
+ starred?: boolean
+ from?: string
+ label?: string
+ before?: string
+ after?: string
+}
+
+export type ListThreadsParams = PaginationParams & ThreadFilterParams
+
+export interface PaginatedResponse {
+ data: T[]
+ pagination: {
+ page: number
+ limit: number
+ totalItems: number
+ totalPages: number
+ hasNext: boolean
+ hasPrev: boolean
+ }
+}
+
+export interface ThreadDetailResponse {
+ data: {
+ thread: EmailThread
+ messages: EmailMessage[]
+ }
+}
+
+export interface ReplyRequest {
+ body: string
+ bodyType: BodyType
+ to: ThreadParticipant[]
+ cc?: ThreadParticipant[]
+ bcc?: ThreadParticipant[]
+ attachments?: AttachmentPayload[]
+}
+
+export interface CreateThreadRequest {
+ subject: string
+ body: string
+ bodyType: BodyType
+ to: ThreadParticipant[]
+ cc?: ThreadParticipant[]
+ bcc?: ThreadParticipant[]
+ attachments?: AttachmentPayload[]
+}
+
+export interface SentMessageResponse {
+ data: {
+ message: EmailMessage
+ thread: EmailThread
+ }
+}
+
+export interface AttachmentPayload {
+ filename: string
+ mimeType: string
+ size: number
+ content: string
+}
+
+export interface ApiError {
+ error: string
+ message: string
+ details?: ValidationDetail[]
+}
+
+export interface ValidationDetail {
+ field: string
+ code: string
+ message: string
+}