initialize git, basic setup for crm
This commit is contained in:
108
src/services/audit.service.js
Normal file
108
src/services/audit.service.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import { db } from '../config/database.js';
|
||||
import { auditLogs } from '../db/schema.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
/**
|
||||
* Zaloguje audit event
|
||||
*/
|
||||
export const logAuditEvent = async ({
|
||||
userId = null,
|
||||
action,
|
||||
resource,
|
||||
resourceId = null,
|
||||
oldValue = null,
|
||||
newValue = null,
|
||||
ipAddress = null,
|
||||
userAgent = null,
|
||||
success = true,
|
||||
errorMessage = null,
|
||||
}) => {
|
||||
try {
|
||||
await db.insert(auditLogs).values({
|
||||
userId,
|
||||
action,
|
||||
resource,
|
||||
resourceId,
|
||||
oldValue: oldValue ? JSON.stringify(oldValue) : null,
|
||||
newValue: newValue ? JSON.stringify(newValue) : null,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success,
|
||||
errorMessage,
|
||||
});
|
||||
|
||||
logger.audit(
|
||||
`${action} on ${resource}${resourceId ? ` (${resourceId})` : ''} ${success ? 'SUCCESS' : 'FAILED'}${userId ? ` by user ${userId}` : ''}`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to log audit event', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Pomocné funkcie pre špecifické audit eventy
|
||||
*/
|
||||
|
||||
export const logLoginAttempt = async (username, success, ipAddress, userAgent, errorMessage = null) => {
|
||||
await logAuditEvent({
|
||||
action: 'login_attempt',
|
||||
resource: 'user',
|
||||
resourceId: username,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success,
|
||||
errorMessage,
|
||||
});
|
||||
};
|
||||
|
||||
export const logPasswordChange = async (userId, ipAddress, userAgent) => {
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
action: 'password_change',
|
||||
resource: 'user',
|
||||
resourceId: userId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const logEmailLink = async (userId, email, ipAddress, userAgent) => {
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
action: 'email_linked',
|
||||
resource: 'user',
|
||||
resourceId: userId,
|
||||
newValue: { email },
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const logRoleChange = async (adminId, userId, oldRole, newRole, ipAddress, userAgent) => {
|
||||
await logAuditEvent({
|
||||
userId: adminId,
|
||||
action: 'role_change',
|
||||
resource: 'user',
|
||||
resourceId: userId,
|
||||
oldValue: { role: oldRole },
|
||||
newValue: { role: newRole },
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const logUserCreation = async (adminId, newUserId, username, role, ipAddress, userAgent) => {
|
||||
await logAuditEvent({
|
||||
userId: adminId,
|
||||
action: 'user_created',
|
||||
resource: 'user',
|
||||
resourceId: newUserId,
|
||||
newValue: { username, role },
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
};
|
||||
218
src/services/auth.service.js
Normal file
218
src/services/auth.service.js
Normal file
@@ -0,0 +1,218 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '../config/database.js';
|
||||
import { users } from '../db/schema.js';
|
||||
import { hashPassword, comparePassword, encryptPassword, decryptPassword } from '../utils/password.js';
|
||||
import { generateTokenPair } from '../utils/jwt.js';
|
||||
import { validateJmapCredentials } from './email.service.js';
|
||||
import {
|
||||
AuthenticationError,
|
||||
ConflictError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
} from '../utils/errors.js';
|
||||
|
||||
/**
|
||||
* KROK 1: Login s temporary password
|
||||
*/
|
||||
export const loginWithTempPassword = async (username, password, ipAddress, userAgent) => {
|
||||
// Najdi usera
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
throw new AuthenticationError('Nesprávne prihlasovacie údaje');
|
||||
}
|
||||
|
||||
// Ak už user zmenil heslo, použije sa permanentné heslo
|
||||
if (user.changedPassword && user.password) {
|
||||
const isValid = await comparePassword(password, user.password);
|
||||
if (!isValid) {
|
||||
throw new AuthenticationError('Nesprávne prihlasovacie údaje');
|
||||
}
|
||||
} else {
|
||||
// Ak ešte nezmenil heslo, použije sa temporary password
|
||||
if (!user.tempPassword) {
|
||||
throw new AuthenticationError('Účet nie je správne nastavený');
|
||||
}
|
||||
|
||||
// Temporary password môže byť plain text alebo hash (závisí od seeding)
|
||||
// Pre bezpečnosť ho budeme hashovať pri vytvorení
|
||||
const isValid = await comparePassword(password, user.tempPassword);
|
||||
if (!isValid) {
|
||||
throw new AuthenticationError('Nesprávne prihlasovacie údaje');
|
||||
}
|
||||
}
|
||||
|
||||
// Update last login
|
||||
await db
|
||||
.update(users)
|
||||
.set({ lastLogin: new Date() })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
// Generuj JWT tokeny
|
||||
const tokens = generateTokenPair(user);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.role,
|
||||
changedPassword: user.changedPassword,
|
||||
},
|
||||
tokens,
|
||||
needsPasswordChange: !user.changedPassword,
|
||||
needsEmailSetup: !user.email,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* KROK 2: Nastavenie nového hesla
|
||||
*/
|
||||
export const setNewPassword = async (userId, newPassword) => {
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundError('Používateľ nenájdený');
|
||||
}
|
||||
|
||||
if (user.changedPassword) {
|
||||
throw new ValidationError('Heslo už bolo zmenené');
|
||||
}
|
||||
|
||||
// Hash nového hesla
|
||||
const hashedPassword = await hashPassword(newPassword);
|
||||
|
||||
// Update user
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
password: hashedPassword,
|
||||
changedPassword: true,
|
||||
tempPassword: null, // Vymaž temporary password
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Heslo úspešne nastavené',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* KROK 3: Pripojenie emailu s JMAP validáciou
|
||||
*/
|
||||
export const linkEmail = async (userId, email, emailPassword) => {
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundError('Používateľ nenájdený');
|
||||
}
|
||||
|
||||
// Skontroluj či email už nie je použitý
|
||||
const [existingUser] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (existingUser) {
|
||||
throw new ConflictError('Email už je použitý');
|
||||
}
|
||||
|
||||
// Validuj JMAP credentials a získaj account ID
|
||||
const { accountId } = await validateJmapCredentials(email, emailPassword);
|
||||
|
||||
// Encrypt email password pre bezpečné uloženie (nie hash, lebo potrebujeme decryption pre JMAP)
|
||||
const encryptedEmailPassword = encryptPassword(emailPassword);
|
||||
|
||||
// Update user s email credentials
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
email,
|
||||
emailPassword: encryptedEmailPassword,
|
||||
jmapAccountId: accountId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
accountId,
|
||||
message: 'Email účet úspešne pripojený',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Skip email setup
|
||||
*/
|
||||
export const skipEmailSetup = async (userId) => {
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundError('Používateľ nenájdený');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Email setup preskočený',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Logout - clear tokens (handled on client side)
|
||||
*/
|
||||
export const logout = async () => {
|
||||
return {
|
||||
success: true,
|
||||
message: 'Úspešne odhlásený',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get user by ID
|
||||
*/
|
||||
export const getUserById = async (userId) => {
|
||||
const [user] = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
email: users.email,
|
||||
emailPassword: users.emailPassword,
|
||||
jmapAccountId: users.jmapAccountId,
|
||||
firstName: users.firstName,
|
||||
lastName: users.lastName,
|
||||
role: users.role,
|
||||
changedPassword: users.changedPassword,
|
||||
lastLogin: users.lastLogin,
|
||||
createdAt: users.createdAt,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundError('Používateľ nenájdený');
|
||||
}
|
||||
|
||||
return user;
|
||||
};
|
||||
102
src/services/contact.service.js
Normal file
102
src/services/contact.service.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import { db } from '../config/database.js';
|
||||
import { contacts, emails } from '../db/schema.js';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { NotFoundError, ConflictError } from '../utils/errors.js';
|
||||
import { syncEmailsFromSender } from './jmap.service.js';
|
||||
|
||||
/**
|
||||
* Get all contacts for a user
|
||||
*/
|
||||
export const getUserContacts = async (userId) => {
|
||||
const userContacts = await db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(eq(contacts.userId, userId))
|
||||
.orderBy(desc(contacts.addedAt));
|
||||
|
||||
return userContacts;
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a new contact and sync their emails
|
||||
*/
|
||||
export const addContact = async (userId, jmapConfig, email, name = '', notes = '') => {
|
||||
// Check if contact already exists
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(and(eq(contacts.userId, userId), eq(contacts.email, email)))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictError('Kontakt už existuje');
|
||||
}
|
||||
|
||||
// Create contact
|
||||
const [newContact] = await db
|
||||
.insert(contacts)
|
||||
.values({
|
||||
userId,
|
||||
email,
|
||||
name: name || email.split('@')[0],
|
||||
notes: notes || null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Sync emails from this sender
|
||||
try {
|
||||
await syncEmailsFromSender(jmapConfig, userId, newContact.id, email);
|
||||
} catch (error) {
|
||||
console.error('Failed to sync emails for new contact:', error);
|
||||
// Don't throw - contact was created successfully
|
||||
}
|
||||
|
||||
return newContact;
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a contact
|
||||
*/
|
||||
export const removeContact = async (userId, contactId) => {
|
||||
const [contact] = await db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(and(eq(contacts.id, contactId), eq(contacts.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!contact) {
|
||||
throw new NotFoundError('Kontakt nenájdený');
|
||||
}
|
||||
|
||||
// Delete contact (emails will be cascade deleted)
|
||||
await db.delete(contacts).where(eq(contacts.id, contactId));
|
||||
|
||||
return { success: true, message: 'Kontakt bol odstránený' };
|
||||
};
|
||||
|
||||
/**
|
||||
* Update contact
|
||||
*/
|
||||
export const updateContact = async (userId, contactId, { name, notes }) => {
|
||||
const [contact] = await db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(and(eq(contacts.id, contactId), eq(contacts.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!contact) {
|
||||
throw new NotFoundError('Kontakt nenájdený');
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(contacts)
|
||||
.set({
|
||||
name: name !== undefined ? name : contact.name,
|
||||
notes: notes !== undefined ? notes : contact.notes,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(contacts.id, contactId))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
};
|
||||
120
src/services/crm-email.service.js
Normal file
120
src/services/crm-email.service.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import { db } from '../config/database.js';
|
||||
import { emails, contacts } from '../db/schema.js';
|
||||
import { eq, and, or, desc, like, sql } from 'drizzle-orm';
|
||||
import { NotFoundError } from '../utils/errors.js';
|
||||
|
||||
/**
|
||||
* Get all emails for a user (only from added contacts)
|
||||
*/
|
||||
export const getUserEmails = async (userId) => {
|
||||
const userEmails = await db
|
||||
.select({
|
||||
id: emails.id,
|
||||
jmapId: emails.jmapId,
|
||||
messageId: emails.messageId,
|
||||
threadId: emails.threadId,
|
||||
inReplyTo: emails.inReplyTo,
|
||||
from: emails.from,
|
||||
to: emails.to,
|
||||
subject: emails.subject,
|
||||
body: emails.body,
|
||||
isRead: emails.isRead,
|
||||
date: emails.date,
|
||||
createdAt: emails.createdAt,
|
||||
contact: {
|
||||
id: contacts.id,
|
||||
email: contacts.email,
|
||||
name: contacts.name,
|
||||
},
|
||||
})
|
||||
.from(emails)
|
||||
.leftJoin(contacts, eq(emails.contactId, contacts.id))
|
||||
.where(eq(emails.userId, userId))
|
||||
.orderBy(desc(emails.date));
|
||||
|
||||
return userEmails;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get emails by thread ID
|
||||
*/
|
||||
export const getEmailThread = async (userId, threadId) => {
|
||||
const thread = await db
|
||||
.select()
|
||||
.from(emails)
|
||||
.where(and(eq(emails.userId, userId), eq(emails.threadId, threadId)))
|
||||
.orderBy(emails.date);
|
||||
|
||||
if (thread.length === 0) {
|
||||
throw new NotFoundError('Thread nenájdený');
|
||||
}
|
||||
|
||||
return thread;
|
||||
};
|
||||
|
||||
/**
|
||||
* Search emails (from, to, subject)
|
||||
*/
|
||||
export const searchEmails = async (userId, query) => {
|
||||
if (!query || query.trim().length < 2) {
|
||||
throw new Error('Search term must be at least 2 characters');
|
||||
}
|
||||
|
||||
const searchPattern = `%${query}%`;
|
||||
|
||||
const results = await db
|
||||
.select()
|
||||
.from(emails)
|
||||
.where(
|
||||
and(
|
||||
eq(emails.userId, userId),
|
||||
or(
|
||||
like(emails.from, searchPattern),
|
||||
like(emails.to, searchPattern),
|
||||
like(emails.subject, searchPattern)
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(emails.date))
|
||||
.limit(50);
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get unread email count
|
||||
*/
|
||||
export const getUnreadCount = async (userId) => {
|
||||
const result = await db
|
||||
.select({ count: sql`count(*)::int` })
|
||||
.from(emails)
|
||||
.where(and(eq(emails.userId, userId), eq(emails.isRead, false)));
|
||||
|
||||
return result[0]?.count || 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark thread as read
|
||||
*/
|
||||
export const markThreadAsRead = async (userId, threadId) => {
|
||||
const result = await db
|
||||
.update(emails)
|
||||
.set({ isRead: true, updatedAt: new Date() })
|
||||
.where(and(eq(emails.userId, userId), eq(emails.threadId, threadId), eq(emails.isRead, false)))
|
||||
.returning();
|
||||
|
||||
return { success: true, count: result.length };
|
||||
};
|
||||
|
||||
/**
|
||||
* Get emails for a specific contact
|
||||
*/
|
||||
export const getContactEmails = async (userId, contactId) => {
|
||||
const contactEmails = await db
|
||||
.select()
|
||||
.from(emails)
|
||||
.where(and(eq(emails.userId, userId), eq(emails.contactId, contactId)))
|
||||
.orderBy(desc(emails.date));
|
||||
|
||||
return contactEmails;
|
||||
};
|
||||
255
src/services/email.service.js
Normal file
255
src/services/email.service.js
Normal file
@@ -0,0 +1,255 @@
|
||||
import axios from 'axios';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
const JMAP_CONFIG = {
|
||||
server: process.env.JMAP_SERVER || 'https://mail.truemail.sk/jmap/',
|
||||
username: process.env.JMAP_USERNAME || 'info1_test@truemail.sk',
|
||||
password: process.env.JMAP_PASSWORD || 'info1',
|
||||
accountId: process.env.JMAP_ACCOUNT_ID || 'ba',
|
||||
};
|
||||
|
||||
/**
|
||||
* Získa JMAP session
|
||||
*/
|
||||
const getJmapSession = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${JMAP_CONFIG.server}session`, {
|
||||
auth: {
|
||||
username: JMAP_CONFIG.username,
|
||||
password: JMAP_CONFIG.password,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get JMAP session', error);
|
||||
throw new Error('Email service nedostupný');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validuje JMAP credentials a vráti account ID
|
||||
* @param {string} email - Email address
|
||||
* @param {string} password - Email password
|
||||
* @returns {Promise<{accountId: string, session: object}>}
|
||||
*/
|
||||
export const validateJmapCredentials = async (email, password) => {
|
||||
try {
|
||||
const response = await axios.get(`${JMAP_CONFIG.server}session`, {
|
||||
auth: {
|
||||
username: email,
|
||||
password: password,
|
||||
},
|
||||
});
|
||||
|
||||
const session = response.data;
|
||||
|
||||
// Získaj account ID z session
|
||||
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'] ||
|
||||
session.primaryAccounts?.['urn:ietf:params:jmap:core'] ||
|
||||
Object.keys(session.accounts || {})[0];
|
||||
|
||||
if (!accountId) {
|
||||
throw new Error('Nepodarilo sa získať JMAP account ID');
|
||||
}
|
||||
|
||||
logger.success(`JMAP credentials validated for ${email}, accountId: ${accountId}`);
|
||||
|
||||
return {
|
||||
accountId,
|
||||
session,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Failed to validate JMAP credentials for ${email}`, error);
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
throw new Error('Nesprávne prihlasovacie údaje k emailu');
|
||||
}
|
||||
|
||||
throw new Error('Nepodarilo sa overiť emailový účet');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Pošle email pomocou JMAP
|
||||
*/
|
||||
const sendJmapEmail = async ({ to, subject, htmlBody, textBody }) => {
|
||||
try {
|
||||
const session = await getJmapSession();
|
||||
const apiUrl = session.apiUrl;
|
||||
|
||||
const emailObject = {
|
||||
from: [{ email: JMAP_CONFIG.username }],
|
||||
to: [{ email: to }],
|
||||
subject,
|
||||
htmlBody: [
|
||||
{
|
||||
partId: '1',
|
||||
type: 'text/html',
|
||||
value: htmlBody,
|
||||
},
|
||||
],
|
||||
textBody: [
|
||||
{
|
||||
partId: '2',
|
||||
type: 'text/plain',
|
||||
value: textBody || subject,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await axios.post(
|
||||
apiUrl,
|
||||
{
|
||||
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
|
||||
methodCalls: [
|
||||
[
|
||||
'Email/set',
|
||||
{
|
||||
accountId: JMAP_CONFIG.accountId,
|
||||
create: {
|
||||
draft: emailObject,
|
||||
},
|
||||
},
|
||||
'0',
|
||||
],
|
||||
[
|
||||
'EmailSubmission/set',
|
||||
{
|
||||
accountId: JMAP_CONFIG.accountId,
|
||||
create: {
|
||||
submission: {
|
||||
emailId: '#draft',
|
||||
envelope: {
|
||||
mailFrom: { email: JMAP_CONFIG.username },
|
||||
rcptTo: [{ email: to }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'1',
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
auth: {
|
||||
username: JMAP_CONFIG.username,
|
||||
password: JMAP_CONFIG.password,
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
logger.success(`Email sent to ${to}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to send email to ${to}`, error);
|
||||
throw new Error('Nepodarilo sa odoslať email');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Email templates
|
||||
*/
|
||||
|
||||
export const sendVerificationEmail = async (to, username, verificationToken) => {
|
||||
const verificationUrl = `${process.env.BETTER_AUTH_URL}/api/auth/verify-email?token=${verificationToken}`;
|
||||
|
||||
const htmlBody = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.footer { margin-top: 30px; font-size: 12px; color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>Vitajte v CRM systéme, ${username}!</h2>
|
||||
<p>Prosím, verifikujte svoju emailovú adresu kliknutím na tlačidlo nižšie:</p>
|
||||
<a href="${verificationUrl}" class="button">Verifikovať email</a>
|
||||
<p>Alebo skopírujte tento link do prehliadača:</p>
|
||||
<p>${verificationUrl}</p>
|
||||
<p class="footer">
|
||||
Tento link vyprší za 24 hodín.<br>
|
||||
Ak ste tento email neočakávali, môžete ho ignorovať.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const textBody = `
|
||||
Vitajte v CRM systéme, ${username}!
|
||||
|
||||
Prosím, verifikujte svoju emailovú adresu kliknutím na tento link:
|
||||
${verificationUrl}
|
||||
|
||||
Tento link vyprší za 24 hodín.
|
||||
Ak ste tento email neočakávali, môžete ho ignorovať.
|
||||
`;
|
||||
|
||||
return sendJmapEmail({
|
||||
to,
|
||||
subject: 'Verifikácia emailu - CRM systém',
|
||||
htmlBody,
|
||||
textBody,
|
||||
});
|
||||
};
|
||||
|
||||
export const sendWelcomeEmail = async (to, username) => {
|
||||
const htmlBody = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>Vitajte v CRM systéme, ${username}!</h2>
|
||||
<p>Váš účet bol úspešne vytvorený a nastavený.</p>
|
||||
<p>Môžete sa prihlásiť a začať používať systém.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const textBody = `
|
||||
Vitajte v CRM systéme, ${username}!
|
||||
|
||||
Váš účet bol úspešne vytvorený a nastavený.
|
||||
Môžete sa prihlásiť a začať používať systém.
|
||||
`;
|
||||
|
||||
return sendJmapEmail({
|
||||
to,
|
||||
subject: 'Vitajte v CRM systéme',
|
||||
htmlBody,
|
||||
textBody,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Asynchrónne posielanie emailov (non-blocking)
|
||||
*/
|
||||
export const sendEmailAsync = (emailFunction, ...args) => {
|
||||
// Spustí email sending v pozadí
|
||||
emailFunction(...args).catch((error) => {
|
||||
logger.error('Async email sending failed', error);
|
||||
});
|
||||
};
|
||||
498
src/services/jmap.service.js
Normal file
498
src/services/jmap.service.js
Normal file
@@ -0,0 +1,498 @@
|
||||
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
|
||||
*/
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 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, userId, 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 user
|
||||
const existingContacts = await db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(eq(contacts.userId, userId));
|
||||
|
||||
const contactEmailsSet = new Set(existingContacts.map((c) => c.email.toLowerCase()));
|
||||
|
||||
// Group by sender (unique senders)
|
||||
const sendersMap = new Map();
|
||||
const myEmail = jmapConfig.username.toLowerCase();
|
||||
|
||||
emailsList.forEach((email) => {
|
||||
const fromEmail = email.from?.[0]?.email;
|
||||
|
||||
if (!fromEmail || fromEmail.toLowerCase() === myEmail) {
|
||||
return; // Skip my own emails
|
||||
}
|
||||
|
||||
// Keep only the most recent email from each sender
|
||||
if (!sendersMap.has(fromEmail)) {
|
||||
sendersMap.set(fromEmail, {
|
||||
email: fromEmail,
|
||||
name: email.from?.[0]?.name || fromEmail.split('@')[0],
|
||||
latestSubject: email.subject || '(No Subject)',
|
||||
latestDate: email.receivedAt,
|
||||
snippet: email.preview || '',
|
||||
isContact: contactEmailsSet.has(fromEmail.toLowerCase()),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync emails from a specific sender (when adding as contact)
|
||||
*/
|
||||
export const syncEmailsFromSender = async (jmapConfig, userId, contactId, senderEmail) => {
|
||||
try {
|
||||
logger.info(`Syncing emails from sender: ${senderEmail}`);
|
||||
|
||||
// Query all emails from this sender
|
||||
const queryResponse = await jmapRequest(jmapConfig, [
|
||||
[
|
||||
'Email/query',
|
||||
{
|
||||
accountId: jmapConfig.accountId,
|
||||
filter: {
|
||||
operator: 'OR',
|
||||
conditions: [{ from: senderEmail }, { to: senderEmail }],
|
||||
},
|
||||
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||
limit: 500,
|
||||
},
|
||||
'query1',
|
||||
],
|
||||
]);
|
||||
|
||||
const emailIds = queryResponse.methodResponses[0][1].ids;
|
||||
logger.info(`Found ${emailIds.length} emails from ${senderEmail}`);
|
||||
|
||||
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 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({
|
||||
userId,
|
||||
contactId,
|
||||
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,
|
||||
});
|
||||
|
||||
savedCount++;
|
||||
} catch (error) {
|
||||
logger.error(`Error saving email ${email.messageId}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`Synced ${savedCount} new emails from ${senderEmail}`);
|
||||
return { total: emailsList.length, saved: savedCount };
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync emails from sender', 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 not found in 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, 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('Sent mailbox not found');
|
||||
}
|
||||
|
||||
// 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('Failed to create email draft');
|
||||
}
|
||||
|
||||
// Get user identity
|
||||
const identities = await getIdentities(jmapConfig);
|
||||
const identity = identities.find((i) => i.email === jmapConfig.username) || identities[0];
|
||||
|
||||
if (!identity) {
|
||||
throw new Error('No identity found for sending email');
|
||||
}
|
||||
|
||||
// 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('Failed to submit email');
|
||||
}
|
||||
|
||||
logger.success(`Email sent successfully to ${to}`);
|
||||
|
||||
// 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({
|
||||
userId,
|
||||
contactId: null, // Will be linked later if recipient is a contact
|
||||
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
|
||||
});
|
||||
|
||||
return { success: true, messageId };
|
||||
} catch (error) {
|
||||
logger.error('Error sending email', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user