Refactor: Split jmap.service.js into modules and update Slovak translations
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,7 @@ const pool = new Pool({
|
|||||||
|
|
||||||
// Note: Connection logging handled in index.js to avoid circular dependencies
|
// Note: Connection logging handled in index.js to avoid circular dependencies
|
||||||
pool.on('error', (err) => {
|
pool.on('error', (err) => {
|
||||||
console.error('Unexpected database error:', err);
|
console.error('Neočakávaná chyba databázy:', err);
|
||||||
process.exit(-1);
|
process.exit(-1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as contactService from '../services/contact.service.js';
|
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';
|
import * as emailAccountService from '../services/email-account.service.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as crmEmailService from '../services/crm-email.service.js';
|
import * as crmEmailService from '../services/crm-email.service.js';
|
||||||
import * as contactService from '../services/contact.service.js';
|
import * as contactService from '../services/contact.service.js';
|
||||||
import * as emailAccountService from '../services/email-account.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 { getUserById } from '../services/auth.service.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export const createUser = async (username, firstName, lastName, role, email, ema
|
|||||||
shared: newEmailAccount.shared,
|
shared: newEmailAccount.shared,
|
||||||
};
|
};
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
logger.error('Failed to create email account:', { error: emailError.message });
|
logger.error('Nepodarilo sa vytvoriť email účet:', { error: emailError.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export const logAuditEvent = async ({
|
|||||||
`${action} on ${resource}${resourceId ? ` (${resourceId})` : ''} ${success ? 'SUCCESS' : 'FAILED'}${userId ? ` by user ${userId}` : ''}`
|
`${action} on ${resource}${resourceId ? ` (${resourceId})` : ''} ${success ? 'SUCCESS' : 'FAILED'}${userId ? ` by user ${userId}` : ''}`
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to log audit event', error);
|
logger.error('Nepodarilo sa zaznamenať audit event', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { db } from '../config/database.js';
|
|||||||
import { contacts, emails, companies } from '../db/schema.js';
|
import { contacts, emails, companies } from '../db/schema.js';
|
||||||
import { eq, and, desc, or, ne } from 'drizzle-orm';
|
import { eq, and, desc, or, ne } from 'drizzle-orm';
|
||||||
import { NotFoundError, ConflictError } from '../utils/errors.js';
|
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
|
* Get all contacts for an email account
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const validateJmapCredentials = async (email, password) => {
|
|||||||
const jmapServer = process.env.JMAP_SERVER;
|
const jmapServer = process.env.JMAP_SERVER;
|
||||||
|
|
||||||
if (!jmapServer) {
|
if (!jmapServer) {
|
||||||
throw new Error('JMAP_SERVER environment variable is not configured');
|
throw new Error('JMAP_SERVER premenná prostredia nie je nastavená');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -33,14 +33,14 @@ export const validateJmapCredentials = async (email, password) => {
|
|||||||
throw new Error('Nepodarilo sa získať JMAP account ID');
|
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 {
|
return {
|
||||||
accountId,
|
accountId,
|
||||||
session,
|
session,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} 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) {
|
if (error.response?.status === 401) {
|
||||||
throw new Error('Nesprávne prihlasovacie údaje k emailu');
|
throw new Error('Nesprávne prihlasovacie údaje k emailu');
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
74
src/services/jmap/client.js
Normal file
74
src/services/jmap/client.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
38
src/services/jmap/config.js
Normal file
38
src/services/jmap/config.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
97
src/services/jmap/discovery.js
Normal file
97
src/services/jmap/discovery.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
30
src/services/jmap/index.js
Normal file
30
src/services/jmap/index.js
Normal file
@@ -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';
|
||||||
210
src/services/jmap/operations.js
Normal file
210
src/services/jmap/operations.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
135
src/services/jmap/search.js
Normal file
135
src/services/jmap/search.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
211
src/services/jmap/sync.js
Normal file
211
src/services/jmap/sync.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -68,7 +68,7 @@ const safeUnlink = async (filePath) => {
|
|||||||
await fs.unlink(filePath);
|
await fs.unlink(filePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Keep server responsive even if cleanup fails
|
// Keep server responsive even if cleanup fails
|
||||||
console.error('Failed to delete file:', error);
|
console.error('Nepodarilo sa zmazať súbor:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user