diff --git a/packages/email-threads-api/.env.example b/packages/email-threads-api/.env.example new file mode 100644 index 00000000..c3056692 --- /dev/null +++ b/packages/email-threads-api/.env.example @@ -0,0 +1,9 @@ +# Email Threads API - Environment Configuration +# Copy this file to .env and fill in the values + +# Database +DATABASE_URL="postgresql://user:password@localhost:5432/email_threads?schema=public" + +# Server +PORT=3000 +NODE_ENV=development diff --git a/packages/email-threads-api/API.md b/packages/email-threads-api/API.md new file mode 100644 index 00000000..b975361e --- /dev/null +++ b/packages/email-threads-api/API.md @@ -0,0 +1,81 @@ +# Email Threads API - Tests + +## Thread List + +### GET /api/threads +Returns paginated list of email threads. + +Query Parameters: +- `page` (number, default: 1) - Page number +- `limit` (number, default: 20, max: 100) - Items per page +- `category` (string: INBOX|SENT|DRAFT|SPAM|TRASH|ARCHIVE) - Filter by category +- `priority` (string: LOW|NORMAL|HIGH|URGENT) - Filter by priority +- `isRead` (boolean) - Filter by read status +- `isStarred` (boolean) - Filter by starred status +- `search` (string) - Search in subject, snippet, and message body +- `label` (string) - Filter by label name +- `tag` (string) - Filter by tag name +- `sortBy` (string: createdAt|updatedAt|subject, default: createdAt) +- `sortOrder` (string: asc|desc, default: desc) + +### Response +```json +{ + "threads": [ + { + "id": "clx...", + "subject": "Meeting tomorrow", + "snippet": "Hi, just confirming our meeting...", + "isRead": false, + "isStarred": true, + "category": "INBOX", + "priority": "HIGH", + "createdAt": "2024-01-15T10:30:00Z", + "updatedAt": "2024-01-15T11:00:00Z", + "messageCount": 3, + "lastMessageFrom": "Alice", + "labels": [{ "id": "...", "name": "Work", "color": "#007AFF" }], + "tags": [{ "id": "...", "name": "Important", "color": "#FF3B30" }] + } + ], + "total": 42, + "page": 1, + "limit": 20, + "totalPages": 3 +} +``` + +## Thread Detail + +### GET /api/threads/:id +Returns a single thread with all its messages. + +### Response +```json +{ + "id": "clx...", + "subject": "Meeting tomorrow", + "snippet": "Hi, just confirming our meeting...", + "isRead": false, + "isStarred": true, + "category": "INBOX", + "priority": "HIGH", + "createdAt": "2024-01-15T10:30:00Z", + "updatedAt": "2024-01-15T11:00:00Z", + "messages": [ + { + "id": "...", + "fromName": "Alice", + "fromEmail": "alice@example.com", + "to": "bob@example.com", + "cc": null, + "subject": "Meeting tomorrow", + "body": "Hi, just confirming our meeting...", + "isRead": true, + "createdAt": "2024-01-15T10:30:00Z" + } + ], + "labels": [...], + "tags": [...] +} +``` diff --git a/packages/email-threads-api/package.json b/packages/email-threads-api/package.json new file mode 100644 index 00000000..baf0ebf3 --- /dev/null +++ b/packages/email-threads-api/package.json @@ -0,0 +1,32 @@ +{ + "name": "@warpspeed/email-threads-api", + "version": "0.1.0", + "description": "Email Threads API - list, detail, search, and filter email threads", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsx watch src/index.ts", + "db:generate": "prisma generate", + "db:push": "prisma db push", + "db:migrate": "prisma migrate dev", + "test": "vitest run", + "lint": "eslint src/ --ext .ts" + }, + "dependencies": { + "@prisma/client": "^5.18.0", + "express": "^4.19.2", + "cors": "^2.8.5", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/cors": "^2.8.17", + "@types/node": "^20.14.11", + "prisma": "^5.18.0", + "tsx": "^4.16.2", + "typescript": "^5.5.3", + "vitest": "^2.0.3" + } +} diff --git a/packages/email-threads-api/prisma/schema.prisma b/packages/email-threads-api/prisma/schema.prisma new file mode 100644 index 00000000..39175999 --- /dev/null +++ b/packages/email-threads-api/prisma/schema.prisma @@ -0,0 +1,73 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model EmailThread { + id String @id @default(cuid()) + subject String + snippet String? + isRead Boolean @default(false) + isStarred Boolean @default(false) + category Category @default(INBOX) + priority Priority @default(NORMAL) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + messages EmailMessage[] + labels Label[] + tags Tag[] +} + +model EmailMessage { + id String @id @default(cuid()) + threadId String + thread EmailThread @relation(fields: [threadId], references: [id], onDelete: Cascade) + fromName String + fromEmail String + to String + cc String? + bcc String? + subject String + body String + isRead Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([threadId]) + @@index([fromEmail]) + @@index([createdAt]) +} + +model Label { + id String @id @default(cuid()) + name String @unique + color String @default("#007AFF") + threads EmailThread[] +} + +model Tag { + id String @id @default(cuid()) + name String @unique + color String @default("#8E8E93") + threads EmailThread[] +} + +enum Category { + INBOX + SENT + DRAFT + SPAM + TRASH + ARCHIVE +} + +enum Priority { + LOW + NORMAL + HIGH + URGENT +} diff --git a/packages/email-threads-api/src/index.ts b/packages/email-threads-api/src/index.ts new file mode 100644 index 00000000..51a3802c --- /dev/null +++ b/packages/email-threads-api/src/index.ts @@ -0,0 +1,25 @@ +import express from 'express'; +import cors from 'cors'; +import threadRoutes from './routes/threads.js'; + +const app = express(); +const PORT = parseInt(process.env.PORT ?? '3000', 10); + +// Middleware +app.use(cors()); +app.use(express.json()); + +// Routes +app.use('/api', threadRoutes); + +// Health check +app.get('/health', (_req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +// Start server +app.listen(PORT, () => { + console.log(`Email Threads API running on http://localhost:${PORT}`); +}); + +export default app; diff --git a/packages/email-threads-api/src/lib/prisma.ts b/packages/email-threads-api/src/lib/prisma.ts new file mode 100644 index 00000000..89f754f8 --- /dev/null +++ b/packages/email-threads-api/src/lib/prisma.ts @@ -0,0 +1,13 @@ +import { PrismaClient } from '@prisma/client'; + +const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined }; + +export const prisma = + globalForPrisma.prisma ?? + new PrismaClient({ + log: process.env.NODE_ENV === 'development' ? ['query'] : [], + }); + +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; + +export default prisma; diff --git a/packages/email-threads-api/src/routes/threads.ts b/packages/email-threads-api/src/routes/threads.ts new file mode 100644 index 00000000..1abcbcf0 --- /dev/null +++ b/packages/email-threads-api/src/routes/threads.ts @@ -0,0 +1,57 @@ +import { Router, Request, Response } from 'express'; +import { threadListQuerySchema, threadParamsSchema } from '../schemas.js'; +import { listThreads, getThreadById } from '../services/thread-service.js'; + +const router = Router(); + +/** + * GET /api/threads + * List email threads with optional filtering, search, and pagination. + */ +router.get('/threads', async (req: Request, res: Response) => { + try { + const query = threadListQuerySchema.parse(req.query); + const result = await listThreads(query); + res.json(result); + } catch (error: any) { + if (error?.name === 'ZodError') { + res.status(400).json({ + error: 'Invalid query parameters', + details: error.errors ?? error, + }); + return; + } + console.error('Error listing threads:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /api/threads/:id + * Get a single thread with all its messages. + */ +router.get('/threads/:id', async (req: Request, res: Response) => { + try { + const { id } = threadParamsSchema.parse(req.params); + const thread = await getThreadById(id); + + if (!thread) { + res.status(404).json({ error: 'Thread not found' }); + return; + } + + res.json(thread); + } catch (error: any) { + if (error?.name === 'ZodError') { + res.status(400).json({ + error: 'Invalid thread ID', + details: error.errors ?? error, + }); + return; + } + console.error('Error getting thread:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +export default router; diff --git a/packages/email-threads-api/src/schemas.ts b/packages/email-threads-api/src/schemas.ts new file mode 100644 index 00000000..b6efa6a0 --- /dev/null +++ b/packages/email-threads-api/src/schemas.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +export const CategoryEnum = z.enum(['INBOX', 'SENT', 'DRAFT', 'SPAM', 'TRASH', 'ARCHIVE']); +export const PriorityEnum = z.enum(['LOW', 'NORMAL', 'HIGH', 'URGENT']); + +export const threadListQuerySchema = z.object({ + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), + category: CategoryEnum.optional(), + priority: PriorityEnum.optional(), + isRead: z.coerce.boolean().optional(), + isStarred: z.coerce.boolean().optional(), + search: z.string().optional(), + label: z.string().optional(), + tag: z.string().optional(), + sortBy: z.enum(['createdAt', 'updatedAt', 'subject']).default('createdAt'), + sortOrder: z.enum(['asc', 'desc']).default('desc'), +}); + +export const threadParamsSchema = z.object({ + id: z.string().min(1), +}); + +export const paginationSchema = z.object({ + page: z.number().int().positive(), + limit: z.number().int().positive().max(100), + total: z.number().int().min(0), + totalPages: z.number().int().min(0), +}); + +export type ThreadListQuery = z.infer; +export type Category = z.infer; +export type Priority = z.infer; diff --git a/packages/email-threads-api/src/services/thread-service.ts b/packages/email-threads-api/src/services/thread-service.ts new file mode 100644 index 00000000..33508f41 --- /dev/null +++ b/packages/email-threads-api/src/services/thread-service.ts @@ -0,0 +1,109 @@ +import { Prisma } from '@prisma/client'; +import prisma from '../lib/prisma.js'; +import type { ThreadListQuery } from '../schemas.js'; + +export interface ThreadListResult { + threads: Array<{ + id: string; + subject: string; + snippet: string | null; + isRead: boolean; + isStarred: boolean; + category: string; + priority: string; + createdAt: Date; + updatedAt: Date; + messageCount: number; + lastMessageFrom: string; + labels: Array<{ id: string; name: string; color: string }>; + tags: Array<{ id: string; name: string; color: string }>; + }>; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export async function listThreads(query: ThreadListQuery): Promise { + const where: Prisma.EmailThreadWhereInput = {}; + + if (query.category) { + where.category = query.category; + } + if (query.priority) { + where.priority = query.priority; + } + if (query.isRead !== undefined) { + where.isRead = query.isRead; + } + if (query.isStarred !== undefined) { + where.isStarred = query.isStarred; + } + if (query.search) { + where.OR = [ + { subject: { contains: query.search, mode: 'insensitive' } }, + { snippet: { contains: query.search, mode: 'insensitive' } }, + { messages: { some: { body: { contains: query.search, mode: 'insensitive' } } } }, + ]; + } + if (query.label) { + where.labels = { some: { name: query.label } }; + } + if (query.tag) { + where.tags = { some: { name: query.tag } }; + } + + const [threads, total] = await Promise.all([ + prisma.emailThread.findMany({ + where, + include: { + messages: { + orderBy: { createdAt: 'desc' }, + take: 1, + select: { fromName: true, createdAt: true }, + }, + labels: { select: { id: true, name: true, color: true } }, + tags: { select: { id: true, name: true, color: true } }, + _count: { select: { messages: true } }, + }, + orderBy: { [query.sortBy]: query.sortOrder }, + skip: (query.page - 1) * query.limit, + take: query.limit, + }), + prisma.emailThread.count({ where }), + ]); + + return { + threads: threads.map((t) => ({ + id: t.id, + subject: t.subject, + snippet: t.snippet, + isRead: t.isRead, + isStarred: t.isStarred, + category: t.category, + priority: t.priority, + createdAt: t.createdAt, + updatedAt: t.updatedAt, + messageCount: t._count.messages, + lastMessageFrom: t.messages[0]?.fromName ?? '', + labels: t.labels, + tags: t.tags, + })), + total, + page: query.page, + limit: query.limit, + totalPages: Math.ceil(total / query.limit), + }; +} + +export async function getThreadById(id: string) { + const thread = await prisma.emailThread.findUnique({ + where: { id }, + include: { + messages: { orderBy: { createdAt: 'asc' } }, + labels: { select: { id: true, name: true, color: true } }, + tags: { select: { id: true, name: true, color: true } }, + }, + }); + return thread; +} diff --git a/packages/email-threads-api/tsconfig.json b/packages/email-threads-api/tsconfig.json new file mode 100644 index 00000000..dcf76e55 --- /dev/null +++ b/packages/email-threads-api/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "sourceMap": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}