From 176d3c5fec3d5914c2c75da04a8163619ab2c671 Mon Sep 17 00:00:00 2001 From: richardtekula Date: Fri, 5 Dec 2025 11:11:41 +0100 Subject: [PATCH] Refactor: Split jmap.service.js into modules and update Slovak translations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split 753-line jmap.service.js into modular structure: - jmap/config.js: JMAP configuration functions - jmap/client.js: Base JMAP requests (jmapRequest, getMailboxes, getIdentities) - jmap/discovery.js: Contact discovery from JMAP - jmap/search.js: Email search functionality - jmap/sync.js: Email synchronization - jmap/operations.js: Email operations (markAsRead, sendEmail) - jmap/index.js: Re-exports for backward compatibility - Update all imports across codebase to use new module structure - Translate remaining English error/log messages to Slovak: - email.service.js: JMAP validation messages - admin.service.js: Email account creation error - audit.service.js: Audit event logging error - timesheet.service.js: File deletion error - database.js: Database error message 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/config/database.js | 2 +- src/controllers/contact.controller.js | 2 +- src/controllers/crm-email.controller.js | 2 +- src/services/admin.service.js | 2 +- src/services/audit.service.js | 2 +- src/services/contact.service.js | 2 +- src/services/email.service.js | 6 +- src/services/jmap.service.js | 753 ------------------------ src/services/jmap/client.js | 74 +++ src/services/jmap/config.js | 38 ++ src/services/jmap/discovery.js | 97 +++ src/services/jmap/index.js | 30 + src/services/jmap/operations.js | 210 +++++++ src/services/jmap/search.js | 135 +++++ src/services/jmap/sync.js | 211 +++++++ src/services/timesheet.service.js | 2 +- 16 files changed, 805 insertions(+), 763 deletions(-) delete mode 100644 src/services/jmap.service.js create mode 100644 src/services/jmap/client.js create mode 100644 src/services/jmap/config.js create mode 100644 src/services/jmap/discovery.js create mode 100644 src/services/jmap/index.js create mode 100644 src/services/jmap/operations.js create mode 100644 src/services/jmap/search.js create mode 100644 src/services/jmap/sync.js diff --git a/src/config/database.js b/src/config/database.js index 0e068a0..476adcb 100644 --- a/src/config/database.js +++ b/src/config/database.js @@ -17,7 +17,7 @@ const pool = new Pool({ // Note: Connection logging handled in index.js to avoid circular dependencies pool.on('error', (err) => { - console.error('Unexpected database error:', err); + console.error('Neočakávaná chyba databázy:', err); process.exit(-1); }); diff --git a/src/controllers/contact.controller.js b/src/controllers/contact.controller.js index cae9719..22a91e9 100644 --- a/src/controllers/contact.controller.js +++ b/src/controllers/contact.controller.js @@ -1,5 +1,5 @@ import * as contactService from '../services/contact.service.js'; -import { discoverContactsFromJMAP, getJmapConfigFromAccount } from '../services/jmap.service.js'; +import { discoverContactsFromJMAP, getJmapConfigFromAccount } from '../services/jmap/index.js'; import * as emailAccountService from '../services/email-account.service.js'; /** diff --git a/src/controllers/crm-email.controller.js b/src/controllers/crm-email.controller.js index f5b597b..a28185b 100644 --- a/src/controllers/crm-email.controller.js +++ b/src/controllers/crm-email.controller.js @@ -1,7 +1,7 @@ import * as crmEmailService from '../services/crm-email.service.js'; import * as contactService from '../services/contact.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 { markEmailAsRead, sendEmail, getJmapConfig, getJmapConfigFromAccount, syncEmailsFromSender, searchEmailsJMAP as searchEmailsJMAPService } from '../services/jmap/index.js'; import { getUserById } from '../services/auth.service.js'; import { logger } from '../utils/logger.js'; diff --git a/src/services/admin.service.js b/src/services/admin.service.js index c892833..60f4422 100644 --- a/src/services/admin.service.js +++ b/src/services/admin.service.js @@ -68,7 +68,7 @@ export const createUser = async (username, firstName, lastName, role, email, ema shared: newEmailAccount.shared, }; } catch (emailError) { - logger.error('Failed to create email account:', { error: emailError.message }); + logger.error('Nepodarilo sa vytvoriť email účet:', { error: emailError.message }); } } diff --git a/src/services/audit.service.js b/src/services/audit.service.js index 6c7fbf3..8761ee6 100644 --- a/src/services/audit.service.js +++ b/src/services/audit.service.js @@ -35,7 +35,7 @@ export const logAuditEvent = async ({ `${action} on ${resource}${resourceId ? ` (${resourceId})` : ''} ${success ? 'SUCCESS' : 'FAILED'}${userId ? ` by user ${userId}` : ''}` ); } catch (error) { - logger.error('Failed to log audit event', error); + logger.error('Nepodarilo sa zaznamenať audit event', error); } }; diff --git a/src/services/contact.service.js b/src/services/contact.service.js index c0398fe..ccb3cce 100644 --- a/src/services/contact.service.js +++ b/src/services/contact.service.js @@ -2,7 +2,7 @@ import { db } from '../config/database.js'; import { contacts, emails, companies } from '../db/schema.js'; import { eq, and, desc, or, ne } from 'drizzle-orm'; import { NotFoundError, ConflictError } from '../utils/errors.js'; -import { syncEmailsFromSender } from './jmap.service.js'; +import { syncEmailsFromSender } from './jmap/index.js'; /** * Get all contacts for an email account diff --git a/src/services/email.service.js b/src/services/email.service.js index 6dbb5a1..9078485 100644 --- a/src/services/email.service.js +++ b/src/services/email.service.js @@ -11,7 +11,7 @@ export const validateJmapCredentials = async (email, password) => { const jmapServer = process.env.JMAP_SERVER; if (!jmapServer) { - throw new Error('JMAP_SERVER environment variable is not configured'); + throw new Error('JMAP_SERVER premenná prostredia nie je nastavená'); } try { @@ -33,14 +33,14 @@ export const validateJmapCredentials = async (email, password) => { throw new Error('Nepodarilo sa získať JMAP account ID'); } - logger.success(`JMAP credentials validated for ${email}, accountId: ${accountId}`); + logger.success(`JMAP prihlasovacie údaje overené pre ${email}, accountId: ${accountId}`); return { accountId, session, }; } catch (error) { - logger.error(`Failed to validate JMAP credentials for ${email}`, error); + logger.error(`Nepodarilo sa overiť JMAP prihlasovacie údaje pre ${email}`, error); if (error.response?.status === 401) { throw new Error('Nesprávne prihlasovacie údaje k emailu'); diff --git a/src/services/jmap.service.js b/src/services/jmap.service.js deleted file mode 100644 index 7b527df..0000000 --- a/src/services/jmap.service.js +++ /dev/null @@ -1,753 +0,0 @@ -import axios from 'axios'; -import { logger } from '../utils/logger.js'; -import { db } from '../config/database.js'; -import { emails, contacts } from '../db/schema.js'; -import { eq, and, or, desc, sql } from 'drizzle-orm'; -import { decryptPassword } from '../utils/password.js'; - -/** - * JMAP Service - integrácia s Truemail.sk JMAP serverom - * Syncuje emaily, označuje ako prečítané, posiela odpovede - */ - -/** - * Get JMAP configuration for user (legacy - for backward compatibility) - */ -export const getJmapConfig = (user) => { - if (!user.email || !user.emailPassword || !user.jmapAccountId) { - throw new Error('User nemá nastavený email account'); - } - - // Decrypt email password for JMAP API - const decryptedPassword = decryptPassword(user.emailPassword); - - return { - server: process.env.JMAP_SERVER || 'https://mail.truemail.sk/jmap/', - username: user.email, - password: decryptedPassword, - accountId: user.jmapAccountId, - }; -}; - -/** - * 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 - */ -export const jmapRequest = async (jmapConfig, methodCalls) => { - try { - const response = await axios.post( - jmapConfig.server, - { - using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], - methodCalls, - }, - { - auth: { - username: jmapConfig.username, - password: jmapConfig.password, - }, - headers: { - 'Content-Type': 'application/json', - }, - } - ); - return response.data; - } catch (error) { - logger.error('JMAP request failed', error); - throw new Error(`JMAP request failed: ${error.message}`); - } -}; - -/** - * Get user's mailboxes (Inbox, Sent, etc.) - */ -export const getMailboxes = async (jmapConfig) => { - try { - const response = await jmapRequest(jmapConfig, [ - [ - 'Mailbox/get', - { - accountId: jmapConfig.accountId, - }, - 'mailbox1', - ], - ]); - - return response.methodResponses[0][1].list; - } catch (error) { - logger.error('Failed to get mailboxes', error); - throw error; - } -}; - -/** - * Get user identities (for sending emails) - */ -export const getIdentities = async (jmapConfig) => { - try { - const response = await jmapRequest(jmapConfig, [ - [ - 'Identity/get', - { - accountId: jmapConfig.accountId, - }, - 'identity1', - ], - ]); - - return response.methodResponses[0][1].list; - } catch (error) { - logger.error('Failed to get identities', error); - throw error; - } -}; - -/** - * Discover potential contacts from JMAP (no DB storage) - * Returns list of unique senders - */ -export const discoverContactsFromJMAP = async (jmapConfig, emailAccountId, searchTerm = '', limit = 50) => { - try { - logger.info(`Discovering contacts from JMAP (search: "${searchTerm}")`); - - // Query emails, sorted by date - const queryResponse = await jmapRequest(jmapConfig, [ - [ - 'Email/query', - { - accountId: jmapConfig.accountId, - filter: searchTerm - ? { - operator: 'OR', - conditions: [{ from: searchTerm }, { subject: searchTerm }], - } - : undefined, - sort: [{ property: 'receivedAt', isAscending: false }], - limit: 200, // Fetch more to get diverse senders - }, - 'query1', - ], - ]); - - const emailIds = queryResponse.methodResponses?.[0]?.[1]?.ids; - - if (!emailIds || emailIds.length === 0) { - return []; - } - - // Fetch email metadata (no bodies) - const getResponse = await jmapRequest(jmapConfig, [ - [ - 'Email/get', - { - accountId: jmapConfig.accountId, - ids: emailIds, - properties: ['from', 'subject', 'receivedAt', 'preview'], - }, - 'get1', - ], - ]); - - const emailsList = getResponse.methodResponses[0][1].list; - - // Get existing contacts for this email account - const existingContacts = await db - .select() - .from(contacts) - .where(eq(contacts.emailAccountId, emailAccountId)); - - const contactEmailsSet = new Set(existingContacts.map((c) => c.email.toLowerCase())); - - // Group by sender (unique senders) - const sendersMap = new Map(); - const myEmail = jmapConfig.username.toLowerCase(); - - emailsList.forEach((email) => { - const fromEmail = email.from?.[0]?.email; - - if (!fromEmail || fromEmail.toLowerCase() === myEmail) { - return; // Skip my own emails - } - - // Keep only the most recent email from each sender - if (!sendersMap.has(fromEmail)) { - sendersMap.set(fromEmail, { - email: fromEmail, - name: email.from?.[0]?.name || fromEmail.split('@')[0], - latestSubject: email.subject || '(No Subject)', - latestDate: email.receivedAt, - snippet: email.preview || '', - isContact: contactEmailsSet.has(fromEmail.toLowerCase()), - }); - } - }); - - const senders = Array.from(sendersMap.values()) - .sort((a, b) => new Date(b.latestDate) - new Date(a.latestDate)) - .slice(0, limit); - - logger.success(`Found ${senders.length} unique senders`); - return senders; - } catch (error) { - logger.error('Failed to discover contacts from JMAP', error); - throw error; - } -}; - -/** - * Search emails using JMAP full-text search - * Searches in: from, to, subject, and email body - * Returns list of unique senders grouped by email address - */ -export const searchEmailsJMAP = async (jmapConfig, emailAccountId, query, limit = 50, offset = 0) => { - try { - logger.info(`Searching emails in JMAP (query: "${query}", limit: ${limit}, offset: ${offset})`); - - if (!query || query.trim().length < 1) { - return []; - } - - // Use JMAP search with wildcards for substring matching - // Add wildcards (*) to enable partial matching: "ander" -> "*ander*" - const wildcardQuery = query.includes('*') ? query : `*${query}*`; - - let queryResponse; - - try { - // Try with 'text' filter first (full-text search if supported) - queryResponse = await jmapRequest(jmapConfig, [ - [ - 'Email/query', - { - accountId: jmapConfig.accountId, - filter: { - text: wildcardQuery, // Full-text search with wildcards - }, - sort: [{ property: 'receivedAt', isAscending: false }], - position: offset, - limit: 200, - }, - 'query1', - ], - ]); - } catch (textFilterError) { - // If 'text' filter fails, fall back to OR conditions with wildcards - logger.warn('Text filter failed, falling back to OR conditions', textFilterError); - queryResponse = await jmapRequest(jmapConfig, [ - [ - 'Email/query', - { - accountId: jmapConfig.accountId, - filter: { - operator: 'OR', - conditions: [ - { from: wildcardQuery }, - { to: wildcardQuery }, - { subject: wildcardQuery }, - ], - }, - sort: [{ property: 'receivedAt', isAscending: false }], - position: offset, - limit: 200, - }, - 'query1', - ], - ]); - } - - const emailIds = queryResponse.methodResponses?.[0]?.[1]?.ids; - - if (!emailIds || emailIds.length === 0) { - logger.info('No emails found matching search query'); - return []; - } - - logger.info(`Found ${emailIds.length} emails matching query`); - - // Fetch email metadata - const getResponse = await jmapRequest(jmapConfig, [ - [ - 'Email/get', - { - accountId: jmapConfig.accountId, - ids: emailIds, - properties: ['from', 'to', 'subject', 'receivedAt', 'preview'], - }, - 'get1', - ], - ]); - - const emailsList = getResponse.methodResponses[0][1].list; - - // Get existing contacts for this email account - const existingContacts = await db - .select() - .from(contacts) - .where(eq(contacts.emailAccountId, emailAccountId)); - - const contactEmailsSet = new Set(existingContacts.map((c) => c.email.toLowerCase())); - - // Group by sender (unique senders) - const sendersMap = new Map(); - const myEmail = jmapConfig.username.toLowerCase(); - - emailsList.forEach((email) => { - const fromEmail = email.from?.[0]?.email; - - if (!fromEmail || fromEmail.toLowerCase() === myEmail) { - return; // Skip my own emails - } - - // Keep only the most recent email from each sender - if (!sendersMap.has(fromEmail)) { - sendersMap.set(fromEmail, { - email: fromEmail, - name: email.from?.[0]?.name || fromEmail.split('@')[0], - latestSubject: email.subject || '(No Subject)', - latestDate: email.receivedAt, - snippet: email.preview || '', - isContact: contactEmailsSet.has(fromEmail.toLowerCase()), - }); - } - }); - - // Convert to array, sort by date, and apply limit - const senders = Array.from(sendersMap.values()) - .sort((a, b) => new Date(b.latestDate) - new Date(a.latestDate)) - .slice(0, limit); - - logger.success(`Found ${senders.length} unique senders matching query`); - return senders; - } catch (error) { - logger.error('Failed to search emails in JMAP', error); - throw error; - } -}; - -/** - * Sync emails with a specific contact (bidirectional: both from and to) - * ONLY syncs from Inbox and Sent mailboxes (no Archive, Trash, Drafts) - * ONLY syncs recent emails (last 30 days by default) - */ -export const syncEmailsFromSender = async ( - jmapConfig, - emailAccountId, - contactId, - senderEmail, - options = {} -) => { - const { limit = 50, daysBack = 30 } = options; - - try { - logger.info(`Syncing emails with contact: ${senderEmail} for account ${emailAccountId}`); - - const [contact] = await db - .select({ companyId: contacts.companyId }) - .from(contacts) - .where(eq(contacts.id, contactId)) - .limit(1); - - // Get Inbox and Sent mailboxes ONLY - const mailboxes = await getMailboxes(jmapConfig); - const inboxMailbox = mailboxes.find(m => m.role === 'inbox' || m.name === 'Inbox' || m.name === 'INBOX'); - const sentMailbox = mailboxes.find(m => m.role === 'sent' || m.name === 'Sent'); - - if (!inboxMailbox) { - logger.error('Inbox mailbox not found'); - throw new Error('Priečinok Inbox nebol nájdený'); - } - - logger.info(`Using mailboxes: Inbox (${inboxMailbox.id})${sentMailbox ? `, Sent (${sentMailbox.id})` : ''}`); - - // Calculate date threshold (only emails from last X days) - const dateThreshold = new Date(); - dateThreshold.setDate(dateThreshold.getDate() - daysBack); - const dateThresholdISO = dateThreshold.toISOString(); - - logger.info(`Filtering: last ${daysBack} days, from Inbox/Sent only, for ${senderEmail}`); - - // Query emails FROM the contact - const queryFromResponse = await jmapRequest(jmapConfig, [ - [ - 'Email/query', - { - accountId: jmapConfig.accountId, - filter: { - operator: 'AND', - conditions: [ - { inMailbox: inboxMailbox.id }, - { from: senderEmail }, - { after: dateThresholdISO } - ] - }, - sort: [{ property: 'receivedAt', isAscending: false }], - limit, - }, - 'query1', - ], - ]); - - const fromEmailIds = queryFromResponse.methodResponses[0][1].ids || []; - logger.info(`Found ${fromEmailIds.length} emails FROM ${senderEmail}`); - - // Query emails TO the contact (from Sent folder if it exists) - let toEmailIds = []; - if (sentMailbox) { - const queryToResponse = await jmapRequest(jmapConfig, [ - [ - 'Email/query', - { - accountId: jmapConfig.accountId, - filter: { - operator: 'AND', - conditions: [ - { inMailbox: sentMailbox.id }, - { to: senderEmail }, - { after: dateThresholdISO } - ] - }, - sort: [{ property: 'receivedAt', isAscending: false }], - limit, - }, - 'query2', - ], - ]); - - toEmailIds = queryToResponse.methodResponses[0][1].ids || []; - logger.info(`Found ${toEmailIds.length} emails TO ${senderEmail}`); - } - - // Combine and deduplicate - const emailIds = [...new Set([...fromEmailIds, ...toEmailIds])]; - logger.info(`Total unique emails: ${emailIds.length}`); - - if (emailIds.length === 0) { - return { total: 0, saved: 0 }; - } - - // Fetch full email details - const getResponse = await jmapRequest(jmapConfig, [ - [ - 'Email/get', - { - accountId: jmapConfig.accountId, - ids: emailIds, - properties: [ - 'id', - 'messageId', - 'threadId', - 'inReplyTo', - 'from', - 'to', - 'subject', - 'receivedAt', - 'textBody', - 'htmlBody', - 'bodyValues', - 'keywords', - ], - fetchTextBodyValues: true, - fetchHTMLBodyValues: true, - }, - 'get1', - ], - ]); - - const emailsList = getResponse.methodResponses[0][1].list; - let savedCount = 0; - - // Save emails to database - for (const email of emailsList) { - try { - const fromEmail = email.from?.[0]?.email; - const toEmail = email.to?.[0]?.email; - const messageId = Array.isArray(email.messageId) ? email.messageId[0] : email.messageId; - 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; - } - - // VALIDATION: Email must belong to this contact - // Email belongs to contact if: - // - from === senderEmail (received email FROM contact) - // - to === senderEmail (sent email TO contact) - const belongsToContact = - fromEmail?.toLowerCase() === senderEmail.toLowerCase() || - toEmail?.toLowerCase() === senderEmail.toLowerCase(); - - if (!belongsToContact) { - logger.warn(`Skipping email ${messageId} - does not belong to contact ${senderEmail} (from: ${fromEmail}, to: ${toEmail})`); - continue; - } - - // Skip if already exists - const [existing] = await db - .select() - .from(emails) - .where(eq(emails.messageId, messageId)) - .limit(1); - - if (existing) { - continue; - } - - // Save email - await db.insert(emails).values({ - emailAccountId, - contactId, - companyId: contact?.companyId || null, - jmapId: email.id, - messageId, - threadId: email.threadId || messageId, - inReplyTo: inReplyTo || null, - from: fromEmail, - to: toEmail, - subject: email.subject || '(No Subject)', - body: - email.bodyValues?.[email.textBody?.[0]?.partId]?.value || - email.bodyValues?.[email.htmlBody?.[0]?.partId]?.value || - '(Empty message)', - date: email.receivedAt ? new Date(email.receivedAt) : new Date(), - isRead, - sentByUserId: null, // Prijatý email, nie odpoveď - }); - - savedCount++; - } catch (error) { - logger.error(`Error saving email ${email.messageId}`, error); - } - } - - logger.success(`Synced ${savedCount} new emails with ${senderEmail}`); - return { total: emailsList.length, saved: savedCount }; - } catch (error) { - logger.error('Failed to sync emails with contact', error); - throw error; - } -}; - -/** - * Mark email as read/unread in JMAP and local DB - */ -export const markEmailAsRead = async (jmapConfig, userId, jmapId, isRead) => { - try { - logger.info(`Marking email ${jmapId} as ${isRead ? 'read' : 'unread'}`); - - // Get current keywords - const getResponse = await jmapRequest(jmapConfig, [ - [ - 'Email/get', - { - accountId: jmapConfig.accountId, - ids: [jmapId], - properties: ['keywords', 'mailboxIds'], - }, - 'get1', - ], - ]); - - const email = getResponse.methodResponses[0][1].list?.[0]; - - if (!email) { - // Update local DB even if JMAP fails - await db.update(emails).set({ isRead }).where(eq(emails.jmapId, jmapId)); - throw new Error('Email nebol nájdený v JMAP'); - } - - // Build new keywords - const newKeywords = { ...email.keywords }; - if (isRead) { - newKeywords['$seen'] = true; - } else { - delete newKeywords['$seen']; - } - - // Update in JMAP - const updateResponse = await jmapRequest(jmapConfig, [ - [ - 'Email/set', - { - accountId: jmapConfig.accountId, - update: { - [jmapId]: { - keywords: newKeywords, - }, - }, - }, - 'set1', - ], - ]); - - const updateResult = updateResponse.methodResponses[0][1]; - - // Check if update succeeded - if (updateResult.notUpdated?.[jmapId]) { - logger.error('Failed to update email in JMAP', updateResult.notUpdated[jmapId]); - } - - // Update in local DB - await db.update(emails).set({ isRead }).where(eq(emails.jmapId, jmapId)); - - logger.success(`Email ${jmapId} marked as ${isRead ? 'read' : 'unread'}`); - return { success: true }; - } catch (error) { - logger.error('Error marking email as read', error); - - // Still try to update local DB - try { - await db.update(emails).set({ isRead }).where(eq(emails.jmapId, jmapId)); - } catch (dbError) { - logger.error('Failed to update DB', dbError); - } - - throw error; - } -}; - -/** - * Send email via JMAP - */ -export const sendEmail = async (jmapConfig, userId, emailAccountId, to, subject, body, inReplyTo = null, threadId = null) => { - try { - logger.info(`Sending email to: ${to}`); - - // Get mailboxes - const mailboxes = await getMailboxes(jmapConfig); - const sentMailbox = mailboxes.find((m) => m.role === 'sent' || m.name === 'Sent'); - - if (!sentMailbox) { - throw new Error('Priečinok Odoslané nebol nájdený'); - } - - // Create email draft - const createResponse = await jmapRequest(jmapConfig, [ - [ - 'Email/set', - { - accountId: jmapConfig.accountId, - create: { - draft: { - mailboxIds: { - [sentMailbox.id]: true, - }, - keywords: { - $draft: true, - }, - from: [{ email: jmapConfig.username }], - to: [{ email: to }], - subject: subject, - textBody: [{ partId: 'body', type: 'text/plain' }], - bodyValues: { - body: { - value: body, - }, - }, - inReplyTo: inReplyTo ? [inReplyTo] : null, - references: inReplyTo ? [inReplyTo] : null, - }, - }, - }, - 'set1', - ], - ]); - - const createdEmailId = createResponse.methodResponses[0][1].created?.draft?.id; - - if (!createdEmailId) { - throw new Error('Nepodarilo sa vytvoriť koncept emailu'); - } - - // Get user identity - const identities = await getIdentities(jmapConfig); - const identity = identities.find((i) => i.email === jmapConfig.username) || identities[0]; - - if (!identity) { - throw new Error('Nenašla sa identita pre odosielanie emailov'); - } - - // Submit the email - const submitResponse = await jmapRequest(jmapConfig, [ - [ - 'EmailSubmission/set', - { - accountId: jmapConfig.accountId, - create: { - submission: { - emailId: createdEmailId, - identityId: identity.id, - }, - }, - }, - 'submit1', - ], - ]); - - const submissionId = submitResponse.methodResponses[0][1].created?.submission?.id; - - if (!submissionId) { - throw new Error('Nepodarilo sa odoslať email'); - } - - logger.success(`Email sent successfully to ${to}`); - - // Find contact by recipient email address to properly link the sent email - const [recipientContact] = await db - .select() - .from(contacts) - .where( - and( - eq(contacts.emailAccountId, emailAccountId), - eq(contacts.email, to) - ) - ) - .limit(1); - - // Save sent email to database - const messageId = `<${Date.now()}.${Math.random().toString(36).substr(2, 9)}@${jmapConfig.username.split('@')[1]}>`; - - await db.insert(emails).values({ - emailAccountId, - contactId: recipientContact?.id || null, // Link to contact if recipient is in contacts - companyId: recipientContact?.companyId || null, - jmapId: createdEmailId, - messageId, - threadId: threadId || messageId, - inReplyTo: inReplyTo || null, - from: jmapConfig.username, - to, - subject, - body, - date: new Date(), - isRead: true, // Sent emails are always read - sentByUserId: userId, // Track who sent this email - }); - - logger.info(`Sent email linked to contact: ${recipientContact ? recipientContact.id : 'none (not in contacts)'}`); - - return { success: true, messageId }; - } catch (error) { - logger.error('Error sending email', error); - throw error; - } -}; diff --git a/src/services/jmap/client.js b/src/services/jmap/client.js new file mode 100644 index 0000000..ab22830 --- /dev/null +++ b/src/services/jmap/client.js @@ -0,0 +1,74 @@ +import axios from 'axios'; +import { logger } from '../../utils/logger.js'; + +/** + * Make JMAP API request + */ +export const jmapRequest = async (jmapConfig, methodCalls) => { + try { + const response = await axios.post( + jmapConfig.server, + { + using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], + methodCalls, + }, + { + auth: { + username: jmapConfig.username, + password: jmapConfig.password, + }, + headers: { + 'Content-Type': 'application/json', + }, + } + ); + return response.data; + } catch (error) { + logger.error('JMAP požiadavka zlyhala', error); + throw new Error(`JMAP požiadavka zlyhala: ${error.message}`); + } +}; + +/** + * Get user's mailboxes (Inbox, Sent, etc.) + */ +export const getMailboxes = async (jmapConfig) => { + try { + const response = await jmapRequest(jmapConfig, [ + [ + 'Mailbox/get', + { + accountId: jmapConfig.accountId, + }, + 'mailbox1', + ], + ]); + + return response.methodResponses[0][1].list; + } catch (error) { + logger.error('Nepodarilo sa získať priečinky', error); + throw error; + } +}; + +/** + * Get user identities (for sending emails) + */ +export const getIdentities = async (jmapConfig) => { + try { + const response = await jmapRequest(jmapConfig, [ + [ + 'Identity/get', + { + accountId: jmapConfig.accountId, + }, + 'identity1', + ], + ]); + + return response.methodResponses[0][1].list; + } catch (error) { + logger.error('Nepodarilo sa získať identity', error); + throw error; + } +}; diff --git a/src/services/jmap/config.js b/src/services/jmap/config.js new file mode 100644 index 0000000..25e33be --- /dev/null +++ b/src/services/jmap/config.js @@ -0,0 +1,38 @@ +import { decryptPassword } from '../../utils/password.js'; + +/** + * Get JMAP configuration for user (legacy - for backward compatibility) + */ +export const getJmapConfig = (user) => { + if (!user.email || !user.emailPassword || !user.jmapAccountId) { + throw new Error('Používateľ nemá nastavený email účet'); + } + + // Decrypt email password for JMAP API + const decryptedPassword = decryptPassword(user.emailPassword); + + return { + server: process.env.JMAP_SERVER || 'https://mail.truemail.sk/jmap/', + username: user.email, + password: decryptedPassword, + accountId: user.jmapAccountId, + }; +}; + +/** + * 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 účet 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, + }; +}; diff --git a/src/services/jmap/discovery.js b/src/services/jmap/discovery.js new file mode 100644 index 0000000..853bca0 --- /dev/null +++ b/src/services/jmap/discovery.js @@ -0,0 +1,97 @@ +import { logger } from '../../utils/logger.js'; +import { db } from '../../config/database.js'; +import { contacts } from '../../db/schema.js'; +import { eq } from 'drizzle-orm'; +import { jmapRequest } from './client.js'; + +/** + * Discover potential contacts from JMAP (no DB storage) + * Returns list of unique senders + */ +export const discoverContactsFromJMAP = async (jmapConfig, emailAccountId, searchTerm = '', limit = 50) => { + try { + logger.info(`Objavovanie kontaktov z JMAP (hľadanie: "${searchTerm}")`); + + // Query emails, sorted by date + const queryResponse = await jmapRequest(jmapConfig, [ + [ + 'Email/query', + { + accountId: jmapConfig.accountId, + filter: searchTerm + ? { + operator: 'OR', + conditions: [{ from: searchTerm }, { subject: searchTerm }], + } + : undefined, + sort: [{ property: 'receivedAt', isAscending: false }], + limit: 200, // Fetch more to get diverse senders + }, + 'query1', + ], + ]); + + const emailIds = queryResponse.methodResponses?.[0]?.[1]?.ids; + + if (!emailIds || emailIds.length === 0) { + return []; + } + + // Fetch email metadata (no bodies) + const getResponse = await jmapRequest(jmapConfig, [ + [ + 'Email/get', + { + accountId: jmapConfig.accountId, + ids: emailIds, + properties: ['from', 'subject', 'receivedAt', 'preview'], + }, + 'get1', + ], + ]); + + const emailsList = getResponse.methodResponses[0][1].list; + + // Get existing contacts for this email account + const existingContacts = await db + .select() + .from(contacts) + .where(eq(contacts.emailAccountId, emailAccountId)); + + const contactEmailsSet = new Set(existingContacts.map((c) => c.email.toLowerCase())); + + // Group by sender (unique senders) + const sendersMap = new Map(); + const myEmail = jmapConfig.username.toLowerCase(); + + emailsList.forEach((email) => { + const fromEmail = email.from?.[0]?.email; + + if (!fromEmail || fromEmail.toLowerCase() === myEmail) { + return; // Skip my own emails + } + + // Keep only the most recent email from each sender + if (!sendersMap.has(fromEmail)) { + sendersMap.set(fromEmail, { + email: fromEmail, + name: email.from?.[0]?.name || fromEmail.split('@')[0], + latestSubject: email.subject || '(Bez predmetu)', + latestDate: email.receivedAt, + snippet: email.preview || '', + isContact: contactEmailsSet.has(fromEmail.toLowerCase()), + }); + } + }); + + const senders = Array.from(sendersMap.values()) + .sort((a, b) => new Date(b.latestDate) - new Date(a.latestDate)) + .slice(0, limit); + + logger.success(`Nájdených ${senders.length} unikátnych odosielateľov`); + return senders; + } catch (error) { + logger.error('Nepodarilo sa objaviť kontakty z JMAP', error); + throw error; + } +}; diff --git a/src/services/jmap/index.js b/src/services/jmap/index.js new file mode 100644 index 0000000..dbeade1 --- /dev/null +++ b/src/services/jmap/index.js @@ -0,0 +1,30 @@ +/** + * JMAP Service - integrácia s Truemail.sk JMAP serverom + * Syncuje emaily, označuje ako prečítané, posiela odpovede + * + * Modulárna štruktúra: + * - config.js: Konfigurácia JMAP pripojenia + * - client.js: Základné JMAP požiadavky (request, mailboxes, identities) + * - discovery.js: Objavovanie kontaktov + * - search.js: Vyhľadávanie emailov + * - sync.js: Synchronizácia emailov + * - operations.js: Operácie s emailami (čítanie, odosielanie) + */ + +// Config +export { getJmapConfig, getJmapConfigFromAccount } from './config.js'; + +// Client +export { jmapRequest, getMailboxes, getIdentities } from './client.js'; + +// Discovery +export { discoverContactsFromJMAP } from './discovery.js'; + +// Search +export { searchEmailsJMAP } from './search.js'; + +// Sync +export { syncEmailsFromSender } from './sync.js'; + +// Operations +export { markEmailAsRead, sendEmail } from './operations.js'; diff --git a/src/services/jmap/operations.js b/src/services/jmap/operations.js new file mode 100644 index 0000000..fcfc059 --- /dev/null +++ b/src/services/jmap/operations.js @@ -0,0 +1,210 @@ +import { logger } from '../../utils/logger.js'; +import { db } from '../../config/database.js'; +import { emails, contacts } from '../../db/schema.js'; +import { eq, and } from 'drizzle-orm'; +import { jmapRequest, getMailboxes, getIdentities } from './client.js'; + +/** + * Mark email as read/unread in JMAP and local DB + */ +export const markEmailAsRead = async (jmapConfig, userId, jmapId, isRead) => { + try { + logger.info(`Označujem email ${jmapId} ako ${isRead ? 'prečítaný' : 'neprečítaný'}`); + + // Get current keywords + const getResponse = await jmapRequest(jmapConfig, [ + [ + 'Email/get', + { + accountId: jmapConfig.accountId, + ids: [jmapId], + properties: ['keywords', 'mailboxIds'], + }, + 'get1', + ], + ]); + + const email = getResponse.methodResponses[0][1].list?.[0]; + + if (!email) { + // Update local DB even if JMAP fails + await db.update(emails).set({ isRead }).where(eq(emails.jmapId, jmapId)); + throw new Error('Email nebol nájdený v JMAP'); + } + + // Build new keywords + const newKeywords = { ...email.keywords }; + if (isRead) { + newKeywords['$seen'] = true; + } else { + delete newKeywords['$seen']; + } + + // Update in JMAP + const updateResponse = await jmapRequest(jmapConfig, [ + [ + 'Email/set', + { + accountId: jmapConfig.accountId, + update: { + [jmapId]: { + keywords: newKeywords, + }, + }, + }, + 'set1', + ], + ]); + + const updateResult = updateResponse.methodResponses[0][1]; + + // Check if update succeeded + if (updateResult.notUpdated?.[jmapId]) { + logger.error('Nepodarilo sa aktualizovať email v JMAP', updateResult.notUpdated[jmapId]); + } + + // Update in local DB + await db.update(emails).set({ isRead }).where(eq(emails.jmapId, jmapId)); + + logger.success(`Email ${jmapId} označený ako ${isRead ? 'prečítaný' : 'neprečítaný'}`); + return { success: true }; + } catch (error) { + logger.error('Chyba pri označovaní emailu ako prečítaného', error); + + // Still try to update local DB + try { + await db.update(emails).set({ isRead }).where(eq(emails.jmapId, jmapId)); + } catch (dbError) { + logger.error('Nepodarilo sa aktualizovať databázu', dbError); + } + + throw error; + } +}; + +/** + * Send email via JMAP + */ +export const sendEmail = async (jmapConfig, userId, emailAccountId, to, subject, body, inReplyTo = null, threadId = null) => { + try { + logger.info(`Odosielam email na: ${to}`); + + // Get mailboxes + const mailboxes = await getMailboxes(jmapConfig); + const sentMailbox = mailboxes.find((m) => m.role === 'sent' || m.name === 'Sent'); + + if (!sentMailbox) { + throw new Error('Priečinok Odoslané nebol nájdený'); + } + + // Create email draft + const createResponse = await jmapRequest(jmapConfig, [ + [ + 'Email/set', + { + accountId: jmapConfig.accountId, + create: { + draft: { + mailboxIds: { + [sentMailbox.id]: true, + }, + keywords: { + $draft: true, + }, + from: [{ email: jmapConfig.username }], + to: [{ email: to }], + subject: subject, + textBody: [{ partId: 'body', type: 'text/plain' }], + bodyValues: { + body: { + value: body, + }, + }, + inReplyTo: inReplyTo ? [inReplyTo] : null, + references: inReplyTo ? [inReplyTo] : null, + }, + }, + }, + 'set1', + ], + ]); + + const createdEmailId = createResponse.methodResponses[0][1].created?.draft?.id; + + if (!createdEmailId) { + throw new Error('Nepodarilo sa vytvoriť koncept emailu'); + } + + // Get user identity + const identities = await getIdentities(jmapConfig); + const identity = identities.find((i) => i.email === jmapConfig.username) || identities[0]; + + if (!identity) { + throw new Error('Nenašla sa identita pre odosielanie emailov'); + } + + // Submit the email + const submitResponse = await jmapRequest(jmapConfig, [ + [ + 'EmailSubmission/set', + { + accountId: jmapConfig.accountId, + create: { + submission: { + emailId: createdEmailId, + identityId: identity.id, + }, + }, + }, + 'submit1', + ], + ]); + + const submissionId = submitResponse.methodResponses[0][1].created?.submission?.id; + + if (!submissionId) { + throw new Error('Nepodarilo sa odoslať email'); + } + + logger.success(`Email úspešne odoslaný na ${to}`); + + // Find contact by recipient email address to properly link the sent email + const [recipientContact] = await db + .select() + .from(contacts) + .where( + and( + eq(contacts.emailAccountId, emailAccountId), + eq(contacts.email, to) + ) + ) + .limit(1); + + // Save sent email to database + const messageId = `<${Date.now()}.${Math.random().toString(36).substr(2, 9)}@${jmapConfig.username.split('@')[1]}>`; + + await db.insert(emails).values({ + emailAccountId, + contactId: recipientContact?.id || null, // Link to contact if recipient is in contacts + companyId: recipientContact?.companyId || null, + jmapId: createdEmailId, + messageId, + threadId: threadId || messageId, + inReplyTo: inReplyTo || null, + from: jmapConfig.username, + to, + subject, + body, + date: new Date(), + isRead: true, // Sent emails are always read + sentByUserId: userId, // Track who sent this email + }); + + logger.info(`Odoslaný email prepojený s kontaktom: ${recipientContact ? recipientContact.id : 'žiadny (nie je v kontaktoch)'}`); + + return { success: true, messageId }; + } catch (error) { + logger.error('Chyba pri odosielaní emailu', error); + throw error; + } +}; diff --git a/src/services/jmap/search.js b/src/services/jmap/search.js new file mode 100644 index 0000000..e07968d --- /dev/null +++ b/src/services/jmap/search.js @@ -0,0 +1,135 @@ +import { logger } from '../../utils/logger.js'; +import { db } from '../../config/database.js'; +import { contacts } from '../../db/schema.js'; +import { eq } from 'drizzle-orm'; +import { jmapRequest } from './client.js'; + +/** + * Search emails using JMAP full-text search + * Searches in: from, to, subject, and email body + * Returns list of unique senders grouped by email address + */ +export const searchEmailsJMAP = async (jmapConfig, emailAccountId, query, limit = 50, offset = 0) => { + try { + logger.info(`Vyhľadávanie emailov v JMAP (dopyt: "${query}", limit: ${limit}, offset: ${offset})`); + + if (!query || query.trim().length < 1) { + return []; + } + + // Use JMAP search with wildcards for substring matching + // Add wildcards (*) to enable partial matching: "ander" -> "*ander*" + const wildcardQuery = query.includes('*') ? query : `*${query}*`; + + let queryResponse; + + try { + // Try with 'text' filter first (full-text search if supported) + queryResponse = await jmapRequest(jmapConfig, [ + [ + 'Email/query', + { + accountId: jmapConfig.accountId, + filter: { + text: wildcardQuery, // Full-text search with wildcards + }, + sort: [{ property: 'receivedAt', isAscending: false }], + position: offset, + limit: 200, + }, + 'query1', + ], + ]); + } catch (textFilterError) { + // If 'text' filter fails, fall back to OR conditions with wildcards + logger.warn('Textový filter zlyhal, prepínam na OR podmienky', textFilterError); + queryResponse = await jmapRequest(jmapConfig, [ + [ + 'Email/query', + { + accountId: jmapConfig.accountId, + filter: { + operator: 'OR', + conditions: [ + { from: wildcardQuery }, + { to: wildcardQuery }, + { subject: wildcardQuery }, + ], + }, + sort: [{ property: 'receivedAt', isAscending: false }], + position: offset, + limit: 200, + }, + 'query1', + ], + ]); + } + + const emailIds = queryResponse.methodResponses?.[0]?.[1]?.ids; + + if (!emailIds || emailIds.length === 0) { + logger.info('Nenašli sa žiadne emaily zodpovedajúce dopytu'); + return []; + } + + logger.info(`Nájdených ${emailIds.length} emailov zodpovedajúcich dopytu`); + + // Fetch email metadata + const getResponse = await jmapRequest(jmapConfig, [ + [ + 'Email/get', + { + accountId: jmapConfig.accountId, + ids: emailIds, + properties: ['from', 'to', 'subject', 'receivedAt', 'preview'], + }, + 'get1', + ], + ]); + + const emailsList = getResponse.methodResponses[0][1].list; + + // Get existing contacts for this email account + const existingContacts = await db + .select() + .from(contacts) + .where(eq(contacts.emailAccountId, emailAccountId)); + + const contactEmailsSet = new Set(existingContacts.map((c) => c.email.toLowerCase())); + + // Group by sender (unique senders) + const sendersMap = new Map(); + const myEmail = jmapConfig.username.toLowerCase(); + + emailsList.forEach((email) => { + const fromEmail = email.from?.[0]?.email; + + if (!fromEmail || fromEmail.toLowerCase() === myEmail) { + return; // Skip my own emails + } + + // Keep only the most recent email from each sender + if (!sendersMap.has(fromEmail)) { + sendersMap.set(fromEmail, { + email: fromEmail, + name: email.from?.[0]?.name || fromEmail.split('@')[0], + latestSubject: email.subject || '(Bez predmetu)', + latestDate: email.receivedAt, + snippet: email.preview || '', + isContact: contactEmailsSet.has(fromEmail.toLowerCase()), + }); + } + }); + + // Convert to array, sort by date, and apply limit + const senders = Array.from(sendersMap.values()) + .sort((a, b) => new Date(b.latestDate) - new Date(a.latestDate)) + .slice(0, limit); + + logger.success(`Nájdených ${senders.length} unikátnych odosielateľov zodpovedajúcich dopytu`); + return senders; + } catch (error) { + logger.error('Nepodarilo sa vyhľadať emaily v JMAP', error); + throw error; + } +}; diff --git a/src/services/jmap/sync.js b/src/services/jmap/sync.js new file mode 100644 index 0000000..af00796 --- /dev/null +++ b/src/services/jmap/sync.js @@ -0,0 +1,211 @@ +import { logger } from '../../utils/logger.js'; +import { db } from '../../config/database.js'; +import { emails, contacts } from '../../db/schema.js'; +import { eq } from 'drizzle-orm'; +import { jmapRequest, getMailboxes } from './client.js'; + +/** + * Sync emails with a specific contact (bidirectional: both from and to) + * ONLY syncs from Inbox and Sent mailboxes (no Archive, Trash, Drafts) + * ONLY syncs recent emails (last 30 days by default) + */ +export const syncEmailsFromSender = async ( + jmapConfig, + emailAccountId, + contactId, + senderEmail, + options = {} +) => { + const { limit = 50, daysBack = 30 } = options; + + try { + logger.info(`Synchronizácia emailov s kontaktom: ${senderEmail} pre účet ${emailAccountId}`); + + const [contact] = await db + .select({ companyId: contacts.companyId }) + .from(contacts) + .where(eq(contacts.id, contactId)) + .limit(1); + + // Get Inbox and Sent mailboxes ONLY + const mailboxes = await getMailboxes(jmapConfig); + const inboxMailbox = mailboxes.find(m => m.role === 'inbox' || m.name === 'Inbox' || m.name === 'INBOX'); + const sentMailbox = mailboxes.find(m => m.role === 'sent' || m.name === 'Sent'); + + if (!inboxMailbox) { + logger.error('Priečinok Inbox nebol nájdený'); + throw new Error('Priečinok Inbox nebol nájdený'); + } + + logger.info(`Používam priečinky: Inbox (${inboxMailbox.id})${sentMailbox ? `, Sent (${sentMailbox.id})` : ''}`); + + // Calculate date threshold (only emails from last X days) + const dateThreshold = new Date(); + dateThreshold.setDate(dateThreshold.getDate() - daysBack); + const dateThresholdISO = dateThreshold.toISOString(); + + logger.info(`Filtrovanie: posledných ${daysBack} dní, z Inbox/Sent, pre ${senderEmail}`); + + // Query emails FROM the contact + const queryFromResponse = await jmapRequest(jmapConfig, [ + [ + 'Email/query', + { + accountId: jmapConfig.accountId, + filter: { + operator: 'AND', + conditions: [ + { inMailbox: inboxMailbox.id }, + { from: senderEmail }, + { after: dateThresholdISO } + ] + }, + sort: [{ property: 'receivedAt', isAscending: false }], + limit, + }, + 'query1', + ], + ]); + + const fromEmailIds = queryFromResponse.methodResponses[0][1].ids || []; + logger.info(`Nájdených ${fromEmailIds.length} emailov OD ${senderEmail}`); + + // Query emails TO the contact (from Sent folder if it exists) + let toEmailIds = []; + if (sentMailbox) { + const queryToResponse = await jmapRequest(jmapConfig, [ + [ + 'Email/query', + { + accountId: jmapConfig.accountId, + filter: { + operator: 'AND', + conditions: [ + { inMailbox: sentMailbox.id }, + { to: senderEmail }, + { after: dateThresholdISO } + ] + }, + sort: [{ property: 'receivedAt', isAscending: false }], + limit, + }, + 'query2', + ], + ]); + + toEmailIds = queryToResponse.methodResponses[0][1].ids || []; + logger.info(`Nájdených ${toEmailIds.length} emailov PRE ${senderEmail}`); + } + + // Combine and deduplicate + const emailIds = [...new Set([...fromEmailIds, ...toEmailIds])]; + logger.info(`Celkovo unikátnych emailov: ${emailIds.length}`); + + if (emailIds.length === 0) { + return { total: 0, saved: 0 }; + } + + // Fetch full email details + const getResponse = await jmapRequest(jmapConfig, [ + [ + 'Email/get', + { + accountId: jmapConfig.accountId, + ids: emailIds, + properties: [ + 'id', + 'messageId', + 'threadId', + 'inReplyTo', + 'from', + 'to', + 'subject', + 'receivedAt', + 'textBody', + 'htmlBody', + 'bodyValues', + 'keywords', + ], + fetchTextBodyValues: true, + fetchHTMLBodyValues: true, + }, + 'get1', + ], + ]); + + const emailsList = getResponse.methodResponses[0][1].list; + let savedCount = 0; + + // Save emails to database + for (const email of emailsList) { + try { + const fromEmail = email.from?.[0]?.email; + const toEmail = email.to?.[0]?.email; + const messageId = Array.isArray(email.messageId) ? email.messageId[0] : email.messageId; + 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(`Preskakujem email ${messageId} - chýba pole from aj to`); + continue; + } + + // VALIDATION: Email must belong to this contact + // Email belongs to contact if: + // - from === senderEmail (received email FROM contact) + // - to === senderEmail (sent email TO contact) + const belongsToContact = + fromEmail?.toLowerCase() === senderEmail.toLowerCase() || + toEmail?.toLowerCase() === senderEmail.toLowerCase(); + + if (!belongsToContact) { + logger.warn(`Preskakujem email ${messageId} - nepatrí kontaktu ${senderEmail} (od: ${fromEmail}, pre: ${toEmail})`); + continue; + } + + // Skip if already exists + const [existing] = await db + .select() + .from(emails) + .where(eq(emails.messageId, messageId)) + .limit(1); + + if (existing) { + continue; + } + + // Save email + await db.insert(emails).values({ + emailAccountId, + contactId, + companyId: contact?.companyId || null, + jmapId: email.id, + messageId, + threadId: email.threadId || messageId, + inReplyTo: inReplyTo || null, + from: fromEmail, + to: toEmail, + subject: email.subject || '(Bez predmetu)', + body: + email.bodyValues?.[email.textBody?.[0]?.partId]?.value || + email.bodyValues?.[email.htmlBody?.[0]?.partId]?.value || + '(Prázdna správa)', + date: email.receivedAt ? new Date(email.receivedAt) : new Date(), + isRead, + sentByUserId: null, // Prijatý email, nie odpoveď + }); + + savedCount++; + } catch (error) { + logger.error(`Chyba pri ukladaní emailu ${email.messageId}`, error); + } + } + + logger.success(`Synchronizovaných ${savedCount} nových emailov s ${senderEmail}`); + return { total: emailsList.length, saved: savedCount }; + } catch (error) { + logger.error('Nepodarilo sa synchronizovať emaily s kontaktom', error); + throw error; + } +}; diff --git a/src/services/timesheet.service.js b/src/services/timesheet.service.js index ce5a70f..aa1f393 100644 --- a/src/services/timesheet.service.js +++ b/src/services/timesheet.service.js @@ -68,7 +68,7 @@ const safeUnlink = async (filePath) => { await fs.unlink(filePath); } catch (error) { // Keep server responsive even if cleanup fails - console.error('Failed to delete file:', error); + console.error('Nepodarilo sa zmazať súbor:', error); } };