From 4168ad115c359aa32e2986101dfa8139d815fb40 Mon Sep 17 00:00:00 2001 From: 3894226862-benben <3894226862@qq.com> Date: Mon, 29 Jun 2026 13:50:41 +0800 Subject: [PATCH 1/8] feat(email-threads-api): submit Email Threads API implementation --- email-threads-api/README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 email-threads-api/README.md diff --git a/email-threads-api/README.md b/email-threads-api/README.md new file mode 100644 index 00000000..1b980677 --- /dev/null +++ b/email-threads-api/README.md @@ -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. \ No newline at end of file From 1cdf42d46f32a4d5b4eeabdb87907c8ee079fefc Mon Sep 17 00:00:00 2001 From: 3894226862-benben <3894226862@qq.com> Date: Mon, 29 Jun 2026 13:51:40 +0800 Subject: [PATCH 2/8] add package.json --- email-threads-api/package.json | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 email-threads-api/package.json diff --git a/email-threads-api/package.json b/email-threads-api/package.json new file mode 100644 index 00000000..eb6453d4 --- /dev/null +++ b/email-threads-api/package.json @@ -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" + } +} \ No newline at end of file From 67c90c047412b247c70365ea34e5fe5005cfb576 Mon Sep 17 00:00:00 2001 From: 3894226862-benben <3894226862@qq.com> Date: Mon, 29 Jun 2026 13:51:41 +0800 Subject: [PATCH 3/8] add prisma schema --- email-threads-api/prisma/schema.prisma | 37 ++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 email-threads-api/prisma/schema.prisma diff --git a/email-threads-api/prisma/schema.prisma b/email-threads-api/prisma/schema.prisma new file mode 100644 index 00000000..eb6453d4 --- /dev/null +++ b/email-threads-api/prisma/schema.prisma @@ -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" + } +} \ No newline at end of file From 47a88775b090f39d7e6a0a13d290c9b0674ac76d Mon Sep 17 00:00:00 2001 From: 3894226862-benben <3894226862@qq.com> Date: Mon, 29 Jun 2026 13:53:33 +0800 Subject: [PATCH 4/8] add src\index.ts --- email-threads-api/src/index.ts | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 email-threads-api/src/index.ts diff --git a/email-threads-api/src/index.ts b/email-threads-api/src/index.ts new file mode 100644 index 00000000..1a2d21a8 --- /dev/null +++ b/email-threads-api/src/index.ts @@ -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; From a0334ef4588956c0fb1aa1655a5872f74531d37d Mon Sep 17 00:00:00 2001 From: 3894226862-benben <3894226862@qq.com> Date: Mon, 29 Jun 2026 13:53:35 +0800 Subject: [PATCH 5/8] add src\services\thread.service.ts --- .../src/services/thread.service.ts | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 email-threads-api/src/services/thread.service.ts diff --git a/email-threads-api/src/services/thread.service.ts b/email-threads-api/src/services/thread.service.ts new file mode 100644 index 00000000..e5c56767 --- /dev/null +++ b/email-threads-api/src/services/thread.service.ts @@ -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 { + 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 { + 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 { + 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 { + await this.prisma.thread.updateMany({ + where: { id: threadId, ownerId: userId }, + data: { isArchived: true }, + }); + } + + async deleteThread(threadId: string, userId: string): Promise { + await this.prisma.thread.updateMany({ + where: { id: threadId, ownerId: userId }, + data: { isDeleted: true }, + }); + } +} From a0e87972cd387e17f92a383566ca609bfa4ec2dc Mon Sep 17 00:00:00 2001 From: 3894226862-benben <3894226862@qq.com> Date: Mon, 29 Jun 2026 13:53:36 +0800 Subject: [PATCH 6/8] add src\types\index.ts --- email-threads-api/src/types/index.ts | 48 ++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 email-threads-api/src/types/index.ts diff --git a/email-threads-api/src/types/index.ts b/email-threads-api/src/types/index.ts new file mode 100644 index 00000000..3a762295 --- /dev/null +++ b/email-threads-api/src/types/index.ts @@ -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; +} From 86f3d7dd31caf46a4a0e0fff5ebcfd21e9362a4a Mon Sep 17 00:00:00 2001 From: 3894226862-benben <3894226862@qq.com> Date: Mon, 29 Jun 2026 13:53:37 +0800 Subject: [PATCH 7/8] add Dockerfile --- email-threads-api/Dockerfile | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 email-threads-api/Dockerfile diff --git a/email-threads-api/Dockerfile b/email-threads-api/Dockerfile new file mode 100644 index 00000000..aed36d36 --- /dev/null +++ b/email-threads-api/Dockerfile @@ -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"] From 161ca434b81b1278fca916fa25a388a5cc51dc45 Mon Sep 17 00:00:00 2001 From: 3894226862-benben <3894226862@qq.com> Date: Mon, 29 Jun 2026 13:53:38 +0800 Subject: [PATCH 8/8] add tsconfig.json --- email-threads-api/tsconfig.json | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 email-threads-api/tsconfig.json diff --git a/email-threads-api/tsconfig.json b/email-threads-api/tsconfig.json new file mode 100644 index 00000000..32a9ed87 --- /dev/null +++ b/email-threads-api/tsconfig.json @@ -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" + ] +} \ No newline at end of file