From 1e7c1eab90d327b85744757c5414b81efc8f539f Mon Sep 17 00:00:00 2001 From: richardtekula Date: Wed, 19 Nov 2025 13:15:45 +0100 Subject: [PATCH] option for more emails,fix jmap service,add table email accounts --- README.md | 1425 +++-------------- src/app.js | 2 + src/controllers/contact.controller.js | 97 +- src/controllers/crm-email.controller.js | 134 +- src/controllers/email-account.controller.js | 174 ++ src/db/migrations/0002_parallel_guardian.sql | 17 + src/db/migrations/meta/0002_snapshot.json | 600 +++++++ src/db/migrations/meta/_journal.json | 7 + src/db/migrations/migrate-data-only.js | 138 ++ .../migrations/migrate-to-email-accounts.js | 179 +++ src/db/schema.js | 15 + src/routes/contact.routes.js | 1 + src/routes/email-account.routes.js | 71 + src/services/contact.service.js | 26 +- src/services/crm-email.service.js | 62 +- src/services/email-account.service.js | 282 ++++ src/services/jmap.service.js | 33 +- src/validators/email-account.validators.js | 27 + 18 files changed, 1991 insertions(+), 1299 deletions(-) create mode 100644 src/controllers/email-account.controller.js create mode 100644 src/db/migrations/0002_parallel_guardian.sql create mode 100644 src/db/migrations/meta/0002_snapshot.json create mode 100644 src/db/migrations/migrate-data-only.js create mode 100644 src/db/migrations/migrate-to-email-accounts.js create mode 100644 src/routes/email-account.routes.js create mode 100644 src/services/email-account.service.js create mode 100644 src/validators/email-account.validators.js diff --git a/README.md b/README.md index bf54943..3611ab7 100644 --- a/README.md +++ b/README.md @@ -1,1248 +1,271 @@ # CRM Server API -Modern backend API server pre CRM systém s pokročilou autentifikáciou, JMAP email integráciou, kontakt managementom a kompletnou bezpečnostnou vrstvou. - -## 📋 Obsah - -- [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) - ---- - -## 🚀 Technológie - -### 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 - -### 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 - -### 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 - -### Email Integration -- **JMAP Protocol** - Modern email protocol -- **Truemail.sk** - Email provider integration - ---- - -## ✨ Funkcie - -### 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 - -### 1. Klonovanie a Dependencies - -```bash -npm install -``` - -### 2. Konfigurácia Environment Variables - -Skopírujte `.env.example` do `.env` a upravte hodnoty: - -```bash -cp .env.example .env -``` - -Dôležité premenné: -- `JWT_SECRET` - Zmeňte v produkcii! -- `JWT_REFRESH_SECRET` - Zmeňte v produkcii! -- `BETTER_AUTH_SECRET` - Zmeňte v produkcii! -- `DB_*` - Databázové credentials -- `JMAP_*` - Email server credentials - -### 3. Databáza Setup - -Spustite PostgreSQL (Docker): - -```bash -docker compose up -d postgres -``` - -Vygenerujte a aplikujte migrácie: - -```bash -npm run db:generate -npm run db:migrate -``` - -### 4. Seed Admin Account - -Vytvorte prvý admin účet: - -```bash -npm run db:seed -``` - -**DÔLEŽITÉ:** Uložte si vygenerované temporary password! - -### 5. Spustenie Servera - -Development mode: - -```bash -npm run dev -``` - -Production mode: - -```bash -npm start -``` - -Server beží na `http://localhost:5000` +Backend API pre email a kontaktný manažment s podporou viacerých JMAP účtov. + +## Funkcie + +### Autentifikácia +- **Login** - Session-based auth s JWT tokens (httpOnly cookies) +- **Onboarding** - 3-krokový flow (login → nastavenie hesla → pripojenie emailu) +- **Session management** - Automatický refresh tokenov +- **Role-based access** - Admin a Member + +### Email účty (Multi-account) +- **Pridanie účtu** - Pripojenie viacerých JMAP účtov +- **Šifrovanie hesiel** - AES-256-GCM encryption pre JMAP heslá +- **Primárny účet** - Označenie hlavného účtu +- **Cascade delete** - Odstránenie účtu vymaže všetky jeho dáta + +### JMAP Email integrácia +- **Sync emailov** - Stiahnutie emailov z Truemail.sk JMAP servera +- **Thread organizácia** - Emaily zoskupené do konverzácií +- **Full-text search** - Vyhľadávanie v predmete, tele, odosielateľovi +- **Posielanie odpovedí** - Odpoveď na emaily cez JMAP +- **Neprecítané správy** - Počítadlo per účet + +### Kontakty +- **Objavovanie** - Automatické získanie odosielateľov z emailov +- **Auto-sync emailov** - Pri pridaní kontaktu sa stiahnu všetky jeho emaily +- **CRUD operácie** - Pridanie, úprava, odstránenie +- **Account filtering** - Kontakty izolované per email účet + +### Správa používateľov (Admin) +- **Vytvorenie usera** - Auto-generovanie temporary hesla +- **Zmena rolí** - Admin/Member +- **Zoznam používateľov** - Len pre adminov + +### Bezpečnosť +- **Password hashing** - Bcrypt (12 rounds) +- **Rate limiting** - Login, API, citlivé operácie +- **Helmet** - HTTP security headers (CSP, HSTS) +- **Input validation** - Zod schemas +- **SQL injection protection** - Drizzle ORM prepared statements +- **XSS protection** - Sanitizácia inputov +- **Audit logging** - Všetky dôležité akcie logované ## API Endpointy -### Public Endpoints +### Autentifikácia `/api/auth` +- `POST /login` - Prihlásenie +- `POST /set-password` - Nastavenie hesla (onboarding) +- `POST /change-password` - Zmena hesla +- `POST /logout` - Odhlásenie +- `GET /session` - Získanie session info +- `GET /me` - Aktuálny používateľ -#### Login -```http -POST /api/auth/login -Content-Type: application/json +### Email účty `/api/email-accounts` +- `GET /` - Zoznam účtov +- `POST /` - Pridanie účtu +- `DELETE /:id` - Odstránenie účtu +- `PATCH /:id/primary` - Nastavenie primárneho -{ - "username": "admin", - "password": "temporary-password" -} -``` +### Admin `/api/admin` (Admin only) +- `POST /users` - Vytvorenie používateľa +- `GET /users` - Zoznam používateľov +- `PATCH /users/:id/role` - Zmena role +- `DELETE /users/:id` - Zmazanie používateľa -**Response:** -```json -{ - "success": true, - "data": { - "user": { ... }, - "tokens": { - "accessToken": "...", - "refreshToken": "..." - }, - "needsPasswordChange": true, - "needsEmailSetup": true - } -} -``` +### Kontakty `/api/contacts` +- `GET /` - Zoznam kontaktov (s accountId filtrom) +- `GET /discover` - Potenciálne kontakty z JMAP +- `POST /` - Pridať kontakt + auto-sync emailov +- `PATCH /:id` - Upraviť kontakt +- `DELETE /:id` - Odstrániť kontakt -### Protected Endpoints (vyžadujú JWT token) +### Emaily `/api/emails` +- `GET /` - Zoznam emailov (s accountId filtrom) +- `GET /search` - Vyhľadávanie v DB +- `GET /search-jmap` - JMAP full-text search +- `GET /thread/:id` - Thread konverzácie +- `POST /thread/:id/read` - Označiť thread ako prečítaný +- `POST /sync` - Manuálna synchronizácia +- `POST /reply` - Odpovedať na email +- `GET /unread-count` - Počet neprecítaných per účet -#### Set New Password (Krok 2) -```http -POST /api/auth/set-password -Authorization: Bearer -Content-Type: application/json +## Services -{ - "newPassword": "NewSecurePass123!", - "confirmPassword": "NewSecurePass123!" -} -``` +### auth.service.js +- `getUserById()` - Získanie usera z DB +- `createUser()` - Vytvorenie nového usera s temp heslom +- `validatePassword()` - Validácia hesla (bcrypt compare) +- `changePassword()` - Zmena hesla s auditom -#### Link Email (Krok 3) -```http -POST /api/auth/link-email -Authorization: Bearer -Content-Type: application/json +### email-account.service.js +- `getEmailAccounts()` - Zoznam účtov pre usera +- `addEmailAccount()` - Pridanie JMAP účtu (šifrovanie hesla) +- `getEmailAccountWithCredentials()` - Účet s dešifrovaným heslom +- `getPrimaryEmailAccount()` - Primárny účet +- `removeEmailAccount()` - Odstránenie s cascade delete +- `setPrimaryAccount()` - Nastavenie primárneho -{ - "email": "user@example.com" -} -``` +### jmap.service.js +- `jmapRequest()` - HTTP request na JMAP server +- `getJmapSession()` - Získanie JMAP session +- `getJmapConfigFromAccount()` - Config z email účtu +- `syncEmailsFromSender()` - Sync emailov od kontaktu +- `searchEmailsJMAP()` - Full-text search v JMAP +- `sendEmail()` - Poslanie odpovede cez JMAP +- `discoverContactsFromJMAP()` - Objavenie odosielateľov -#### Skip Email Setup -```http -POST /api/auth/skip-email -Authorization: Bearer -``` +### contact.service.js +- `getUserContacts()` - Kontakty usera (s accountId filtrom) +- `addContact()` - Pridanie + auto-sync emailov +- `updateContact()` - Úprava kontaktu +- `removeContact()` - Odstránenie + cascade delete -#### Get Current User -```http -GET /api/auth/me -Authorization: Bearer -``` +### crm-email.service.js +- `getUserEmails()` - Emaily usera (s accountId filtrom) +- `getEmailThread()` - Thread konverzácia +- `markThreadAsRead()` - Označenie threadu +- `searchEmails()` - DB vyhľadávanie +- `getUnreadCount()` - Počet neprecítaných per účet -#### Logout -```http -POST /api/auth/logout -Authorization: Bearer -``` +### audit.service.js +- `logAuditEvent()` - Zaloguje akciu s IP, user-agent, timestamp -### Admin Endpoints (vyžadujú admin rolu) +## Middlewares -#### Create User -```http -POST /api/admin/users -Authorization: Bearer -Content-Type: application/json +### Autentifikácia +- `authenticate` - Validácia JWT tokenu, načítanie usera +- `requireAdmin` - Overenie admin role +- `requireOwnerOrAdmin` - Vlastník alebo admin -{ - "username": "newuser", - "tempPassword": "TempPass123!", - "role": "member", - "firstName": "John", - "lastName": "Doe" -} -``` +### Bezpečnosť +- `loginRateLimiter` - 5 pokusov / 15 min +- `apiRateLimiter` - 100 requestov / 15 min (dev: 1000) +- `sensitiveOperationLimiter` - 3 pokusy / 15 min +- `validateBody` - Zod schema validácia +- `validateParams` - Zod params validácia -#### Get All Users -```http -GET /api/admin/users -Authorization: Bearer -``` +### Global +- `errorHandler` - Centrálne error handling +- `notFound` - 404 handler -#### Get User by ID -```http -GET /api/admin/users/:userId -Authorization: Bearer -``` +## Database Schema -#### Change User Role -```http -PATCH /api/admin/users/:userId/role -Authorization: Bearer -Content-Type: application/json +### users +- id, username, role (admin/member) +- password, temp_password (bcrypt hash) +- changed_password, last_login +- created_at, updated_at -{ - "role": "admin" -} -``` +### email_accounts +- id, user_id (FK → users) +- email, email_password (AES-256 encrypted) +- jmap_account_id +- is_primary, is_active +- created_at, updated_at -#### Delete User -```http -DELETE /api/admin/users/:userId -Authorization: Bearer -``` +### contacts +- id, user_id (FK → users) +- email_account_id (FK → email_accounts) +- email, name, notes +- added_at, created_at, updated_at -## Databázová Schéma +### emails +- id, user_id (FK → users) +- email_account_id (FK → email_accounts) +- contact_id (FK → contacts) +- jmap_id, message_id, thread_id +- from, to, subject, body +- is_read, date +- created_at, updated_at -### Users Table -``` -- id (UUID) -- username (unique) -- email (unique, nullable) -- email_verified (boolean) -- first_name -- last_name -- password (bcrypt hash) -- temp_password (bcrypt hash) -- changed_password (boolean) -- role (enum: admin, member) -- last_login +### audit_logs +- id, user_id (FK → users) +- action, resource, resource_id +- old_value, new_value (JSON) +- ip_address, user_agent +- success, error_message - created_at -- updated_at -``` -### Sessions Table -``` -- id (text) -- user_id (UUID, FK) -- expires_at -- ip_address -- user_agent -- created_at -``` +## Konfigurácia -### Audit Logs Table -``` -- id (UUID) -- user_id (UUID, FK, nullable) -- action -- resource -- resource_id -- old_value (JSON) -- new_value (JSON) -- ip_address -- user_agent -- success (boolean) -- error_message -- created_at -``` - -## Validačné Pravidlá - -### 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 - -### Username Policy -- 3-50 znakov -- Iba písmená, čísla, pomlčky a podčiarkovníky - -## Rate Limiting - -- **Login endpoint:** 5 pokusov / 15 minút -- **API endpoints:** 100 requestov / 15 minút -- **Citlivé operácie:** 3 pokusy / 15 minút - -## Audit Logging - -Všetky dôležité akcie sú automaticky logované: -- Login attempts (úspešné aj neúspešné) -- Password changes -- Email linking & verification -- Role changes -- User creation -- A ďalšie... - -## Security Best Practices - -1. **HTTPS Only** - V produkcii vždy používajte HTTPS -2. **Strong Secrets** - Zmeňte všetky secret keys v `.env` -3. **Regular Updates** - Aktualizujte dependencies -4. **Monitor Logs** - Sledujte audit logs -5. **Backup Database** - Pravidelné zálohy PostgreSQL - -## Database Commands - -```bash -# Generovanie migrácií -npm run db:generate - -# Aplikovanie migrácií -npm run db:migrate - -# Push schema (alternative to migrations) -npm run db:push - -# Drizzle Studio (GUI) -npm run db:studio - -# Seed admin account -npm run db:seed -``` - -## Testing - -Spustenie testov: - -```bash -npm test -``` - -## 📁 Štruktúra projektu - -``` -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 -``` - -### Kľúčové súbory - -**`src/app.js`** -- Express aplikácia setup -- Middleware chain (security, CORS, parsing, rate limiting) -- Route mounting -- Global error handling - -**`src/db/schema.js`** -- Drizzle ORM schema definície -- Tables: users, contacts, emails, audit_logs -- Relationships a indexy - -**`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 - ---- - -## 🧪 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 +**.env** +```env +# Server +PORT=5000 +NODE_ENV=development # Database -DB_HOST=your-production-db-host +DB_HOST=localhost DB_PORT=5432 -DB_USER=crm_user -DB_PASSWORD= -DB_NAME=crm_production +DB_USER=postgres +DB_PASSWORD=password +DB_NAME=crm + +# JWT +JWT_SECRET=your-secret-key +JWT_REFRESH_SECRET=your-refresh-secret +JWT_EXPIRES_IN=1h + +# JMAP +JMAP_SERVER=https://mail.truemail.sk/jmap/ + +# Encryption +ENCRYPTION_KEY=your-32-char-encryption-key + +# CORS +CORS_ORIGIN=http://localhost:5173 ``` -**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"] -``` +## Skripty ```bash -# Build -docker build -t crm-server . - -# Run -docker run -d \ - --name crm-api \ - -p 5000:5000 \ - --env-file .env \ - crm-server +npm run dev # Dev server s nodemon +npm start # Production server +npm run db:generate # Generovanie Drizzle migrácií +npm run db:migrate # Aplikovanie migrácií +npm run db:push # Push schema (bez migrácií) +npm run db:studio # Drizzle Studio (DB GUI) +npm run db:seed # Seed admin account +npm test # Spustenie testov ``` ---- +## Spustenie -## 📝 TODO / Budúce vylepšenia +1. Nainštaluj dependencies: `npm install` +2. Nastav `.env` s DB credentials a secrets +3. Spusti PostgreSQL: `docker-compose up -d postgres` +4. Aplikuj migrácie: `npm run db:migrate` +5. Seed admin: `npm run db:seed` (uložíš si temp password!) +6. Spusti server: `npm run dev` +7. API beží na `http://localhost:5000` -- [ ] 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 +## Systém rolí ---- +**Admin** +- Všetko čo Member +- `/api/admin/*` endpointy +- Vytvorenie/zmazanie používateľov +- Zmena rolí -## 🐛 Known Issues +**Member** +- Vlastný profil a nastavenia +- Správa vlastných email účtov +- Vlastné kontakty a emaily +- Inbox -- 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 +## JMAP Integrácia ---- +- **Provider:** Truemail.sk +- **Endpoint:** https://mail.truemail.sk/jmap/ +- **Auth:** Basic Auth (email + heslo) +- **Operácie:** + - Email/query - Vyhľadávanie emailov + - Email/get - Získanie detailov + - Email/set - Vytvorenie emailu + - EmailSubmission/set - Poslanie emailu + - Mailbox/get - Zoznam mailboxov + - Identity/get - Email identity -## 📞 Podpora a Kontakt +## Šifrovanie -Pre technické otázky a bug reporting: -- Vytvor Issue v repozitári -- Email: richard@example.com +**JMAP heslá** - AES-256-GCM +- Format: `iv:authTag:encryptedText` +- Kľúč: `ENCRYPTION_KEY` z .env (32 znakov) +- Funkcie: `encryptPassword()`, `decryptPassword()` ---- - -## 📄 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! 🚀** +**User heslá** - Bcrypt +- 12 rounds (2^12 iterations) +- Funkcie: `hashPassword()`, `comparePassword()` diff --git a/src/app.js b/src/app.js index 3c09133..299a585 100644 --- a/src/app.js +++ b/src/app.js @@ -16,6 +16,7 @@ import authRoutes from './routes/auth.routes.js'; import adminRoutes from './routes/admin.routes.js'; import contactRoutes from './routes/contact.routes.js'; import crmEmailRoutes from './routes/crm-email.routes.js'; +import emailAccountRoutes from './routes/email-account.routes.js'; const app = express(); @@ -70,6 +71,7 @@ app.use('/api/auth', authRoutes); app.use('/api/admin', adminRoutes); app.use('/api/contacts', contactRoutes); app.use('/api/emails', crmEmailRoutes); +app.use('/api/email-accounts', emailAccountRoutes); // Basic route app.get('/', (req, res) => { diff --git a/src/controllers/contact.controller.js b/src/controllers/contact.controller.js index a9ff3ba..9a74d1e 100644 --- a/src/controllers/contact.controller.js +++ b/src/controllers/contact.controller.js @@ -1,16 +1,18 @@ import * as contactService from '../services/contact.service.js'; -import { discoverContactsFromJMAP, getJmapConfig } from '../services/jmap.service.js'; +import { discoverContactsFromJMAP, getJmapConfigFromAccount } from '../services/jmap.service.js'; import { formatErrorResponse } from '../utils/errors.js'; -import { getUserById } from '../services/auth.service.js'; +import * as emailAccountService from '../services/email-account.service.js'; /** * Get all contacts for authenticated user - * GET /api/contacts + * GET /api/contacts?accountId=xxx (optional) */ export const getContacts = async (req, res) => { try { const userId = req.userId; - const contacts = await contactService.getUserContacts(userId); + const { accountId } = req.query; + + const contacts = await contactService.getUserContacts(userId, accountId || null); res.status(200).json({ success: true, @@ -25,28 +27,45 @@ export const getContacts = async (req, res) => { /** * Discover potential contacts from JMAP (email senders) - * GET /api/contacts/discover?search=query&limit=50 + * GET /api/contacts/discover?accountId=xxx&search=query&limit=50 */ export const discoverContacts = async (req, res) => { try { const userId = req.userId; - const { search = '', limit = 50 } = req.query; + const { accountId, search = '', limit = 50 } = req.query; - // Get user to access JMAP config - const user = await getUserById(userId); + console.log('🔍 discoverContacts called:', { userId, accountId, search, limit }); - // 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, - }, - }); + // Get email account (or primary if not specified) + let emailAccount; + if (accountId) { + console.log('📧 Getting email account by ID:', accountId); + emailAccount = await emailAccountService.getEmailAccountWithCredentials(accountId, userId); + console.log('✅ Email account retrieved:', { id: emailAccount.id, email: emailAccount.email }); + } else { + console.log('📧 No accountId provided, getting primary account for user:', userId); + const primaryAccount = await emailAccountService.getPrimaryEmailAccount(userId); + console.log('🔑 Primary account:', primaryAccount ? { id: primaryAccount.id, email: primaryAccount.email } : 'NOT FOUND'); + if (!primaryAccount) { + return res.status(400).json({ + success: false, + error: { + message: 'Najprv musíš pripojiť email účet v Profile', + statusCode: 400, + }, + }); + } + emailAccount = await emailAccountService.getEmailAccountWithCredentials(primaryAccount.id, userId); + console.log('✅ Email account retrieved from primary:', { id: emailAccount.id, email: emailAccount.email }); } - const jmapConfig = getJmapConfig(user); + const jmapConfig = getJmapConfigFromAccount(emailAccount); + console.log('🔧 JMAP Config created:', { + server: jmapConfig.server, + username: jmapConfig.username, + accountId: jmapConfig.accountId, + hasPassword: !!jmapConfig.password + }); const potentialContacts = await discoverContactsFromJMAP( jmapConfig, @@ -61,6 +80,8 @@ export const discoverContacts = async (req, res) => { data: potentialContacts, }); } catch (error) { + console.error('❌ ERROR in discoverContacts:', error); + console.error('Error stack:', error.stack); const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); res.status(error.statusCode || 500).json(errorResponse); } @@ -69,11 +90,15 @@ export const discoverContacts = async (req, res) => { /** * Add a new contact * POST /api/contacts + * Body: { email, name, notes, accountId } */ export const addContact = async (req, res) => { try { const userId = req.userId; - const { email, name = '', notes = '' } = req.body; + console.log('📦 Full req.body:', JSON.stringify(req.body, null, 2)); + const { email, name = '', notes = '', accountId } = req.body; + + console.log('➕ addContact called:', { userId, email, name, accountId }); if (!email) { return res.status(400).json({ @@ -85,11 +110,37 @@ export const addContact = async (req, res) => { }); } - // Get user to access JMAP config - const user = await getUserById(userId); - const jmapConfig = getJmapConfig(user); + // Get email account (or primary if not specified) + let emailAccount; + if (accountId) { + console.log('📧 Using provided accountId:', accountId); + emailAccount = await emailAccountService.getEmailAccountWithCredentials(accountId, userId); + } else { + console.log('📧 No accountId provided, using primary account'); + const primaryAccount = await emailAccountService.getPrimaryEmailAccount(userId); + if (!primaryAccount) { + return res.status(400).json({ + success: false, + error: { + message: 'Najprv musíš pripojiť email účet v Profile', + statusCode: 400, + }, + }); + } + emailAccount = await emailAccountService.getEmailAccountWithCredentials(primaryAccount.id, userId); + console.log('📧 Using primary account:', primaryAccount.id); + } - const contact = await contactService.addContact(userId, jmapConfig, email, name, notes); + const jmapConfig = getJmapConfigFromAccount(emailAccount); + + const contact = await contactService.addContact( + userId, + emailAccount.id, + jmapConfig, + email, + name, + notes + ); res.status(201).json({ success: true, diff --git a/src/controllers/crm-email.controller.js b/src/controllers/crm-email.controller.js index fae4c71..15b2399 100644 --- a/src/controllers/crm-email.controller.js +++ b/src/controllers/crm-email.controller.js @@ -1,17 +1,20 @@ import * as crmEmailService from '../services/crm-email.service.js'; import * as contactService from '../services/contact.service.js'; -import { markEmailAsRead, sendEmail, getJmapConfig, syncEmailsFromSender, searchEmailsJMAP as searchEmailsJMAPService } from '../services/jmap.service.js'; +import * as emailAccountService from '../services/email-account.service.js'; +import { markEmailAsRead, sendEmail, getJmapConfig, getJmapConfigFromAccount, syncEmailsFromSender, searchEmailsJMAP as searchEmailsJMAPService } from '../services/jmap.service.js'; import { formatErrorResponse } from '../utils/errors.js'; import { getUserById } from '../services/auth.service.js'; /** * Get all emails for authenticated user - * GET /api/emails + * GET /api/emails?accountId=xxx (optional) */ export const getEmails = async (req, res) => { try { const userId = req.userId; - const emails = await crmEmailService.getUserEmails(userId); + const { accountId } = req.query; + + const emails = await crmEmailService.getUserEmails(userId, accountId || null); res.status(200).json({ success: true, @@ -48,14 +51,14 @@ export const getThread = async (req, res) => { /** * Search emails - * GET /api/emails/search?q=query + * GET /api/emails/search?q=query&accountId=xxx (accountId optional) */ export const searchEmails = async (req, res) => { try { const userId = req.userId; - const { q } = req.query; + const { q, accountId } = req.query; - const results = await crmEmailService.searchEmails(userId, q); + const results = await crmEmailService.searchEmails(userId, q, accountId || null); res.status(200).json({ success: true, @@ -71,33 +74,24 @@ export const searchEmails = async (req, res) => { /** * Get unread count * GET /api/emails/unread-count + * Returns total unread count and per-account counts */ export const getUnreadCount = async (req, res) => { try { 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, - }); - } + const unreadData = await crmEmailService.getUnreadCount(userId); res.status(200).json({ success: true, data: { - count, - totalUnread: count, - accounts, + count: unreadData.totalUnread, + totalUnread: unreadData.totalUnread, + accounts: unreadData.accounts, lastUpdatedAt: new Date().toISOString(), }, }); } catch (error) { + console.error('❌ ERROR in getUnreadCount:', error); const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); res.status(error.statusCode || 500).json(errorResponse); } @@ -106,23 +100,33 @@ export const getUnreadCount = async (req, res) => { /** * Sync latest emails for all contacts from JMAP * POST /api/emails/sync + * Body: { accountId } (optional - defaults to primary account) */ export const syncEmails = async (req, res) => { try { const userId = req.userId; - const user = await getUserById(userId); + const { accountId } = req.body; - 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, - }, - }); + // Get email account (or primary if not specified) + let emailAccount; + if (accountId) { + emailAccount = await emailAccountService.getEmailAccountWithCredentials(accountId, userId); + } else { + const primaryAccount = await emailAccountService.getPrimaryEmailAccount(userId); + if (!primaryAccount) { + return res.status(400).json({ + success: false, + error: { + message: 'Najprv musíš pripojiť email účet v Profile', + statusCode: 400, + }, + }); + } + emailAccount = await emailAccountService.getEmailAccountWithCredentials(primaryAccount.id, userId); } - const contacts = await contactService.getUserContacts(userId); + // Get contacts for this email account + const contacts = await contactService.getUserContacts(userId, emailAccount.id); if (!contacts.length) { return res.status(200).json({ @@ -132,7 +136,7 @@ export const syncEmails = async (req, res) => { }); } - const jmapConfig = getJmapConfig(user); + const jmapConfig = getJmapConfigFromAccount(emailAccount); let totalSynced = 0; let totalNew = 0; @@ -141,6 +145,7 @@ export const syncEmails = async (req, res) => { const { total, saved } = await syncEmailsFromSender( jmapConfig, userId, + emailAccount.id, contact.id, contact.email, { limit: 50 } @@ -240,11 +245,12 @@ export const markThreadRead = async (req, res) => { /** * Send email reply * POST /api/emails/reply + * Body: { to, subject, body, inReplyTo, threadId, accountId } */ export const replyToEmail = async (req, res) => { try { const userId = req.userId; - const { to, subject, body, inReplyTo = null, threadId = null } = req.body; + const { to, subject, body, inReplyTo = null, threadId = null, accountId } = req.body; if (!to || !subject || !body) { return res.status(400).json({ @@ -256,11 +262,27 @@ export const replyToEmail = async (req, res) => { }); } - // Get user to access JMAP config - const user = await getUserById(userId); - const jmapConfig = getJmapConfig(user); + // Get email account (or primary if not specified) + let emailAccount; + if (accountId) { + emailAccount = await emailAccountService.getEmailAccountWithCredentials(accountId, userId); + } else { + const primaryAccount = await emailAccountService.getPrimaryEmailAccount(userId); + if (!primaryAccount) { + return res.status(400).json({ + success: false, + error: { + message: 'Najprv musíš pripojiť email účet v Profile', + statusCode: 400, + }, + }); + } + emailAccount = await emailAccountService.getEmailAccountWithCredentials(primaryAccount.id, userId); + } - const result = await sendEmail(jmapConfig, userId, to, subject, body, inReplyTo, threadId); + const jmapConfig = getJmapConfigFromAccount(emailAccount); + + const result = await sendEmail(jmapConfig, userId, emailAccount.id, to, subject, body, inReplyTo, threadId); res.status(200).json({ success: true, @@ -297,29 +319,38 @@ export const getContactEmails = async (req, res) => { /** * Search emails using JMAP full-text search - * GET /api/emails/search-jmap?query=text&limit=50&offset=0 + * GET /api/emails/search-jmap?query=text&limit=50&offset=0&accountId=xxx * 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; + const { query = '', limit = 50, offset = 0, accountId } = req.query; - // Get user to access JMAP config - const user = await getUserById(userId); + console.log('🔍 searchEmailsJMAP called:', { userId, query, limit, offset, accountId }); - // 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, - }, - }); + // Get email account (or primary if not specified) + let emailAccount; + if (accountId) { + console.log('📧 Using provided accountId:', accountId); + emailAccount = await emailAccountService.getEmailAccountWithCredentials(accountId, userId); + } else { + console.log('📧 No accountId provided, using primary account'); + const primaryAccount = await emailAccountService.getPrimaryEmailAccount(userId); + if (!primaryAccount) { + return res.status(400).json({ + success: false, + error: { + message: 'Najprv musíš pripojiť email účet v Profile', + statusCode: 400, + }, + }); + } + emailAccount = await emailAccountService.getEmailAccountWithCredentials(primaryAccount.id, userId); + console.log('📧 Using primary account:', primaryAccount.id); } - const jmapConfig = getJmapConfig(user); + const jmapConfig = getJmapConfigFromAccount(emailAccount); const results = await searchEmailsJMAPService( jmapConfig, @@ -335,6 +366,7 @@ export const searchEmailsJMAP = async (req, res) => { data: results, }); } catch (error) { + console.error('❌ ERROR in searchEmailsJMAP:', error); const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); res.status(error.statusCode || 500).json(errorResponse); } diff --git a/src/controllers/email-account.controller.js b/src/controllers/email-account.controller.js new file mode 100644 index 0000000..5a974c1 --- /dev/null +++ b/src/controllers/email-account.controller.js @@ -0,0 +1,174 @@ +import * as emailAccountService from '../services/email-account.service.js'; +import { + logEmailLink, +} from '../services/audit.service.js'; +import { formatErrorResponse } from '../utils/errors.js'; + +/** + * Get all email accounts for logged-in user + * GET /api/email-accounts + */ +export const getEmailAccounts = async (req, res) => { + try { + const userId = req.userId; + const accounts = await emailAccountService.getUserEmailAccounts(userId); + + res.status(200).json({ + success: true, + data: accounts, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Get a specific email account + * GET /api/email-accounts/:id + */ +export const getEmailAccount = async (req, res) => { + try { + const userId = req.userId; + const { id } = req.params; + + const account = await emailAccountService.getEmailAccountById(id, userId); + + res.status(200).json({ + success: true, + data: account, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Create a new email account + * POST /api/email-accounts + */ +export const createEmailAccount = async (req, res) => { + const { email, emailPassword } = req.body; + const userId = req.userId; + const ipAddress = req.ip || req.connection.remoteAddress; + const userAgent = req.headers['user-agent']; + + try { + const account = await emailAccountService.createEmailAccount( + userId, + email, + emailPassword + ); + + // Log email account creation + await logEmailLink(userId, email, ipAddress, userAgent); + + res.status(201).json({ + success: true, + data: account, + message: 'Email účet úspešne pripojený', + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Update email account password + * PATCH /api/email-accounts/:id/password + */ +export const updateEmailAccountPassword = async (req, res) => { + try { + const userId = req.userId; + const { id } = req.params; + const { emailPassword } = req.body; + + const result = await emailAccountService.updateEmailAccountPassword( + id, + userId, + emailPassword + ); + + res.status(200).json({ + success: true, + data: result, + message: 'Heslo k emailovému účtu bolo aktualizované', + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Toggle email account active status + * PATCH /api/email-accounts/:id/status + */ +export const toggleEmailAccountStatus = async (req, res) => { + try { + const userId = req.userId; + const { id } = req.params; + const { isActive } = req.body; + + const result = await emailAccountService.toggleEmailAccountStatus( + id, + userId, + isActive + ); + + res.status(200).json({ + success: true, + data: result, + message: `Email účet ${isActive ? 'aktivovaný' : 'deaktivovaný'}`, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Set email account as primary + * POST /api/email-accounts/:id/set-primary + */ +export const setPrimaryEmailAccount = async (req, res) => { + try { + const userId = req.userId; + const { id } = req.params; + + const result = await emailAccountService.setPrimaryEmailAccount(id, userId); + + res.status(200).json({ + success: true, + data: result, + message: 'Primárny email účet bol nastavený', + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Delete email account + * DELETE /api/email-accounts/:id + */ +export const deleteEmailAccount = async (req, res) => { + try { + const userId = req.userId; + const { id } = req.params; + + const result = await emailAccountService.deleteEmailAccount(id, userId); + + res.status(200).json({ + success: true, + data: result, + message: result.message, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; diff --git a/src/db/migrations/0002_parallel_guardian.sql b/src/db/migrations/0002_parallel_guardian.sql new file mode 100644 index 0000000..8dbc6f9 --- /dev/null +++ b/src/db/migrations/0002_parallel_guardian.sql @@ -0,0 +1,17 @@ +CREATE TABLE "email_accounts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "email" text NOT NULL, + "email_password" text NOT NULL, + "jmap_account_id" text NOT NULL, + "is_primary" boolean DEFAULT false NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "contacts" ADD COLUMN "email_account_id" uuid NOT NULL;--> statement-breakpoint +ALTER TABLE "emails" ADD COLUMN "email_account_id" uuid NOT NULL;--> statement-breakpoint +ALTER TABLE "email_accounts" ADD CONSTRAINT "email_accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "contacts" ADD CONSTRAINT "contacts_email_account_id_email_accounts_id_fk" FOREIGN KEY ("email_account_id") REFERENCES "public"."email_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "emails" ADD CONSTRAINT "emails_email_account_id_email_accounts_id_fk" FOREIGN KEY ("email_account_id") REFERENCES "public"."email_accounts"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/src/db/migrations/meta/0002_snapshot.json b/src/db/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..e32a6ff --- /dev/null +++ b/src/db/migrations/meta/0002_snapshot.json @@ -0,0 +1,600 @@ +{ + "id": "0a729a36-e7a3-488d-b9c5-26392e1cc67d", + "prevId": "1b8c1e0f-8476-470c-a641-b3c350a2c1a4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contacts": { + "name": "contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email_account_id": { + "name": "email_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "contacts_user_id_users_id_fk": { + "name": "contacts_user_id_users_id_fk", + "tableFrom": "contacts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "contacts_email_account_id_email_accounts_id_fk": { + "name": "contacts_email_account_id_email_accounts_id_fk", + "tableFrom": "contacts", + "tableTo": "email_accounts", + "columnsFrom": [ + "email_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_accounts": { + "name": "email_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_password": { + "name": "email_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "jmap_account_id": { + "name": "jmap_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "email_accounts_user_id_users_id_fk": { + "name": "email_accounts_user_id_users_id_fk", + "tableFrom": "email_accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.emails": { + "name": "emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email_account_id": { + "name": "email_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "jmap_id": { + "name": "jmap_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from": { + "name": "from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to": { + "name": "to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "emails_user_id_users_id_fk": { + "name": "emails_user_id_users_id_fk", + "tableFrom": "emails", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "emails_email_account_id_email_accounts_id_fk": { + "name": "emails_email_account_id_email_accounts_id_fk", + "tableFrom": "emails", + "tableTo": "email_accounts", + "columnsFrom": [ + "email_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "emails_contact_id_contacts_id_fk": { + "name": "emails_contact_id_contacts_id_fk", + "tableFrom": "emails", + "tableTo": "contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "emails_jmap_id_unique": { + "name": "emails_jmap_id_unique", + "nullsNotDistinct": false, + "columns": [ + "jmap_id" + ] + }, + "emails_message_id_unique": { + "name": "emails_message_id_unique", + "nullsNotDistinct": false, + "columns": [ + "message_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_password": { + "name": "email_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jmap_account_id": { + "name": "jmap_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "temp_password": { + "name": "temp_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changed_password": { + "name": "changed_password", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "last_login": { + "name": "last_login", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "member" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index d6478b2..372134e 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1763457837858, "tag": "0001_slow_drax", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1763547133084, + "tag": "0002_parallel_guardian", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/migrations/migrate-data-only.js b/src/db/migrations/migrate-data-only.js new file mode 100644 index 0000000..875cea4 --- /dev/null +++ b/src/db/migrations/migrate-data-only.js @@ -0,0 +1,138 @@ +import 'dotenv/config'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import pkg from 'pg'; +const { Pool } = pkg; +import { sql } from 'drizzle-orm'; + +/** + * Data-only migration script to move existing user emails to email_accounts + * Assumes tables already exist from Drizzle migrations + */ + +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 db = drizzle(pool); + +async function migrateData() { + console.log('🚀 Starting data migration to email accounts...\n'); + + try { + // Step 1: Check if email_accounts table exists + console.log('Step 1: Checking email_accounts table...'); + const tableExists = await db.execute(sql` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'email_accounts' + ) + `); + + if (!tableExists.rows[0].exists) { + throw new Error('email_accounts table does not exist. Run Drizzle migrations first.'); + } + console.log('✅ email_accounts table exists\n'); + + // Step 2: Migrate existing user emails to email_accounts + console.log('Step 2: Migrating existing user emails to email_accounts...'); + const usersWithEmail = await db.execute(sql` + SELECT id, email, email_password, jmap_account_id + FROM users + WHERE email IS NOT NULL + AND email_password IS NOT NULL + AND jmap_account_id IS NOT NULL + `); + + console.log(`Found ${usersWithEmail.rows.length} users with email accounts`); + + for (const user of usersWithEmail.rows) { + // Check if already migrated + const existing = await db.execute(sql` + SELECT id FROM email_accounts + WHERE user_id = ${user.id} AND email = ${user.email} + `); + + if (existing.rows.length > 0) { + console.log(` ⏩ Skipping user ${user.id}: ${user.email} (already migrated)`); + continue; + } + + await db.execute(sql` + INSERT INTO email_accounts (user_id, email, email_password, jmap_account_id, is_primary, is_active) + VALUES (${user.id}, ${user.email}, ${user.email_password}, ${user.jmap_account_id}, true, true) + `); + console.log(` ✓ Migrated email account for user ${user.id}: ${user.email}`); + } + console.log('✅ User emails migrated\n'); + + // Step 3: Update existing contacts with email_account_id + console.log('Step 3: Updating existing contacts with email_account_id...'); + const contactsNeedUpdate = await db.execute(sql` + SELECT COUNT(*) as count FROM contacts WHERE email_account_id IS NULL + `); + + if (parseInt(contactsNeedUpdate.rows[0].count) > 0) { + await db.execute(sql` + UPDATE contacts + SET email_account_id = ( + SELECT ea.id + FROM email_accounts ea + WHERE ea.user_id = contacts.user_id + AND ea.is_primary = true + LIMIT 1 + ) + WHERE email_account_id IS NULL + `); + console.log(`✅ Updated ${contactsNeedUpdate.rows[0].count} contacts\n`); + } else { + console.log('✅ No contacts to update\n'); + } + + // Step 4: Update existing emails with email_account_id + console.log('Step 4: Updating existing emails with email_account_id...'); + const emailsNeedUpdate = await db.execute(sql` + SELECT COUNT(*) as count FROM emails WHERE email_account_id IS NULL + `); + + if (parseInt(emailsNeedUpdate.rows[0].count) > 0) { + await db.execute(sql` + UPDATE emails + SET email_account_id = ( + SELECT ea.id + FROM email_accounts ea + WHERE ea.user_id = emails.user_id + AND ea.is_primary = true + LIMIT 1 + ) + WHERE email_account_id IS NULL + `); + console.log(`✅ Updated ${emailsNeedUpdate.rows[0].count} emails\n`); + } else { + console.log('✅ No emails to update\n'); + } + + // Summary + console.log('🎉 Data migration completed successfully!\n'); + console.log('Summary:'); + console.log(` - Email accounts migrated: ${usersWithEmail.rows.length}`); + console.log(` - Contacts updated: ${contactsNeedUpdate.rows[0].count}`); + console.log(` - Emails updated: ${emailsNeedUpdate.rows[0].count}`); + + } catch (error) { + console.error('❌ Migration failed:', error); + throw error; + } finally { + await pool.end(); + } +} + +// Run migration +migrateData().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/src/db/migrations/migrate-to-email-accounts.js b/src/db/migrations/migrate-to-email-accounts.js new file mode 100644 index 0000000..f93d89b --- /dev/null +++ b/src/db/migrations/migrate-to-email-accounts.js @@ -0,0 +1,179 @@ +import 'dotenv/config'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import pkg from 'pg'; +const { Pool } = pkg; +import { users, emailAccounts, contacts, emails } from '../schema.js'; +import { sql } from 'drizzle-orm'; + +/** + * Migration script to move from single email per user to multiple email accounts + * + * Steps: + * 1. Create email_accounts table + * 2. Migrate existing user emails to email_accounts (as primary) + * 3. Add email_account_id to contacts and emails tables + * 4. Update existing contacts and emails to reference new email accounts + */ + +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 db = drizzle(pool); + +async function migrateToEmailAccounts() { + console.log('🚀 Starting migration to email accounts...\n'); + + try { + // Step 1: Create email_accounts table + console.log('Step 1: Creating email_accounts table...'); + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "email_accounts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "email" text NOT NULL, + "email_password" text NOT NULL, + "jmap_account_id" text NOT NULL, + "is_primary" boolean DEFAULT false NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL + ) + `); + + await db.execute(sql` + ALTER TABLE "email_accounts" + ADD CONSTRAINT "email_accounts_user_id_users_id_fk" + FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") + ON DELETE cascade ON UPDATE no action + `); + console.log('✅ email_accounts table created\n'); + + // Step 2: Migrate existing user emails to email_accounts + console.log('Step 2: Migrating existing user emails to email_accounts...'); + const usersWithEmail = await db.execute(sql` + SELECT id, email, email_password, jmap_account_id + FROM users + WHERE email IS NOT NULL + AND email_password IS NOT NULL + AND jmap_account_id IS NOT NULL + `); + + console.log(`Found ${usersWithEmail.rows.length} users with email accounts`); + + for (const user of usersWithEmail.rows) { + await db.execute(sql` + INSERT INTO email_accounts (user_id, email, email_password, jmap_account_id, is_primary, is_active) + VALUES (${user.id}, ${user.email}, ${user.email_password}, ${user.jmap_account_id}, true, true) + `); + console.log(` ✓ Migrated email account for user ${user.id}: ${user.email}`); + } + console.log('✅ User emails migrated\n'); + + // Step 3: Add email_account_id column to contacts (nullable first) + console.log('Step 3: Adding email_account_id to contacts table...'); + await db.execute(sql` + ALTER TABLE contacts + ADD COLUMN IF NOT EXISTS email_account_id uuid + `); + console.log('✅ Column added to contacts\n'); + + // Step 4: Update existing contacts with email_account_id + console.log('Step 4: Updating existing contacts with email_account_id...'); + await db.execute(sql` + UPDATE contacts + SET email_account_id = ( + SELECT ea.id + FROM email_accounts ea + WHERE ea.user_id = contacts.user_id + AND ea.is_primary = true + LIMIT 1 + ) + WHERE email_account_id IS NULL + `); + + const contactsUpdated = await db.execute(sql` + SELECT COUNT(*) as count FROM contacts WHERE email_account_id IS NOT NULL + `); + console.log(`✅ Updated ${contactsUpdated.rows[0].count} contacts\n`); + + // Step 5: Make email_account_id NOT NULL and add foreign key + console.log('Step 5: Adding constraints to contacts...'); + await db.execute(sql` + ALTER TABLE contacts + ALTER COLUMN email_account_id SET NOT NULL + `); + + await db.execute(sql` + ALTER TABLE contacts + ADD CONSTRAINT "contacts_email_account_id_email_accounts_id_fk" + FOREIGN KEY ("email_account_id") REFERENCES "public"."email_accounts"("id") + ON DELETE cascade ON UPDATE no action + `); + console.log('✅ Constraints added to contacts\n'); + + // Step 6: Add email_account_id column to emails (nullable first) + console.log('Step 6: Adding email_account_id to emails table...'); + await db.execute(sql` + ALTER TABLE emails + ADD COLUMN IF NOT EXISTS email_account_id uuid + `); + console.log('✅ Column added to emails\n'); + + // Step 7: Update existing emails with email_account_id + console.log('Step 7: Updating existing emails with email_account_id...'); + await db.execute(sql` + UPDATE emails + SET email_account_id = ( + SELECT ea.id + FROM email_accounts ea + WHERE ea.user_id = emails.user_id + AND ea.is_primary = true + LIMIT 1 + ) + WHERE email_account_id IS NULL + `); + + const emailsUpdated = await db.execute(sql` + SELECT COUNT(*) as count FROM emails WHERE email_account_id IS NOT NULL + `); + console.log(`✅ Updated ${emailsUpdated.rows[0].count} emails\n`); + + // Step 8: Make email_account_id NOT NULL and add foreign key + console.log('Step 8: Adding constraints to emails...'); + await db.execute(sql` + ALTER TABLE emails + ALTER COLUMN email_account_id SET NOT NULL + `); + + await db.execute(sql` + ALTER TABLE emails + ADD CONSTRAINT "emails_email_account_id_email_accounts_id_fk" + FOREIGN KEY ("email_account_id") REFERENCES "public"."email_accounts"("id") + ON DELETE cascade ON UPDATE no action + `); + console.log('✅ Constraints added to emails\n'); + + console.log('🎉 Migration completed successfully!\n'); + console.log('Summary:'); + console.log(` - Email accounts created: ${usersWithEmail.rows.length}`); + console.log(` - Contacts updated: ${contactsUpdated.rows[0].count}`); + console.log(` - Emails updated: ${emailsUpdated.rows[0].count}`); + + } catch (error) { + console.error('❌ Migration failed:', error); + throw error; + } finally { + await pool.end(); + } +} + +// Run migration +migrateToEmailAccounts().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/src/db/schema.js b/src/db/schema.js index 3d85741..19f628a 100644 --- a/src/db/schema.js +++ b/src/db/schema.js @@ -21,6 +21,19 @@ export const users = pgTable('users', { updatedAt: timestamp('updated_at').defaultNow().notNull(), }); +// Email Accounts table - viacero emailových účtov pre jedného usera +export const emailAccounts = pgTable('email_accounts', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + email: text('email').notNull(), + emailPassword: text('email_password').notNull(), // Heslo k emailovému účtu (encrypted) + jmapAccountId: text('jmap_account_id').notNull(), // JMAP account ID z truemail + isPrimary: boolean('is_primary').default(false).notNull(), // primárny email účet + isActive: boolean('is_active').default(true).notNull(), // či je účet aktívny + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + // Audit logs - kompletný audit trail všetkých akcií export const auditLogs = pgTable('audit_logs', { id: uuid('id').primaryKey().defaultRandom(), @@ -41,6 +54,7 @@ export const auditLogs = pgTable('audit_logs', { export const contacts = pgTable('contacts', { id: uuid('id').primaryKey().defaultRandom(), userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + emailAccountId: uuid('email_account_id').references(() => emailAccounts.id, { onDelete: 'cascade' }).notNull(), email: text('email').notNull(), name: text('name'), notes: text('notes'), @@ -53,6 +67,7 @@ export const contacts = pgTable('contacts', { export const emails = pgTable('emails', { id: uuid('id').primaryKey().defaultRandom(), userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + emailAccountId: uuid('email_account_id').references(() => emailAccounts.id, { onDelete: 'cascade' }).notNull(), contactId: uuid('contact_id').references(() => contacts.id, { onDelete: 'cascade' }), jmapId: text('jmap_id').unique(), messageId: text('message_id').unique(), diff --git a/src/routes/contact.routes.js b/src/routes/contact.routes.js index e9e9536..2f04422 100644 --- a/src/routes/contact.routes.js +++ b/src/routes/contact.routes.js @@ -27,6 +27,7 @@ router.post( email: z.string().email('Neplatný formát emailu'), name: z.string().optional(), notes: z.string().optional(), + accountId: z.string().uuid().optional(), }) ), contactController.addContact diff --git a/src/routes/email-account.routes.js b/src/routes/email-account.routes.js new file mode 100644 index 0000000..471ab47 --- /dev/null +++ b/src/routes/email-account.routes.js @@ -0,0 +1,71 @@ +import express from 'express'; +import * as emailAccountController from '../controllers/email-account.controller.js'; +import { authenticate } from '../middlewares/auth/authMiddleware.js'; +import { validateBody, validateParams } from '../middlewares/security/validateInput.js'; +import { sensitiveOperationLimiter } from '../middlewares/security/rateLimiter.js'; +import { + createEmailAccountSchema, + updateEmailAccountSchema, +} from '../validators/email-account.validators.js'; +import { z } from 'zod'; + +const router = express.Router(); + +// All email account routes require authentication +router.use(authenticate); + +/** + * Email account management + */ + +// Get all email accounts for logged-in user +router.get('/', emailAccountController.getEmailAccounts); + +// Get specific email account +router.get( + '/:id', + validateParams(z.object({ id: z.string().uuid() })), + emailAccountController.getEmailAccount +); + +// Create new email account +router.post( + '/', + sensitiveOperationLimiter, + validateBody(createEmailAccountSchema), + emailAccountController.createEmailAccount +); + +// Update email account password +router.patch( + '/:id/password', + validateParams(z.object({ id: z.string().uuid() })), + validateBody(z.object({ emailPassword: z.string().min(1) })), + sensitiveOperationLimiter, + emailAccountController.updateEmailAccountPassword +); + +// Toggle email account status +router.patch( + '/:id/status', + validateParams(z.object({ id: z.string().uuid() })), + validateBody(z.object({ isActive: z.boolean() })), + emailAccountController.toggleEmailAccountStatus +); + +// Set email account as primary +router.post( + '/:id/set-primary', + validateParams(z.object({ id: z.string().uuid() })), + emailAccountController.setPrimaryEmailAccount +); + +// Delete email account +router.delete( + '/:id', + validateParams(z.object({ id: z.string().uuid() })), + sensitiveOperationLimiter, + emailAccountController.deleteEmailAccount +); + +export default router; diff --git a/src/services/contact.service.js b/src/services/contact.service.js index 2eb483f..342c7f3 100644 --- a/src/services/contact.service.js +++ b/src/services/contact.service.js @@ -6,12 +6,19 @@ import { syncEmailsFromSender } from './jmap.service.js'; /** * Get all contacts for a user + * If emailAccountId is provided, filter by that account, otherwise return all */ -export const getUserContacts = async (userId) => { +export const getUserContacts = async (userId, emailAccountId = null) => { + const conditions = [eq(contacts.userId, userId)]; + + if (emailAccountId) { + conditions.push(eq(contacts.emailAccountId, emailAccountId)); + } + const userContacts = await db .select() .from(contacts) - .where(eq(contacts.userId, userId)) + .where(and(...conditions)) .orderBy(desc(contacts.addedAt)); return userContacts; @@ -20,12 +27,18 @@ export const getUserContacts = async (userId) => { /** * Add a new contact and sync their emails */ -export const addContact = async (userId, jmapConfig, email, name = '', notes = '') => { - // Check if contact already exists +export const addContact = async (userId, emailAccountId, jmapConfig, email, name = '', notes = '') => { + // Check if contact already exists for this email account const [existing] = await db .select() .from(contacts) - .where(and(eq(contacts.userId, userId), eq(contacts.email, email))) + .where( + and( + eq(contacts.userId, userId), + eq(contacts.emailAccountId, emailAccountId), + eq(contacts.email, email) + ) + ) .limit(1); if (existing) { @@ -37,6 +50,7 @@ export const addContact = async (userId, jmapConfig, email, name = '', notes = ' .insert(contacts) .values({ userId, + emailAccountId, email, name: name || email.split('@')[0], notes: notes || null, @@ -45,7 +59,7 @@ export const addContact = async (userId, jmapConfig, email, name = '', notes = ' // Sync emails from this sender try { - await syncEmailsFromSender(jmapConfig, userId, newContact.id, email); + await syncEmailsFromSender(jmapConfig, userId, emailAccountId, newContact.id, email); } catch (error) { console.error('Failed to sync emails for new contact:', error); // Don't throw - contact was created successfully diff --git a/src/services/crm-email.service.js b/src/services/crm-email.service.js index 72cdc40..2e40453 100644 --- a/src/services/crm-email.service.js +++ b/src/services/crm-email.service.js @@ -5,8 +5,15 @@ import { NotFoundError } from '../utils/errors.js'; /** * Get all emails for a user (only from added contacts) + * If emailAccountId is provided, filter by that account */ -export const getUserEmails = async (userId) => { +export const getUserEmails = async (userId, emailAccountId = null) => { + const conditions = [eq(emails.userId, userId)]; + + if (emailAccountId) { + conditions.push(eq(emails.emailAccountId, emailAccountId)); + } + const userEmails = await db .select({ id: emails.id, @@ -21,6 +28,7 @@ export const getUserEmails = async (userId) => { isRead: emails.isRead, date: emails.date, createdAt: emails.createdAt, + emailAccountId: emails.emailAccountId, contact: { id: contacts.id, email: contacts.email, @@ -29,7 +37,7 @@ export const getUserEmails = async (userId) => { }) .from(emails) .leftJoin(contacts, eq(emails.contactId, contacts.id)) - .where(eq(emails.userId, userId)) + .where(and(...conditions)) .orderBy(desc(emails.date)); return userEmails; @@ -54,27 +62,31 @@ export const getEmailThread = async (userId, threadId) => { /** * Search emails (from, to, subject) + * If emailAccountId is provided, filter by that account */ -export const searchEmails = async (userId, query) => { +export const searchEmails = async (userId, query, emailAccountId = null) => { if (!query || query.trim().length < 2) { throw new Error('Search term must be at least 2 characters'); } const searchPattern = `%${query}%`; + const conditions = [ + eq(emails.userId, userId), + or( + like(emails.from, searchPattern), + like(emails.to, searchPattern), + like(emails.subject, searchPattern) + ), + ]; + + if (emailAccountId) { + conditions.push(eq(emails.emailAccountId, emailAccountId)); + } const results = await db .select() .from(emails) - .where( - and( - eq(emails.userId, userId), - or( - like(emails.from, searchPattern), - like(emails.to, searchPattern), - like(emails.subject, searchPattern) - ) - ) - ) + .where(and(...conditions)) .orderBy(desc(emails.date)) .limit(50); @@ -83,14 +95,34 @@ export const searchEmails = async (userId, query) => { /** * Get unread email count + * Returns total count and counts per email account */ export const getUnreadCount = async (userId) => { - const result = await db + // Get total unread count + const totalResult = await db .select({ count: sql`count(*)::int` }) .from(emails) .where(and(eq(emails.userId, userId), eq(emails.isRead, false))); - return result[0]?.count || 0; + const totalUnread = totalResult[0]?.count || 0; + + // Get unread count per email account + const accountCounts = await db + .select({ + emailAccountId: emails.emailAccountId, + count: sql`count(*)::int`, + }) + .from(emails) + .where(and(eq(emails.userId, userId), eq(emails.isRead, false))) + .groupBy(emails.emailAccountId); + + return { + totalUnread, + accounts: accountCounts.map((ac) => ({ + emailAccountId: ac.emailAccountId, + unreadCount: ac.count, + })), + }; }; /** diff --git a/src/services/email-account.service.js b/src/services/email-account.service.js new file mode 100644 index 0000000..561d853 --- /dev/null +++ b/src/services/email-account.service.js @@ -0,0 +1,282 @@ +import { eq, and } from 'drizzle-orm'; +import { db } from '../config/database.js'; +import { emailAccounts, users } from '../db/schema.js'; +import { encryptPassword, decryptPassword } from '../utils/password.js'; +import { validateJmapCredentials } from './email.service.js'; +import { + NotFoundError, + ValidationError, + ConflictError, + AuthenticationError, +} from '../utils/errors.js'; + +/** + * Get all email accounts for a user + */ +export const getUserEmailAccounts = async (userId) => { + const accounts = await db + .select({ + id: emailAccounts.id, + userId: emailAccounts.userId, + email: emailAccounts.email, + jmapAccountId: emailAccounts.jmapAccountId, + isPrimary: emailAccounts.isPrimary, + isActive: emailAccounts.isActive, + createdAt: emailAccounts.createdAt, + updatedAt: emailAccounts.updatedAt, + }) + .from(emailAccounts) + .where(eq(emailAccounts.userId, userId)) + .orderBy(emailAccounts.isPrimary, emailAccounts.createdAt); + + return accounts; +}; + +/** + * Get a specific email account by ID + */ +export const getEmailAccountById = async (accountId, userId) => { + const [account] = await db + .select() + .from(emailAccounts) + .where( + and( + eq(emailAccounts.id, accountId), + eq(emailAccounts.userId, userId) + ) + ) + .limit(1); + + if (!account) { + throw new NotFoundError('Email účet nenájdený'); + } + + return account; +}; + +/** + * Get user's primary email account + */ +export const getPrimaryEmailAccount = async (userId) => { + const [account] = await db + .select() + .from(emailAccounts) + .where( + and( + eq(emailAccounts.userId, userId), + eq(emailAccounts.isPrimary, true) + ) + ) + .limit(1); + + return account || null; +}; + +/** + * Create a new email account with JMAP validation + */ +export const createEmailAccount = async (userId, email, emailPassword) => { + // Check if email already exists for this user + const [existing] = await db + .select() + .from(emailAccounts) + .where( + and( + eq(emailAccounts.userId, userId), + eq(emailAccounts.email, email) + ) + ) + .limit(1); + + if (existing) { + throw new ConflictError('Tento email účet už je pripojený'); + } + + // Validate JMAP credentials and get account ID + let jmapAccountId; + try { + const validation = await validateJmapCredentials(email, emailPassword); + jmapAccountId = validation.accountId; + } catch (error) { + throw new AuthenticationError( + 'Nepodarilo sa pripojiť k emailovému účtu. Skontrolujte prihlasovacie údaje.' + ); + } + + // Encrypt password + const encryptedPassword = encryptPassword(emailPassword); + + // Check if this is the first email account for this user + const existingAccounts = await getUserEmailAccounts(userId); + const isFirst = existingAccounts.length === 0; + + // Create email account + const [newAccount] = await db + .insert(emailAccounts) + .values({ + userId, + email, + emailPassword: encryptedPassword, + jmapAccountId, + isPrimary: isFirst, // First account is automatically primary + isActive: true, + }) + .returning(); + + return { + id: newAccount.id, + email: newAccount.email, + jmapAccountId: newAccount.jmapAccountId, + isPrimary: newAccount.isPrimary, + isActive: newAccount.isActive, + createdAt: newAccount.createdAt, + }; +}; + +/** + * Update email account password + */ +export const updateEmailAccountPassword = async (accountId, userId, newPassword) => { + const account = await getEmailAccountById(accountId, userId); + + // Validate new JMAP credentials + try { + await validateJmapCredentials(account.email, newPassword); + } catch (error) { + throw new AuthenticationError( + 'Nepodarilo sa overiť nové heslo. Skontrolujte prihlasovacie údaje.' + ); + } + + // Encrypt password + const encryptedPassword = encryptPassword(newPassword); + + // Update password + const [updated] = await db + .update(emailAccounts) + .set({ + emailPassword: encryptedPassword, + updatedAt: new Date(), + }) + .where(eq(emailAccounts.id, accountId)) + .returning(); + + return { + id: updated.id, + email: updated.email, + updatedAt: updated.updatedAt, + }; +}; + +/** + * Toggle email account active status + */ +export const toggleEmailAccountStatus = async (accountId, userId, isActive) => { + const account = await getEmailAccountById(accountId, userId); + + // Cannot deactivate primary account + if (account.isPrimary && !isActive) { + throw new ValidationError('Nemôžete deaktivovať primárny email účet'); + } + + const [updated] = await db + .update(emailAccounts) + .set({ + isActive, + updatedAt: new Date(), + }) + .where(eq(emailAccounts.id, accountId)) + .returning(); + + return { + id: updated.id, + isActive: updated.isActive, + }; +}; + +/** + * Set an email account as primary + */ +export const setPrimaryEmailAccount = async (accountId, userId) => { + const account = await getEmailAccountById(accountId, userId); + + // Remove primary flag from all accounts + await db + .update(emailAccounts) + .set({ isPrimary: false, updatedAt: new Date() }) + .where(eq(emailAccounts.userId, userId)); + + // Set new primary account + const [updated] = await db + .update(emailAccounts) + .set({ + isPrimary: true, + isActive: true, // Primary account must be active + updatedAt: new Date(), + }) + .where(eq(emailAccounts.id, accountId)) + .returning(); + + return { + id: updated.id, + email: updated.email, + isPrimary: updated.isPrimary, + }; +}; + +/** + * Delete an email account + * NOTE: This will cascade delete all associated contacts and emails + */ +export const deleteEmailAccount = async (accountId, userId) => { + const account = await getEmailAccountById(accountId, userId); + + // Cannot delete primary account if it's the only one + if (account.isPrimary) { + const allAccounts = await getUserEmailAccounts(userId); + if (allAccounts.length === 1) { + throw new ValidationError('Nemôžete zmazať posledný email účet'); + } + + // If deleting primary account, make another account primary + const otherAccount = allAccounts.find(acc => acc.id !== accountId); + if (otherAccount) { + await setPrimaryEmailAccount(otherAccount.id, userId); + } + } + + // Delete account (will cascade to contacts and emails) + await db + .delete(emailAccounts) + .where(eq(emailAccounts.id, accountId)); + + return { + message: 'Email účet bol úspešne odstránený', + deletedAccountId: accountId, + }; +}; + +/** + * Get email account with decrypted password (for JMAP operations) + */ +export const getEmailAccountWithCredentials = async (accountId, userId) => { + console.log('🔐 getEmailAccountWithCredentials called:', { accountId, userId }); + const account = await getEmailAccountById(accountId, userId); + console.log('📦 Account retrieved:', { + id: account.id, + email: account.email, + hasPassword: !!account.emailPassword, + passwordLength: account.emailPassword?.length + }); + + const decryptedPassword = decryptPassword(account.emailPassword); + console.log('🔓 Password decrypted, length:', decryptedPassword?.length); + + return { + id: account.id, + email: account.email, + emailPassword: decryptedPassword, + jmapAccountId: account.jmapAccountId, + isActive: account.isActive, + }; +}; diff --git a/src/services/jmap.service.js b/src/services/jmap.service.js index c131362..1aca275 100644 --- a/src/services/jmap.service.js +++ b/src/services/jmap.service.js @@ -11,7 +11,7 @@ import { decryptPassword } from '../utils/password.js'; */ /** - * Get JMAP configuration for user + * Get JMAP configuration for user (legacy - for backward compatibility) */ export const getJmapConfig = (user) => { if (!user.email || !user.emailPassword || !user.jmapAccountId) { @@ -29,6 +29,24 @@ export const getJmapConfig = (user) => { }; }; +/** + * Get JMAP configuration from email account object + * NOTE: Expects emailPassword to be already decrypted (from getEmailAccountWithCredentials) + */ +export const getJmapConfigFromAccount = (emailAccount) => { + if (!emailAccount.email || !emailAccount.emailPassword || !emailAccount.jmapAccountId) { + throw new Error('Email account je neúplný'); + } + + // Password is already decrypted by getEmailAccountWithCredentials + return { + server: process.env.JMAP_SERVER || 'https://mail.truemail.sk/jmap/', + username: emailAccount.email, + password: emailAccount.emailPassword, + accountId: emailAccount.jmapAccountId, + }; +}; + /** * Make JMAP API request */ @@ -329,6 +347,7 @@ export const searchEmailsJMAP = async (jmapConfig, userId, query, limit = 50, of export const syncEmailsFromSender = async ( jmapConfig, userId, + emailAccountId, contactId, senderEmail, options = {} @@ -336,7 +355,7 @@ export const syncEmailsFromSender = async ( const { limit = 500 } = options; try { - logger.info(`Syncing emails from sender: ${senderEmail}`); + logger.info(`Syncing emails from sender: ${senderEmail} for account ${emailAccountId}`); // Query all emails from this sender const queryResponse = await jmapRequest(jmapConfig, [ @@ -402,6 +421,12 @@ export const syncEmailsFromSender = async ( const inReplyTo = Array.isArray(email.inReplyTo) ? email.inReplyTo[0] : email.inReplyTo; const isRead = email.keywords && email.keywords['$seen'] === true; + // Skip emails without from or to (malformed data) + if (!fromEmail && !toEmail) { + logger.warn(`Skipping email ${messageId} - missing both from and to fields`); + continue; + } + // Skip if already exists const [existing] = await db .select() @@ -416,6 +441,7 @@ export const syncEmailsFromSender = async ( // Save email await db.insert(emails).values({ userId, + emailAccountId, contactId, jmapId: email.id, messageId, @@ -527,7 +553,7 @@ export const markEmailAsRead = async (jmapConfig, userId, jmapId, isRead) => { /** * Send email via JMAP */ -export const sendEmail = async (jmapConfig, userId, to, subject, body, inReplyTo = null, threadId = null) => { +export const sendEmail = async (jmapConfig, userId, emailAccountId, to, subject, body, inReplyTo = null, threadId = null) => { try { logger.info(`Sending email to: ${to}`); @@ -615,6 +641,7 @@ export const sendEmail = async (jmapConfig, userId, to, subject, body, inReplyTo await db.insert(emails).values({ userId, + emailAccountId, contactId: null, // Will be linked later if recipient is a contact jmapId: createdEmailId, messageId, diff --git a/src/validators/email-account.validators.js b/src/validators/email-account.validators.js new file mode 100644 index 0000000..4ea3e58 --- /dev/null +++ b/src/validators/email-account.validators.js @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +// Create email account schema +export const createEmailAccountSchema = z.object({ + email: z + .string({ + required_error: 'Email je povinný', + }) + .email('Neplatný formát emailu') + .max(255, 'Email môže mať maximálne 255 znakov'), + emailPassword: z + .string({ + required_error: 'Heslo k emailu je povinné', + }) + .min(1, 'Heslo k emailu nemôže byť prázdne'), +}); + +// Update email account schema +export const updateEmailAccountSchema = z.object({ + emailPassword: z.string().min(1, 'Heslo k emailu nemôže byť prázdne').optional(), + isActive: z.boolean().optional(), +}); + +// Set primary account schema +export const setPrimaryAccountSchema = z.object({ + accountId: z.string().uuid('Neplatný formát account ID'), +});