diff --git a/README.md b/README.md index c34c497..bf54943 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,703 @@ -# CRM Server - Autentifikačný Systém +# CRM Server API -Backend API server pre CRM systém s pokročilým autentifikačným systémom, role-based access control a kompletnou bezpečnostnou vrstvou. +Modern backend API server pre CRM systém s pokročilou autentifikáciou, JMAP email integráciou, kontakt managementom a kompletnou bezpečnostnou vrstvou. -## Technológie +## 📋 Obsah -- **Node.js** + **Express.js** - Backend framework -- **PostgreSQL** - Databáza -- **Drizzle ORM** - Type-safe ORM -- **JWT** - JSON Web Tokens pre autentifikáciu -- **Bcrypt** - Password hashing -- **Zod** - Validácia vstupov -- **Helmet.js** - HTTP security headers -- **Express Rate Limit** - Rate limiting -- **JMAP** - Email posielanie (Truemail.sk) +- [Technológie](#technológie) +- [Funkcie](#funkcie) +- [Systém rolí a autorizácia](#systém-rolí-a-autorizácia) +- [Architektúra](#architektúra) +- [Databázová schéma](#databázová-schéma) +- [API Endpoints](#api-endpoints) +- [JMAP Email integrácia](#jmap-email-integrácia) +- [Bezpečnosť](#bezpečnosť) +- [Inštalácia a spustenie](#inštalácia-a-spustenie) +- [Štruktúra projektu](#štruktúra-projektu) +- [Testing](#testing) +- [Deployment](#deployment) -## Funkcionality +--- -### 3-Krokový Autentifikačný Flow +## 🚀 Technológie -1. **Krok 1: Login s temporary password** - - Používateľ dostane username a dočasné heslo - - Prihlásenie vytvorí JWT tokens a session +### Backend Stack +- **Node.js 18+** - Runtime environment +- **Express.js 4.21** - Web framework +- **PostgreSQL 16** - Relačná databáza +- **Drizzle ORM 0.44** - Type-safe ORM +- **JWT (jsonwebtoken 9.0)** - Token-based autentifikácia +- **Bcrypt.js 3.0** - Password hashing +- **Zod 4.1** - Schema validation +- **Axios 1.13** - HTTP client pre JMAP -2. **Krok 2: Nastavenie nového hesla** - - Po prvom prihlásení musí užívateľ zmeniť heslo - - Strong password policy (8+ znakov, uppercase, lowercase, čísla, špeciálne znaky) +### Security & Middleware +- **Helmet 8.0** - HTTP security headers (CSP, HSTS) +- **CORS 2.8** - Cross-Origin Resource Sharing +- **Express Rate Limit 8.2** - Rate limiting protection +- **Cookie Parser 1.4** - Cookie parsing +- **Morgan 1.10** - HTTP request logger +- **XSS Clean 0.1** - XSS protection -3. **Krok 3: Email setup (voliteľný)** - - Pripojenie emailu s verifikačným linkom - - Možnosť preskočiť tento krok +### Dev Tools +- **Nodemon 3.1** - Auto-restart development server +- **Drizzle Kit 0.31** - Database migrations +- **ESLint** - Code linting +- **Jest 29.7** - Testing framework +- **Supertest 6.3** - HTTP API testing -### Role-Based Access Control (RBAC) +### Email Integration +- **JMAP Protocol** - Modern email protocol +- **Truemail.sk** - Email provider integration -- **Admin** - Plný prístup, môže vytvárať používateľov, meniť role -- **Member** - Základný prístup (zatiaľ len vlastný profil) +--- -### Bezpečnostné Vrstvy +## ✨ Funkcie -- ✅ **Helmet.js** - HTTP headers security (CSP, HSTS) -- ✅ **CORS** - Whitelist configuration -- ✅ **Rate limiting** - Login (5/15min), API (100/15min) -- ✅ **Input validation** - Zod schemas -- ✅ **SQL injection protection** - Drizzle ORM parametrized queries -- ✅ **XSS protection** - Helmet + input sanitization -- ✅ **Password security** - Bcrypt (12 rounds) -- ✅ **JWT security** - HttpOnly cookies, short expiration -- ✅ **Audit logging** - Všetky dôležité akcie logované -- ✅ **Environment variables** - Citlivé dáta v .env +### Autentifikácia a používatelia +- ✅ **3-krokový onboarding flow** + - Krok 1: Login s dočasným heslom + - Krok 2: Nastavenie vlastného hesla + - Krok 3: Voliteľné pripojenie JMAP email účtu + +- ✅ **Session-based autentifikácia** + - JWT tokens (Access + Refresh) + - HttpOnly cookies pre bezpečnosť + - Automatický refresh tokenov + +- ✅ **User management (Admin)** + - Vytvorenie používateľa s automatickým temp heslom + - Zmena rolí (admin/member) + - Zmazanie používateľa + - Zoznam všetkých používateľov + +### Email Management (JMAP) +- ✅ **Email synchronizácia** + - Stiahnutie emailov z JMAP servera (Truemail.sk) + - Automatická synchronizácia len pre pridané kontakty + - Thread-based organizácia (konverzácie) + +- ✅ **Email operácie** + - Označovanie ako prečítané/neprečítané + - Vyhľadávanie v emailoch (subject, body, sender) + - Odpovede na emaily cez JMAP + - Počítadlo neprečítaných správ + +- ✅ **Contact Management** + - Objavovanie potenciálnych kontaktov z email odosielateľov + - Pridávanie kontaktov + - Editácia kontaktov (meno, poznámky) + - Odstránenie kontaktu + - Pri pridaní kontaktu sa automaticky syncujú všetky jeho emaily + +### Audit a Logging +- ✅ **Kompletný audit trail** + - Všetky dôležité akcie logované do DB + - IP adresa, user agent, timestamp + - Staré a nové hodnoty (pre change tracking) + - Success/error status + +- ✅ **Audit events** + - Login/logout + - Password changes + - Email linking + - Contact additions/removals + - Role changes (admin akcie) + +--- + +## 🔐 Systém rolí a autorizácia + +### Role Hierarchy + +#### 1. **Admin** +**Plné oprávnenia:** +- ✅ Všetko čo môže Member +- ✅ **User management:** + - `POST /api/admin/users` - Vytvorenie používateľa + - `GET /api/admin/users` - Zoznam všetkých používateľov + - `GET /api/admin/users/:id` - Detail používateľa + - `PATCH /api/admin/users/:id/role` - Zmena role + - `DELETE /api/admin/users/:id` - Zmazanie používateľa + +**Backend implementácia:** +```javascript +// Middleware chain +router.use(authenticate); // Overí JWT token +router.use(requireAdmin); // Overí role === 'admin' +``` + +#### 2. **Member** +**Základné oprávnenia:** +- ✅ Vlastný profil a nastavenia +- ✅ Zmena vlastného hesla +- ✅ Pripojenie vlastného email účtu +- ✅ Správa vlastných kontaktov +- ✅ Vlastné emaily a konverzácie + +**NEMÁ prístup k:** +- ❌ Admin endpointy (`/api/admin/*`) +- ❌ Údaje iných používateľov +- ❌ Zmena rolí + +### Middleware Chain + +**Autentifikácia:** +```javascript +// src/middlewares/auth/authMiddleware.js +export const authenticate = async (req, res, next) => { + // 1. Získaj token z cookie alebo Authorization header + // 2. Verifikuj JWT token + // 3. Načítaj user z DB + // 4. Pridaj user do req.user +} +``` + +**Autorizácia (role-based):** +```javascript +// src/middlewares/auth/roleMiddleware.js +export const requireAdmin = (req, res, next) => { + if (req.user.role !== 'admin') { + return res.status(403).json({ error: 'Forbidden' }) + } + next() +} +``` + +### Bezpečnostný princíp: Backend Filtering + +**✅ Správne:** Backend filtruje dáta podľa role +```javascript +// Admin endpoint - middleware blokuje non-adminov +router.get('/api/admin/users', authenticate, requireAdmin, getAllUsers) + +// Member endpoint - user vidí len svoje dáta +router.get('/api/contacts', authenticate, async (req, res) => { + // Service layer filtruje podľa req.user.id + const contacts = await getContactsByUserId(req.user.id) + res.json({ data: contacts }) +}) +``` + +**❌ Zlé:** Posielanie všetkých dát a filtrovanie na frontende +- Citlivé admin dáta by boli viditeľné v Network tab +- Security by bola len "visual" - nie skutočná + +--- + +## 🏗️ Architektúra + +### Layered Architecture + +``` +┌─────────────────────────────────────┐ +│ Routes Layer │ ← API endpoints, middleware chains +│ (auth.routes.js, admin.routes.js) │ +└──────────────┬──────────────────────┘ + │ +┌──────────────▼──────────────────────┐ +│ Controllers Layer │ ← Request handling, validation +│ (auth.controller.js, ...) │ +└──────────────┬──────────────────────┘ + │ +┌──────────────▼──────────────────────┐ +│ Services Layer │ ← Business logic +│ (auth.service.js, jmap.service.js)│ +└──────────────┬──────────────────────┘ + │ +┌──────────────▼──────────────────────┐ +│ Database Layer (Drizzle ORM) │ ← Data persistence +│ (schema.js) │ +└─────────────────────────────────────┘ +``` + +### Middleware Pipeline + +``` +Request + │ + ├─→ Morgan (logging) + ├─→ Helmet (security headers) + ├─→ CORS (cross-origin) + ├─→ Body Parser (JSON, URL-encoded) + ├─→ Cookie Parser + ├─→ Rate Limiter + │ + ├─→ Route Matching + │ │ + │ ├─→ authenticate (JWT validation) + │ ├─→ requireAdmin (role check) + │ ├─→ validateBody (Zod schemas) + │ ├─→ Controller + │ └─→ Service → Database + │ + ├─→ Not Found Handler (404) + └─→ Error Handler (global catch) +``` + +--- + +## 💾 Databázová schéma + +### Users Table +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username TEXT NOT NULL UNIQUE, + email TEXT UNIQUE, -- JMAP email (Truemail.sk) + email_password TEXT, -- Encrypted JMAP password + jmap_account_id TEXT, -- JMAP account ID + first_name TEXT, + last_name TEXT, + password TEXT, -- Bcrypt hash (permanent) + temp_password TEXT, -- Bcrypt hash (temporary) + changed_password BOOLEAN DEFAULT false, + role role_enum DEFAULT 'member' NOT NULL, -- 'admin' | 'member' + last_login TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +``` + +### Contacts Table +```sql +CREATE TABLE contacts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + email TEXT NOT NULL, + name TEXT, + notes TEXT, + added_at TIMESTAMP DEFAULT NOW(), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +``` + +### Emails Table +```sql +CREATE TABLE emails ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + contact_id UUID REFERENCES contacts(id) ON DELETE CASCADE, + jmap_id TEXT UNIQUE, -- JMAP message ID + message_id TEXT UNIQUE, -- Email Message-ID header + thread_id TEXT, -- Thread grouping + in_reply_to TEXT, -- Reply chain + "from" TEXT, + "to" TEXT, + subject TEXT, + body TEXT, + is_read BOOLEAN DEFAULT false, + date TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +``` + +### Audit Logs Table +```sql +CREATE TABLE audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, -- 'login', 'password_change', ... + resource TEXT NOT NULL, -- 'user', 'contact', 'email' + resource_id TEXT, + old_value TEXT, -- JSON string + new_value TEXT, -- JSON string + ip_address TEXT, + user_agent TEXT, + success BOOLEAN DEFAULT true, + error_message TEXT, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### Relationships +- **Users → Contacts** (1:N) - User môže mať viacero kontaktov +- **Users → Emails** (1:N) - User má svoje emaily +- **Contacts → Emails** (1:N) - Email je priradený kontaktu +- **Users → Audit Logs** (1:N) - Audit log pre každú akciu + +--- + +## 📡 API Endpoints + +### Autentifikácia (`/api/auth`) + +| Method | Endpoint | Auth | Role | Popis | +|--------|----------|------|------|-------| +| POST | `/auth/login` | ❌ | - | Login s temporary alebo permanent heslom | +| POST | `/auth/set-password` | ✅ | - | Nastavenie nového hesla (onboarding) | +| POST | `/auth/link-email` | ✅ | - | Pripojenie JMAP email účtu | +| POST | `/auth/skip-email` | ✅ | - | Preskočenie email setupu | +| POST | `/auth/logout` | ✅ | - | Odhlásenie (clear cookies) | +| GET | `/auth/session` | ✅ | - | Získanie aktuálnej session | +| GET | `/auth/me` | ✅ | - | Profil aktuálneho usera | + +**Príklad: Login request** +```javascript +POST /api/auth/login +Content-Type: application/json + +{ + "username": "jan.novak", + "password": "TempPass123!" +} + +// Response: +{ + "success": true, + "data": { + "user": { "id": "...", "username": "jan.novak", "role": "member" }, + "needsPasswordChange": true, + "needsEmailSetup": true + } +} +// + Sets accessToken cookie (httpOnly) +``` + +--- + +### Admin (`/api/admin`) - **Len pre adminov** + +| Method | Endpoint | Auth | Role | Popis | +|--------|----------|------|------|-------| +| POST | `/admin/users` | ✅ | Admin | Vytvorenie nového používateľa | +| GET | `/admin/users` | ✅ | Admin | Zoznam všetkých používateľov | +| GET | `/admin/users/:userId` | ✅ | Admin | Detail používateľa | +| PATCH | `/admin/users/:userId/role` | ✅ | Admin | Zmena role používateľa | +| DELETE | `/admin/users/:userId` | ✅ | Admin | Zmazanie používateľa | + +**Príklad: Vytvorenie používateľa** +```javascript +POST /api/admin/users +Authorization: Bearer +Content-Type: application/json + +{ + "username": "jana.horvathova", + "firstName": "Jana", + "lastName": "Horvathova", + "email": "jana.horvathova@truemail.sk", // voliteľné + "emailPassword": "jmap-password-123" // voliteľné +} + +// Response: +{ + "success": true, + "data": { + "user": { + "id": "...", + "username": "jana.horvathova", + "tempPassword": "X9kL#mP2qR4w", // ← Admin si toto skopíruje! + "emailSetup": true // true ak bol email poskytnutý + } + } +} +``` + +--- + +### Kontakty (`/api/contacts`) + +| Method | Endpoint | Auth | Role | Popis | +|--------|----------|------|------|-------| +| GET | `/contacts` | ✅ | - | Všetky kontakty usera | +| GET | `/contacts/discover` | ✅ | - | Potenciálne kontakty (z JMAP) | +| POST | `/contacts` | ✅ | - | Pridať kontakt + sync jeho emailov | +| PATCH | `/contacts/:id` | ✅ | - | Upraviť kontakt | +| DELETE | `/contacts/:id` | ✅ | - | Odstrániť kontakt | + +**Príklad: Objavenie potenciálnych kontaktov** +```javascript +GET /api/contacts/discover?limit=50&search=john +Authorization: Bearer + +// Response: +{ + "success": true, + "data": [ + { + "email": "john.doe@example.com", + "name": "John Doe", + "emailCount": 15, + "isContact": false, // Ešte nie je v kontaktoch + "lastEmailDate": "2024-11-18T10:30:00Z" + }, + ... + ] +} +``` + +--- + +### Emaily (`/api/emails`) + +| Method | Endpoint | Auth | Role | Popis | +|--------|----------|------|------|-------| +| GET | `/emails` | ✅ | - | Všetky emaily usera | +| GET | `/emails/search?q=...` | ✅ | - | Vyhľadávanie v emailoch | +| GET | `/emails/unread-count` | ✅ | - | Počet neprečítaných emailov | +| POST | `/emails/sync` | ✅ | - | Manuálna synchronizácia z JMAP | +| GET | `/emails/thread/:threadId` | ✅ | - | Thread (konverzácia) | +| POST | `/emails/thread/:threadId/read` | ✅ | - | Označiť thread ako prečítaný | +| GET | `/emails/contact/:contactId` | ✅ | - | Emaily od konkrétneho kontaktu | +| PATCH | `/emails/:jmapId/read` | ✅ | - | Označiť email ako prečítaný | +| POST | `/emails/reply` | ✅ | - | Odpovedať na email cez JMAP | + +**Príklad: Odpoveď na email** +```javascript +POST /api/emails/reply +Authorization: Bearer +Content-Type: application/json + +{ + "to": "john.doe@example.com", + "subject": "Re: Project Update", + "body": "Thanks for the update!", + "inReplyTo": "", + "threadId": "thread-abc123" +} + +// Response: +{ + "success": true, + "data": { + "messageId": "", + "sentAt": "2024-11-18T15:45:00Z" + } +} +``` + +--- + +## 📧 JMAP Email integrácia + +### Čo je JMAP? +**JMAP (JSON Meta Application Protocol)** je moderný email protocol, náhrada za IMAP/SMTP. + +**Výhody:** +- JSON-based API (nie custom text protocol ako IMAP) +- Stateless (REST-like) +- Efektívnejší bandwidth (delta updates) +- Lepší pre mobile aplikácie + +### Truemail.sk integrácia + +**JMAP Server:** `https://mail.truemail.sk/jmap/` + +**Autentifikácia:** +```javascript +// Basic Auth +username: "user@truemail.sk" +password: "user-email-password" +``` + +**JMAP Session:** +```javascript +// 1. Získaj session info +GET https://mail.truemail.sk/jmap/ +Authorization: Basic + +// Response obsahuje: +{ + "accounts": { + "ba": { ... } // ← accountId + }, + "apiUrl": "https://mail.truemail.sk/jmap/" +} +``` + +### Synchronizácia emailov + +**Kedy sa syncujú emaily:** +1. ✅ Pri pridaní nového kontaktu - automaticky sa stiahnu všetky jeho emaily +2. ✅ Manuálne cez `POST /api/emails/sync` +3. ✅ Automaticky každých 60 sekúnd (frontend polling) + +**Proces:** +```javascript +// Service layer: src/services/jmap.service.js +export const syncContactEmails = async (user, contactEmail) => { + const jmapConfig = getJmapConfig(user) + + // 1. JMAP Email/query - nájdi emaily od/pre kontakt + const emailIds = await jmapRequest(jmapConfig, [ + ['Email/query', { + accountId: jmapConfig.accountId, + filter: { + from: contactEmail + // OR to: contactEmail + } + }] + ]) + + // 2. JMAP Email/get - získaj detaily emailov + const emails = await jmapRequest(jmapConfig, [ + ['Email/get', { + accountId: jmapConfig.accountId, + ids: emailIds + }] + ]) + + // 3. Ulož do DB (emails table) + await saveEmailsToDB(emails, user.id, contact.id) +} +``` + +**Thread support:** +- Emaily sú zoskupené podľa `threadId` (JMAP native) +- `inReplyTo` header pre reply chains +- Frontend zobrazuje ako konverzácie + +--- + +## 🔒 Bezpečnosť + +### 1. Password Security + +**Bcrypt hashing:** +```javascript +// 12 rounds (2^12 iterations) +const hash = await bcrypt.hash(password, 12) +``` + +**Strong password policy:** +- Minimálne 8 znakov +- Aspoň 1 veľké písmeno +- Aspoň 1 malé písmeno +- Aspoň 1 číslo +- Aspoň 1 špeciálny znak + +**Email password encryption:** +```javascript +// AES-256-GCM encryption pre JMAP password +const encrypted = encryptPassword(emailPassword) +// Formát: "iv:authTag:encryptedText" +``` + +### 2. JWT Token Security + +**Access Token:** +- Krátka životnosť (1 hodina) +- HttpOnly cookie (nedostupný pre JavaScript) +- Secure flag (len HTTPS v produkcii) +- SameSite=Strict (CSRF protection) + +**Token generation:** +```javascript +// src/utils/jwt.js +export const generateAccessToken = (user) => { + return jwt.sign( + { id: user.id, role: user.role }, + process.env.JWT_SECRET, + { expiresIn: '1h' } + ) +} +``` + +### 3. Rate Limiting + +**Login protection:** +```javascript +// 5 pokusov za 15 minút +loginRateLimiter: { + windowMs: 15 * 60 * 1000, + max: 5 +} +``` + +**API protection:** +```javascript +// Development: 1000 req/15min +// Production: 100 req/15min +apiRateLimiter: { + windowMs: 15 * 60 * 1000, + max: process.env.NODE_ENV === 'production' ? 100 : 1000 +} +``` + +**Sensitive operations:** +```javascript +// Password changes, email changes +// Production: 3 pokusy za 15 minút +sensitiveOperationLimiter: { max: 3 } +``` + +### 4. HTTP Security Headers (Helmet) + +```javascript +helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"] + } + }, + hsts: { + maxAge: 31536000, // 1 rok + includeSubDomains: true, + preload: true + } +}) +``` + +### 5. Input Validation (Zod) + +**Všetky user inputy sú validované:** +```javascript +// src/validators/auth.validators.js +export const loginSchema = z.object({ + username: z.string().min(3).max(50), + password: z.string().min(8) +}) + +// Middleware +validateBody(loginSchema) +``` + +### 6. SQL Injection Protection + +**Drizzle ORM používa prepared statements:** +```javascript +// ✅ Bezpečné - parametrizované query +await db.select() + .from(users) + .where(eq(users.username, username)) // ← automaticky escaped +``` + +### 7. CORS Configuration + +```javascript +cors({ + origin: process.env.CORS_ORIGIN || 'http://localhost:5173', + credentials: true, // Povoľ cookies + optionsSuccessStatus: 200 +}) +``` + +### 8. Audit Logging + +**Každá dôležitá akcia je logovaná:** +```javascript +await logAuditEvent({ + userId: user.id, + action: 'login', + resource: 'auth', + ipAddress: req.ip, + userAgent: req.headers['user-agent'], + success: true +}) +``` + +--- + +## 📦 Inštalácia a spustenie + +### Požiadavky +- **Node.js 18+** a **npm 9+** +- **PostgreSQL 16+** +- **Docker** (voliteľné, pre lokálne DB) ## Inštalácia @@ -341,41 +992,257 @@ Spustenie testov: npm test ``` -## Štruktúra Projektu +## 📁 Štruktúra projektu ``` -src/ -├── config/ # Database & auth config -├── db/ -│ ├── schema.js # Drizzle schema -│ ├── migrations/ # Auto-generated migrations -│ └── seeds/ # Seed scripts -├── controllers/ # Request handlers -├── services/ # Business logic -├── middlewares/ -│ ├── auth/ # Auth & role middleware -│ ├── security/ # Rate limiting, validation -│ └── global/ # Error handling -├── routes/ # API routes -├── validators/ # Zod schemas -├── utils/ # Helper functions -├── app.js # Express app -└── index.js # Server entry point +crm-server/ +├── src/ +│ ├── api/ # (unused, legacy?) +│ ├── config/ +│ │ ├── database.js # Drizzle DB connection +│ │ └── jmap.js # JMAP configuration +│ │ +│ ├── controllers/ # Request handlers +│ │ ├── auth.controller.js # Login, set password, link email +│ │ ├── admin.controller.js # User management (admin only) +│ │ ├── contact.controller.js # Contact CRUD +│ │ └── crm-email.controller.js # Email sync, search, replies +│ │ +│ ├── db/ +│ │ ├── schema.js # Drizzle tables (users, contacts, emails, audit_logs) +│ │ ├── migrate.js # Migration runner +│ │ ├── migrations/ # Auto-generated SQL migrations +│ │ └── seeds/ +│ │ ├── admin.seed.js # Seed admin account +│ │ └── testuser.seed.js # Seed test user +│ │ +│ ├── middlewares/ +│ │ ├── auth/ +│ │ │ ├── authMiddleware.js # JWT validation (authenticate) +│ │ │ └── roleMiddleware.js # Role check (requireAdmin, requireOwnerOrAdmin) +│ │ ├── security/ +│ │ │ ├── rateLimiter.js # Login, API, sensitive operation limiters +│ │ │ └── validateInput.js # Zod schema validation +│ │ └── global/ +│ │ ├── errorHandler.js # Global error handler +│ │ ├── notFound.js # 404 handler +│ │ └── validateBody.js # Body validation +│ │ +│ ├── routes/ # API route definitions +│ │ ├── auth.routes.js # /api/auth/* (login, set-password, link-email) +│ │ ├── admin.routes.js # /api/admin/* (user management) +│ │ ├── contact.routes.js # /api/contacts/* (CRUD) +│ │ └── crm-email.routes.js # /api/emails/* (sync, search, reply) +│ │ +│ ├── services/ # Business logic layer +│ │ ├── auth.service.js # User authentication, password management +│ │ ├── contact.service.js # Contact management +│ │ ├── crm-email.service.js # Email database operations +│ │ ├── jmap.service.js # JMAP API integration (Truemail.sk) +│ │ ├── email.service.js # Email utilities +│ │ └── audit.service.js # Audit logging +│ │ +│ ├── validators/ # Zod validation schemas +│ │ └── auth.validators.js # Login, password, email schemas +│ │ +│ ├── utils/ # Helper utilities +│ │ ├── errors.js # Custom error classes +│ │ ├── jwt.js # JWT generation & verification +│ │ ├── logger.js # Logging utility +│ │ └── password.js # Bcrypt hashing, temp password generation, encryption +│ │ +│ ├── app.js # Express app configuration +│ └── index.js # Server entry point +│ +├── __tests__/ # Jest test files +├── postgres/ # Docker volume for PostgreSQL data +├── .env.example # Environment variables template +├── .env # Environment variables (gitignored) +├── docker-compose.yml # PostgreSQL Docker setup +├── drizzle.config.js # Drizzle Kit configuration +├── package.json # NPM dependencies and scripts +└── README.md # This file ``` -## Licencia +### Kľúčové súbory -MIT +**`src/app.js`** +- Express aplikácia setup +- Middleware chain (security, CORS, parsing, rate limiting) +- Route mounting +- Global error handling -## Autor +**`src/db/schema.js`** +- Drizzle ORM schema definície +- Tables: users, contacts, emails, audit_logs +- Relationships a indexy -Richard Tekula +**`src/services/jmap.service.js`** +- JMAP API client pre Truemail.sk +- Email sync, search, send reply +- Mailbox management + +**`src/middlewares/auth/authMiddleware.js`** +- JWT token validation +- User session loading +- Pridáva `req.user` do requestu + +**`src/middlewares/auth/roleMiddleware.js`** +- Role-based access control +- `requireAdmin` - blokuje non-adminov +- `requireOwnerOrAdmin` - vlastník alebo admin --- -**POZNÁMKA:** Tento projekt je v aktívnom vývoji. Pre production nasadenie nezabudnite: -1. Zmeniť všetky secret keys v `.env` -2. Nastaviť HTTPS -3. Konfigurovať firewall -4. Nastaviť monitoring a alerting -5. Pravidelné security audity +## 🧪 Testing + +### Spustenie testov + +```bash +npm test +``` + +### Test coverage (TODO) +- Unit testy pre services +- Integration testy pre API endpoints +- E2E testy pre autentifikačný flow + +--- + +## 🚀 Deployment + +### Production Checklist + +**1. Environment Variables** +```bash +# Zmeň všetky secret keys! +JWT_SECRET= +JWT_REFRESH_SECRET= +BETTER_AUTH_SECRET= + +# Production settings +NODE_ENV=production +CORS_ORIGIN=https://your-frontend-domain.com +RATE_LIMIT_MAX_REQUESTS=100 + +# Database +DB_HOST=your-production-db-host +DB_PORT=5432 +DB_USER=crm_user +DB_PASSWORD= +DB_NAME=crm_production +``` + +**2. HTTPS Setup** +- Použite reverse proxy (Nginx, Caddy) +- SSL certifikáty (Let's Encrypt) +- Redirect HTTP → HTTPS + +**3. Database** +- Povolte SSL connections +- Pravidelné backupy (pg_dump) +- Monitoring (pg_stat_statements) + +**4. Security** +- Firewall rules (povoľ len potrebné porty) +- DDoS protection (Cloudflare, AWS Shield) +- Monitoring a alerting (Sentry, DataDog) + +**5. Deployment Platforms** +- **VPS:** DigitalOcean, Linode, Vultr +- **Cloud:** AWS (EC2, RDS), Google Cloud, Azure +- **Platform:** Railway, Render, Fly.io + +### Docker Deployment + +```dockerfile +# Dockerfile +FROM node:18-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --only=production + +COPY . . + +EXPOSE 5000 + +CMD ["npm", "start"] +``` + +```bash +# Build +docker build -t crm-server . + +# Run +docker run -d \ + --name crm-api \ + -p 5000:5000 \ + --env-file .env \ + crm-server +``` + +--- + +## 📝 TODO / Budúce vylepšenia + +- [ ] Refresh token rotation +- [ ] Email verification (2FA) +- [ ] Password reset flow +- [ ] User profile pictures +- [ ] Advanced audit log filtering +- [ ] Webhook support +- [ ] Real-time notifications (WebSockets) +- [ ] Multi-tenant support +- [ ] API rate limiting per user +- [ ] Automated backup system + +--- + +## 🐛 Known Issues + +- JMAP sync môže byť pomalý pri veľkom množstve emailov (optimalizovať batch size) +- Rate limiter je in-memory (nepersistuje cez reštarty) - zvážiť Redis +- Session refresh token nemá automatic rotation + +--- + +## 📞 Podpora a Kontakt + +Pre technické otázky a bug reporting: +- Vytvor Issue v repozitári +- Email: richard@example.com + +--- + +## 📄 Licencia + +MIT License + +Copyright (c) 2024 Richard Tekula + +--- + +## 👨‍💻 Autor + +**Richard Tekula** +Backend Developer + +--- + +**⚠️ PRODUCTION WARNING:** + +Tento projekt je v aktívnom vývoji. Pred production deploymentom nezabudni: + +1. ✅ Zmeniť všetky secret keys v `.env` +2. ✅ Nastaviť HTTPS (reverse proxy) +3. ✅ Konfigurovať firewall pravidlá +4. ✅ Nastaviť monitoring a alerting (Sentry, Prometheus) +5. ✅ Pravidelné security audity (`npm audit`) +6. ✅ Database backupy (automatizované) +7. ✅ Load balancing (pri vysokom trafficu) +8. ✅ CDN pre static assets +9. ✅ GDPR compliance (ak potrebné) + +**Happy coding! 🚀** diff --git a/check-tables.js b/check-tables.js new file mode 100644 index 0000000..b50b29f --- /dev/null +++ b/check-tables.js @@ -0,0 +1,24 @@ +import pkg from 'pg'; +const { Pool } = pkg; +import dotenv from 'dotenv'; +dotenv.config(); + +const pool = new Pool({ + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432'), + user: process.env.DB_USER || 'admin', + password: process.env.DB_PASSWORD || 'heslo123', + database: process.env.DB_NAME || 'crm', +}); + +const query = "SELECT tablename FROM pg_tables WHERE schemaname = 'public';"; +pool.query(query).then(res => { + console.log('Tabuľky v databáze:'); + res.rows.forEach(row => console.log(' -', row.tablename)); + pool.end(); + process.exit(0); +}).catch(err => { + console.error('Chyba:', err.message); + pool.end(); + process.exit(1); +}); diff --git a/src/controllers/admin.controller.js b/src/controllers/admin.controller.js index 6f27b3c..24de922 100644 --- a/src/controllers/admin.controller.js +++ b/src/controllers/admin.controller.js @@ -1,16 +1,18 @@ import { db } from '../config/database.js'; import { users } from '../db/schema.js'; import { eq } from 'drizzle-orm'; -import { hashPassword, generateTempPassword } from '../utils/password.js'; +import { hashPassword, generateTempPassword, encryptPassword } from '../utils/password.js'; import { logUserCreation, logRoleChange } from '../services/audit.service.js'; import { formatErrorResponse, ConflictError, NotFoundError } from '../utils/errors.js'; +import { validateJmapCredentials } from '../services/email.service.js'; /** - * Vytvorenie nového usera s temporary password (admin only) + * Vytvorenie nového usera s automatic temporary password (admin only) + * Ak je poskytnutý email a emailPassword, automaticky sa fetchne JMAP account ID * POST /api/admin/users */ export const createUser = async (req, res) => { - const { username, tempPassword, role, firstName, lastName } = req.body; + const { username, email, emailPassword, firstName, lastName } = req.body; const adminId = req.userId; const ipAddress = req.ip || req.connection.remoteAddress; const userAgent = req.headers['user-agent']; @@ -27,16 +29,34 @@ export const createUser = async (req, res) => { throw new ConflictError('Username už existuje'); } - // Hash temporary password + // Automaticky vygeneruj temporary password + const tempPassword = generateTempPassword(12); const hashedTempPassword = await hashPassword(tempPassword); + // Ak sú poskytnuté email credentials, validuj ich a získaj JMAP account ID + let jmapAccountId = null; + let encryptedEmailPassword = null; + + if (email && emailPassword) { + try { + const { accountId } = await validateJmapCredentials(email, emailPassword); + jmapAccountId = accountId; + encryptedEmailPassword = encryptPassword(emailPassword); + } catch (emailError) { + throw new ConflictError(`Nepodarilo sa overiť emailový účet: ${emailError.message}`); + } + } + // Vytvor usera const [newUser] = await db .insert(users) .values({ username, + email: email || null, + emailPassword: encryptedEmailPassword, + jmapAccountId, tempPassword: hashedTempPassword, - role: role || 'member', + role: 'member', // Vždy member, nie admin firstName: firstName || null, lastName: lastName || null, changedPassword: false, @@ -44,7 +64,7 @@ export const createUser = async (req, res) => { .returning(); // Log user creation - await logUserCreation(adminId, newUser.id, username, role || 'member', ipAddress, userAgent); + await logUserCreation(adminId, newUser.id, username, 'member', ipAddress, userAgent); res.status(201).json({ success: true, @@ -52,11 +72,18 @@ export const createUser = async (req, res) => { user: { id: newUser.id, username: newUser.username, + email: newUser.email, + firstName: newUser.firstName, + lastName: newUser.lastName, role: newUser.role, - tempPassword: tempPassword, // Vráti plain text password pre admina + jmapAccountId: newUser.jmapAccountId, + emailSetup: !!newUser.jmapAccountId, + tempPassword: tempPassword, // Vráti plain text password pre admina aby ho mohol poslať userovi }, }, - message: 'Používateľ úspešne vytvorený', + message: newUser.jmapAccountId + ? 'Používateľ úspešne vytvorený s emailovým účtom.' + : 'Používateľ úspešne vytvorený. Email môže byť nastavený neskôr.', }); } catch (error) { const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); diff --git a/src/controllers/crm-email.controller.js b/src/controllers/crm-email.controller.js index b15b4fc..fae4c71 100644 --- a/src/controllers/crm-email.controller.js +++ b/src/controllers/crm-email.controller.js @@ -1,5 +1,6 @@ import * as crmEmailService from '../services/crm-email.service.js'; -import { markEmailAsRead, sendEmail, getJmapConfig } from '../services/jmap.service.js'; +import * as contactService from '../services/contact.service.js'; +import { markEmailAsRead, sendEmail, getJmapConfig, syncEmailsFromSender, searchEmailsJMAP as searchEmailsJMAPService } from '../services/jmap.service.js'; import { formatErrorResponse } from '../utils/errors.js'; import { getUserById } from '../services/auth.service.js'; @@ -76,9 +77,89 @@ export const getUnreadCount = async (req, res) => { const userId = req.userId; const count = await crmEmailService.getUnreadCount(userId); + const accounts = []; + + if (req.user?.email) { + accounts.push({ + id: req.user.jmapAccountId || req.user.email, + email: req.user.email, + label: req.user.email, + unread: count, + }); + } + res.status(200).json({ success: true, - data: { count }, + data: { + count, + totalUnread: count, + accounts, + lastUpdatedAt: new Date().toISOString(), + }, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Sync latest emails for all contacts from JMAP + * POST /api/emails/sync + */ +export const syncEmails = async (req, res) => { + try { + const userId = req.userId; + const user = await getUserById(userId); + + if (!user.email || !user.emailPassword || !user.jmapAccountId) { + return res.status(400).json({ + success: false, + error: { + message: 'Najprv musíš pripojiť email účet v Profile', + statusCode: 400, + }, + }); + } + + const contacts = await contactService.getUserContacts(userId); + + if (!contacts.length) { + return res.status(200).json({ + success: true, + message: 'Žiadne kontakty na synchronizáciu', + data: { contacts: 0, synced: 0, newEmails: 0 }, + }); + } + + const jmapConfig = getJmapConfig(user); + let totalSynced = 0; + let totalNew = 0; + + for (const contact of contacts) { + try { + const { total, saved } = await syncEmailsFromSender( + jmapConfig, + userId, + contact.id, + contact.email, + { limit: 50 } + ); + totalSynced += total; + totalNew += saved; + } catch (syncError) { + console.error(`Failed to sync emails for contact ${contact.email}`, syncError); + } + } + + return res.status(200).json({ + success: true, + message: 'Emaily synchronizované', + data: { + contacts: contacts.length, + synced: totalSynced, + newEmails: totalNew, + }, }); } catch (error) { const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); @@ -121,12 +202,34 @@ export const markThreadRead = async (req, res) => { const userId = req.userId; const { threadId } = req.params; - const result = await crmEmailService.markThreadAsRead(userId, threadId); + const user = await getUserById(userId); + const threadEmails = await crmEmailService.getEmailThread(userId, threadId); + const unreadEmails = threadEmails.filter((email) => !email.isRead); + + let jmapConfig = null; + if (user?.email && user?.emailPassword && user?.jmapAccountId) { + jmapConfig = getJmapConfig(user); + } + + if (jmapConfig) { + for (const email of unreadEmails) { + if (!email.jmapId) { + continue; + } + try { + await markEmailAsRead(jmapConfig, userId, email.jmapId, true); + } catch (jmapError) { + console.error(`Failed to mark JMAP email ${email.jmapId} as read`, jmapError); + } + } + } + + await crmEmailService.markThreadAsRead(userId, threadId); res.status(200).json({ success: true, message: 'Konverzácia označená ako prečítaná', - count: result.count, + count: unreadEmails.length, }); } catch (error) { const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); @@ -191,3 +294,48 @@ export const getContactEmails = async (req, res) => { res.status(error.statusCode || 500).json(errorResponse); } }; + +/** + * Search emails using JMAP full-text search + * GET /api/emails/search-jmap?query=text&limit=50&offset=0 + * Searches in: from, to, subject, and email body + */ +export const searchEmailsJMAP = async (req, res) => { + try { + const userId = req.userId; + const { query = '', limit = 50, offset = 0 } = req.query; + + // Get user to access JMAP config + const user = await getUserById(userId); + + // Check if user has JMAP email configured + if (!user.email || !user.emailPassword || !user.jmapAccountId) { + return res.status(400).json({ + success: false, + error: { + message: 'Najprv musíš pripojiť email účet v Profile', + statusCode: 400, + }, + }); + } + + const jmapConfig = getJmapConfig(user); + + const results = await searchEmailsJMAPService( + jmapConfig, + userId, + query, + parseInt(limit), + parseInt(offset) + ); + + res.status(200).json({ + success: true, + count: results.length, + data: results, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; diff --git a/src/routes/crm-email.routes.js b/src/routes/crm-email.routes.js index 416fab5..22816c7 100644 --- a/src/routes/crm-email.routes.js +++ b/src/routes/crm-email.routes.js @@ -16,12 +16,18 @@ router.use(authenticate); // Get all emails router.get('/', crmEmailController.getEmails); -// Search emails +// Search emails (DB search - searches in stored emails only) router.get('/search', crmEmailController.searchEmails); +// Search emails using JMAP full-text search (searches in all emails via JMAP) +router.get('/search-jmap', crmEmailController.searchEmailsJMAP); + // Get unread count router.get('/unread-count', crmEmailController.getUnreadCount); +// Sync latest emails from JMAP +router.post('/sync', crmEmailController.syncEmails); + // Get email thread (conversation) router.get( '/thread/:threadId', diff --git a/src/services/jmap.service.js b/src/services/jmap.service.js index 074cf56..c131362 100644 --- a/src/services/jmap.service.js +++ b/src/services/jmap.service.js @@ -193,10 +193,148 @@ export const discoverContactsFromJMAP = async (jmapConfig, userId, searchTerm = } }; +/** + * Search emails using JMAP full-text search + * Searches in: from, to, subject, and email body + * Returns list of unique senders grouped by email address + */ +export const searchEmailsJMAP = async (jmapConfig, userId, query, limit = 50, offset = 0) => { + try { + logger.info(`Searching emails in JMAP (query: "${query}", limit: ${limit}, offset: ${offset})`); + + if (!query || query.trim().length < 1) { + return []; + } + + // Use JMAP search with wildcards for substring matching + // Add wildcards (*) to enable partial matching: "ander" -> "*ander*" + const wildcardQuery = query.includes('*') ? query : `*${query}*`; + + let queryResponse; + + try { + // Try with 'text' filter first (full-text search if supported) + queryResponse = await jmapRequest(jmapConfig, [ + [ + 'Email/query', + { + accountId: jmapConfig.accountId, + filter: { + text: wildcardQuery, // Full-text search with wildcards + }, + sort: [{ property: 'receivedAt', isAscending: false }], + position: offset, + limit: 200, + }, + 'query1', + ], + ]); + } catch (textFilterError) { + // If 'text' filter fails, fall back to OR conditions with wildcards + logger.warn('Text filter failed, falling back to OR conditions', textFilterError); + queryResponse = await jmapRequest(jmapConfig, [ + [ + 'Email/query', + { + accountId: jmapConfig.accountId, + filter: { + operator: 'OR', + conditions: [ + { from: wildcardQuery }, + { to: wildcardQuery }, + { subject: wildcardQuery }, + ], + }, + sort: [{ property: 'receivedAt', isAscending: false }], + position: offset, + limit: 200, + }, + 'query1', + ], + ]); + } + + const emailIds = queryResponse.methodResponses?.[0]?.[1]?.ids; + + if (!emailIds || emailIds.length === 0) { + logger.info('No emails found matching search query'); + return []; + } + + logger.info(`Found ${emailIds.length} emails matching query`); + + // Fetch email metadata + const getResponse = await jmapRequest(jmapConfig, [ + [ + 'Email/get', + { + accountId: jmapConfig.accountId, + ids: emailIds, + properties: ['from', 'to', 'subject', 'receivedAt', 'preview'], + }, + 'get1', + ], + ]); + + const emailsList = getResponse.methodResponses[0][1].list; + + // Get existing contacts for this user + const existingContacts = await db + .select() + .from(contacts) + .where(eq(contacts.userId, userId)); + + const contactEmailsSet = new Set(existingContacts.map((c) => c.email.toLowerCase())); + + // Group by sender (unique senders) + const sendersMap = new Map(); + const myEmail = jmapConfig.username.toLowerCase(); + + emailsList.forEach((email) => { + const fromEmail = email.from?.[0]?.email; + + if (!fromEmail || fromEmail.toLowerCase() === myEmail) { + return; // Skip my own emails + } + + // Keep only the most recent email from each sender + if (!sendersMap.has(fromEmail)) { + sendersMap.set(fromEmail, { + email: fromEmail, + name: email.from?.[0]?.name || fromEmail.split('@')[0], + latestSubject: email.subject || '(No Subject)', + latestDate: email.receivedAt, + snippet: email.preview || '', + isContact: contactEmailsSet.has(fromEmail.toLowerCase()), + }); + } + }); + + // Convert to array, sort by date, and apply limit + const senders = Array.from(sendersMap.values()) + .sort((a, b) => new Date(b.latestDate) - new Date(a.latestDate)) + .slice(0, limit); + + logger.success(`Found ${senders.length} unique senders matching query`); + return senders; + } catch (error) { + logger.error('Failed to search emails in JMAP', error); + throw error; + } +}; + /** * Sync emails from a specific sender (when adding as contact) */ -export const syncEmailsFromSender = async (jmapConfig, userId, contactId, senderEmail) => { +export const syncEmailsFromSender = async ( + jmapConfig, + userId, + contactId, + senderEmail, + options = {} +) => { + const { limit = 500 } = options; + try { logger.info(`Syncing emails from sender: ${senderEmail}`); @@ -211,7 +349,7 @@ export const syncEmailsFromSender = async (jmapConfig, userId, contactId, sender conditions: [{ from: senderEmail }, { to: senderEmail }], }, sort: [{ property: 'receivedAt', isAscending: false }], - limit: 500, + limit, }, 'query1', ], diff --git a/src/validators/auth.validators.js b/src/validators/auth.validators.js index 09b84a6..37f8247 100644 --- a/src/validators/auth.validators.js +++ b/src/validators/auth.validators.js @@ -58,7 +58,8 @@ export const linkEmailSchema = z.object({ }); -// Create user schema (admin only) +// Create user schema (admin only) - temp password sa generuje automaticky +// Ak je poskytnutý email, môže byť poskytnuté aj emailPassword pre automatické nastavenie JMAP export const createUserSchema = z.object({ username: z .string({ @@ -70,15 +71,8 @@ export const createUserSchema = z.object({ /^[a-zA-Z0-9_-]+$/, 'Username môže obsahovať iba písmená, čísla, pomlčky a podčiarkovníky' ), - tempPassword: z - .string({ - required_error: 'Dočasné heslo je povinné', - }) - .min(8, 'Dočasné heslo musí mať aspoň 8 znakov'), - role: z.enum(['admin', 'member'], { - required_error: 'Rola je povinná', - invalid_type_error: 'Neplatná rola', - }), + email: z.string().email('Neplatný formát emailu').max(255).optional(), + emailPassword: z.string().min(1).optional(), firstName: z.string().max(100).optional(), lastName: z.string().max(100).optional(), }); diff --git a/test-search.js b/test-search.js new file mode 100644 index 0000000..5b54ef2 --- /dev/null +++ b/test-search.js @@ -0,0 +1,44 @@ +/** + * Quick test script for JMAP search endpoint + * Run with: node test-search.js + */ + +import axios from 'axios'; + +const API_URL = 'http://localhost:5000/api'; + +async function testSearch() { + try { + console.log('Testing /emails/search-jmap endpoint...\n'); + + // You'll need to replace this with a valid session cookie + // Get it from browser DevTools after logging in + const cookie = process.env.TEST_COOKIE || ''; + + if (!cookie) { + console.error('❌ Please set TEST_COOKIE environment variable'); + console.log(' Get it from browser DevTools > Application > Cookies'); + process.exit(1); + } + + const response = await axios.get(`${API_URL}/emails/search-jmap`, { + params: { + query: 'test', + limit: 10, + offset: 0, + }, + headers: { + Cookie: cookie, + }, + }); + + console.log('✅ Success!'); + console.log('Status:', response.status); + console.log('Data:', JSON.stringify(response.data, null, 2)); + } catch (error) { + console.error('❌ Error:', error.response?.data || error.message); + console.error('Status:', error.response?.status); + } +} + +testSearch();