Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/email-threads-api/.env.example
Original file line number Diff line number Diff line change
@@ -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
81 changes: 81 additions & 0 deletions packages/email-threads-api/API.md
Original file line number Diff line number Diff line change
@@ -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": [...]
}
```
32 changes: 32 additions & 0 deletions packages/email-threads-api/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
73 changes: 73 additions & 0 deletions packages/email-threads-api/prisma/schema.prisma
Original file line number Diff line number Diff line change
@@ -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
}
25 changes: 25 additions & 0 deletions packages/email-threads-api/src/index.ts
Original file line number Diff line number Diff line change
@@ -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;
13 changes: 13 additions & 0 deletions packages/email-threads-api/src/lib/prisma.ts
Original file line number Diff line number Diff line change
@@ -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;
57 changes: 57 additions & 0 deletions packages/email-threads-api/src/routes/threads.ts
Original file line number Diff line number Diff line change
@@ -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;
33 changes: 33 additions & 0 deletions packages/email-threads-api/src/schemas.ts
Original file line number Diff line number Diff line change
@@ -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<typeof threadListQuerySchema>;
export type Category = z.infer<typeof CategoryEnum>;
export type Priority = z.infer<typeof PriorityEnum>;
Loading