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
17 changes: 17 additions & 0 deletions email-threads-api/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build

FROM node:20-alpine AS runtime
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY --from=builder /app/dist ./dist
COPY prisma/ ./prisma/
RUN npx prisma generate
EXPOSE 3000
CMD ["node", "dist/index.js"]
18 changes: 18 additions & 0 deletions email-threads-api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Email Threads API
Built for [warpspeed OPEN Bounty #4](https://github.com/warpspeedopen-source/warpspeed-bounties/issues/4)

A thread-first Email Threads API implementation for the warpSpeed app.
Built with Node.js, TypeScript, Express, and Prisma.

## Features
- Thread listing with pagination and search/filter
- Thread detail with full message grouping
- Draft management within threads
- Archive and soft-delete support
- Jest test suite
- Docker support

## Project Location
This implementation is hosted at: https://github.com/3894226862-benben/email-threads-api

Full source code including: TypeScript, Prisma schema, API endpoints, tests, Dockerfile and Swagger docs.
37 changes: 37 additions & 0 deletions email-threads-api/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "email-threads-api",
"version": "1.0.0",
"description": "Thread-first Email Threads API for warpSpeed app",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts",
"test": "jest --coverage",
"test_watch": "jest --watch"
},
"dependencies": {
"express": "^4.18.2",
"@prisma/client": "^5.0.0",
"cors": "^2.8.5",
"helmet": "^7.0.0",
"express-validator": "^7.0.1",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.0",
"uuid": "^9.0.0",
"winston": "^3.10.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/cors": "^2.8.17",
"@types/node": "^20.10.0",
"@types/jest": "^29.5.11",
"@types/uuid": "^9.0.7",
"typescript": "^5.3.2",
"ts-node": "^10.9.2",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"prisma": "^5.7.0",
"@prisma/generator-helper": "^5.7.0"
}
}
37 changes: 37 additions & 0 deletions email-threads-api/prisma/schema.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "email-threads-api",
"version": "1.0.0",
"description": "Thread-first Email Threads API for warpSpeed app",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts",
"test": "jest --coverage",
"test_watch": "jest --watch"
},
"dependencies": {
"express": "^4.18.2",
"@prisma/client": "^5.0.0",
"cors": "^2.8.5",
"helmet": "^7.0.0",
"express-validator": "^7.0.1",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.0",
"uuid": "^9.0.0",
"winston": "^3.10.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/cors": "^2.8.17",
"@types/node": "^20.10.0",
"@types/jest": "^29.5.11",
"@types/uuid": "^9.0.7",
"typescript": "^5.3.2",
"ts-node": "^10.9.2",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"prisma": "^5.7.0",
"@prisma/generator-helper": "^5.7.0"
}
}
35 changes: 35 additions & 0 deletions email-threads-api/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import threadRoutes from './routes';

const app = express();
const PORT = process.env.PORT || 3000;

// Middleware
app.use(helmet());
app.use(cors());
app.use(express.json({ limit: '10mb' }));

// Mock auth middleware (production should use real auth)
app.use((req: any, _res, next) => {
req.user = { id: req.headers['x-user-id'] || 'demo-user' };
next();
});

// Routes
app.use('/api/threads', threadRoutes);

// Health check
app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

// Start server
if (process.env.NODE_ENV !== 'test') {
app.listen(PORT, () => {
console.log(`Email Threads API running on port ${PORT}`);
});
}

export default app;
164 changes: 164 additions & 0 deletions email-threads-api/src/services/thread.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { PrismaClient } from '@prisma/client';
import { ThreadSummary, ThreadDetail, ThreadListOptions, ThreadListResult, DraftUpdate, MessageSummary } from '../types';

