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 +}