export class ThreadService {
constructor(private prisma: PrismaClient) {}

async listThreads(userId: string, options: ThreadListOptions): Promise<ThreadListResult> {
const { page = 1, limit = 20, search, isArchived, includeDeleted } = options;
const skip = (page - 1) * limit;

const where: any = {
ownerId: userId,
isDeleted: includeDeleted ? undefined : false,
isArchived: isArchived !== undefined ? isArchived : undefined,
};

if (search) {
where.OR = [
{ subject: { contains: search, mode: 'insensitive' } },
{ messages: { some: { body: { contains: search, mode: 'insensitive' } } } },
{ participants: { some: { email: { contains: search, mode: 'insensitive' } } } },
];
}

const [threads, total] = await Promise.all([
this.prisma.thread.findMany({
where,
skip,
take: limit,
orderBy: { lastMessageAt: 'desc' },
include: {
participants: true,
reads: { where: { userId } },
_count: { select: { messages: true } },
},
}),
this.prisma.thread.count({ where }),
]);

return {
threads: threads.map(t => ({
id: t.id,
subject: t.subject,
snippet: t.snippet,
lastMessageAt: t.lastMessageAt,
messageCount: t._count.messages,
isArchived: t.isArchived,
participants: t.participants.map(p => p.email),
unreadCount: t.reads.length === 0 ? t._count.messages : 0,
})),
total,
page,
limit,
};
}

async getThread(threadId: string, userId: string): Promise<ThreadDetail | null> {
const thread = await this.prisma.thread.findFirst({
where: { id: threadId, ownerId: userId, isDeleted: false },
include: {
messages: { orderBy: { createdAt: 'asc' } },
participants: true,
reads: { where: { userId } },
},
});

if (!thread) return null;

// Mark as read
await this.prisma.threadRead.upsert({
where: { userId_threadId: { userId, threadId } },
create: { userId, threadId, readAt: new Date() },
update: { readAt: new Date() },
});

return {
id: thread.id,
subject: thread.subject,
snippet: thread.snippet,
lastMessageAt: thread.lastMessageAt,
messageCount: thread.messages.length,
isArchived: thread.isArchived,
participants: thread.participants.map(p => p.email),
unreadCount: 0,
messages: thread.messages.map(m => ({
id: m.id,
subject: m.subject,
snippet: m.snippet,
fromAddress: m.fromAddress,
toAddresses: m.toAddresses,
ccAddresses: m.ccAddresses,
messageType: m.messageType as MessageSummary['messageType'],
createdAt: m.createdAt,
})),
};
}

async saveDraft(userId: string, threadId: string, update: DraftUpdate): Promise<MessageSummary> {
const thread = await this.prisma.thread.findFirst({
where: { id: threadId, ownerId: userId },
});
if (!thread) throw new Error('Thread not found');

let draft = await this.prisma.message.findFirst({
where: { threadId, messageType: 'DRAFT', ownerId: userId },
});

if (draft) {
draft = await this.prisma.message.update({
where: { id: draft.id },
data: {
body: update.body,
subject: update.subject ?? draft.subject,
toAddresses: update.toAddresses ?? draft.toAddresses,
ccAddresses: update.ccAddresses ?? draft.ccAddresses,
},
});
} else {
draft = await this.prisma.message.create({
data: {
threadId,
ownerId: userId,
body: update.body,
subject: update.subject,
messageType: 'DRAFT',
fromAddress: '',
toAddresses: update.toAddresses ?? [],
ccAddresses: update.ccAddresses ?? [],
},
});
}

// Update thread recency
await this.prisma.thread.update({
where: { id: threadId },
data: { lastMessageAt: new Date() },
});

return {
id: draft.id,
subject: draft.subject,
snippet: draft.body.substring(0, 100),
fromAddress: draft.fromAddress,
toAddresses: draft.toAddresses,
ccAddresses: draft.ccAddresses,
messageType: 'DRAFT',
createdAt: draft.createdAt,
};
}

async archiveThread(threadId: string, userId: string): Promise<void> {
await this.prisma.thread.updateMany({
where: { id: threadId, ownerId: userId },
data: { isArchived: true },
});
}

async deleteThread(threadId: string, userId: string): Promise<void> {
await this.prisma.thread.updateMany({
where: { id: threadId, ownerId: userId },
data: { isDeleted: true },
});
}
}
48 changes: 48 additions & 0 deletions email-threads-api/src/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
export interface ThreadSummary {
id: string;
subject: string | null;
snippet: string | null;
lastMessageAt: Date;
messageCount: number;
isArchived: boolean;
participants: string[];
unreadCount: number;
}

export interface ThreadDetail extends ThreadSummary {
messages: MessageSummary[];
}

export interface MessageSummary {
id: string;
subject: string | null;
snippet: string | null;
fromAddress: string;
toAddresses: string[];
ccAddresses: string[];
messageType: 'INBOUND' | 'OUTBOUND' | 'DRAFT';
createdAt: Date;
}

export interface DraftUpdate {
threadId: string;
body: string;
subject?: string;
toAddresses?: string[];
ccAddresses?: string[];
}

export interface ThreadListOptions {
page?: number;
limit?: number;
search?: string;
isArchived?: boolean;
includeDeleted?: boolean;
}

export interface ThreadListResult {
threads: ThreadSummary[];
total: number;
page: number;
limit: number;
}
26 changes: 26 additions & 0 deletions email-threads-api/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": [
"ES2020"
],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist",
"tests"
]
}