Add Timesheets API with file upload and role-based access
Backend Features:
- Timesheets database table (id, userId, fileName, filePath, fileType, fileSize, year, month, timestamps)
- File upload with multer (memory storage, 10MB limit, PDF/Excel validation)
- Structured file storage: uploads/timesheets/{userId}/{year}/{month}/
- RESTful API endpoints:
* POST /api/timesheets/upload - Upload timesheet
* GET /api/timesheets/my - Get user's timesheets (with filters)
* GET /api/timesheets/all - Get all timesheets (admin only)
* GET /api/timesheets/:id/download - Download file
* DELETE /api/timesheets/:id - Delete timesheet
- Role-based permissions: users access own files, admins access all
- Proper error handling and file cleanup on errors
- Database migration for timesheets table
Technical:
- Uses req.user.role for permission checks
- Automatic directory creation for user/year/month structure
- Blob URL cleanup and proper file handling
- Integration with existing auth middleware
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,12 @@
|
||||
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 { hashPassword, comparePassword } from '../utils/password.js';
|
||||
import { generateTokenPair } from '../utils/jwt.js';
|
||||
import { validateJmapCredentials } from './email.service.js';
|
||||
import * as emailAccountService from './email-account.service.js';
|
||||
import {
|
||||
AuthenticationError,
|
||||
ConflictError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
} from '../utils/errors.js';
|
||||
|
||||
/**
|
||||
@@ -55,11 +53,13 @@ export const loginWithTempPassword = async (username, password, ipAddress, userA
|
||||
// Generuj JWT tokeny
|
||||
const tokens = generateTokenPair(user);
|
||||
|
||||
// Check if user has email accounts (many-to-many)
|
||||
const userEmailAccounts = await emailAccountService.getUserEmailAccounts(user.id);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.role,
|
||||
@@ -67,7 +67,7 @@ export const loginWithTempPassword = async (username, password, ipAddress, userA
|
||||
},
|
||||
tokens,
|
||||
needsPasswordChange: !user.changedPassword,
|
||||
needsEmailSetup: !user.email,
|
||||
needsEmailSetup: userEmailAccounts.length === 0,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -112,6 +112,7 @@ export const setNewPassword = async (userId, newPassword) => {
|
||||
|
||||
/**
|
||||
* KROK 3: Pripojenie emailu s JMAP validáciou
|
||||
* Používa many-to-many vzťah cez userEmailAccounts
|
||||
*/
|
||||
export const linkEmail = async (userId, email, emailPassword) => {
|
||||
const [user] = await db
|
||||
@@ -124,38 +125,22 @@ export const linkEmail = async (userId, email, emailPassword) => {
|
||||
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));
|
||||
// Použij emailAccountService ktorý automaticky vytvorí many-to-many link
|
||||
const newEmailAccount = await emailAccountService.createEmailAccount(
|
||||
userId,
|
||||
email,
|
||||
emailPassword
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
accountId,
|
||||
message: 'Email účet úspešne pripojený',
|
||||
accountId: newEmailAccount.jmapAccountId,
|
||||
emailAccountId: newEmailAccount.id,
|
||||
isPrimary: newEmailAccount.isPrimary,
|
||||
shared: newEmailAccount.shared,
|
||||
message: newEmailAccount.shared
|
||||
? 'Email účet už existoval a bol zdieľaný s vami'
|
||||
: 'Nový email účet úspešne vytvorený a pripojený',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -191,15 +176,13 @@ export const logout = async () => {
|
||||
|
||||
/**
|
||||
* Get user by ID
|
||||
* Email credentials sú v emailAccounts tabuľke (many-to-many)
|
||||
*/
|
||||
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,
|
||||
@@ -215,5 +198,11 @@ export const getUserById = async (userId) => {
|
||||
throw new NotFoundError('Používateľ nenájdený');
|
||||
}
|
||||
|
||||
return user;
|
||||
// Get user's email accounts (many-to-many)
|
||||
const userEmailAccounts = await emailAccountService.getUserEmailAccounts(userId);
|
||||
|
||||
return {
|
||||
...user,
|
||||
emailAccounts: userEmailAccounts,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,41 +1,34 @@
|
||||
import { db } from '../config/database.js';
|
||||
import { contacts, emails } from '../db/schema.js';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { eq, and, desc, or, ne } from 'drizzle-orm';
|
||||
import { NotFoundError, ConflictError } from '../utils/errors.js';
|
||||
import { syncEmailsFromSender } from './jmap.service.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
/**
|
||||
* Get all contacts for a user
|
||||
* If emailAccountId is provided, filter by that account, otherwise return all
|
||||
* Get all contacts for an email account
|
||||
* Kontakty patria k email accountu, nie k jednotlivým používateľom
|
||||
*/
|
||||
export const getUserContacts = async (userId, emailAccountId = null) => {
|
||||
const conditions = [eq(contacts.userId, userId)];
|
||||
|
||||
if (emailAccountId) {
|
||||
conditions.push(eq(contacts.emailAccountId, emailAccountId));
|
||||
}
|
||||
|
||||
const userContacts = await db
|
||||
export const getContactsForEmailAccount = async (emailAccountId) => {
|
||||
const accountContacts = await db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(and(...conditions))
|
||||
.where(eq(contacts.emailAccountId, emailAccountId))
|
||||
.orderBy(desc(contacts.addedAt));
|
||||
|
||||
return userContacts;
|
||||
return accountContacts;
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a new contact and sync their emails
|
||||
* Add a new contact to an email account
|
||||
*/
|
||||
export const addContact = async (userId, emailAccountId, jmapConfig, email, name = '', notes = '') => {
|
||||
export const addContact = async (emailAccountId, jmapConfig, email, name = '', notes = '', addedByUserId = null) => {
|
||||
// Check if contact already exists for this email account
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(
|
||||
and(
|
||||
eq(contacts.userId, userId),
|
||||
eq(contacts.emailAccountId, emailAccountId),
|
||||
eq(contacts.email, email)
|
||||
)
|
||||
@@ -50,33 +43,80 @@ export const addContact = async (userId, emailAccountId, jmapConfig, email, name
|
||||
const [newContact] = await db
|
||||
.insert(contacts)
|
||||
.values({
|
||||
userId,
|
||||
emailAccountId,
|
||||
email,
|
||||
name: name || email.split('@')[0],
|
||||
notes: notes || null,
|
||||
addedBy: addedByUserId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Sync emails from this sender
|
||||
try {
|
||||
await syncEmailsFromSender(jmapConfig, userId, emailAccountId, newContact.id, email);
|
||||
await syncEmailsFromSender(jmapConfig, emailAccountId, newContact.id, email);
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync emails for new contact', { error: error.message });
|
||||
// Don't throw - contact was created successfully
|
||||
}
|
||||
|
||||
// REASSIGN: Fix any existing emails that belong to this contact but have wrong contactId
|
||||
try {
|
||||
logger.info(`Checking for emails to reassign to contact ${email}...`);
|
||||
|
||||
// Find emails where:
|
||||
// - from === newContact.email OR to === newContact.email
|
||||
// - contactId !== newContact.id (belongs to different contact)
|
||||
// - emailAccountId matches
|
||||
const emailsToReassign = await db
|
||||
.select()
|
||||
.from(emails)
|
||||
.where(
|
||||
and(
|
||||
eq(emails.emailAccountId, emailAccountId),
|
||||
ne(emails.contactId, newContact.id),
|
||||
or(
|
||||
eq(emails.from, email),
|
||||
eq(emails.to, email)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
if (emailsToReassign.length > 0) {
|
||||
logger.info(`Found ${emailsToReassign.length} emails to reassign to contact ${email}`);
|
||||
|
||||
// Update contactId for these emails
|
||||
for (const emailToReassign of emailsToReassign) {
|
||||
await db
|
||||
.update(emails)
|
||||
.set({ contactId: newContact.id })
|
||||
.where(eq(emails.id, emailToReassign.id));
|
||||
}
|
||||
|
||||
logger.success(`Reassigned ${emailsToReassign.length} emails to contact ${email}`);
|
||||
} else {
|
||||
logger.info(`No emails to reassign for contact ${email}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to reassign emails', { error: error.message });
|
||||
// Don't throw - contact was created successfully
|
||||
}
|
||||
|
||||
return newContact;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a contact by ID
|
||||
* Get a contact by ID (check it belongs to the email account)
|
||||
*/
|
||||
export const getContactById = async (contactId, userId) => {
|
||||
export const getContactById = async (contactId, emailAccountId) => {
|
||||
const [contact] = await db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(and(eq(contacts.id, contactId), eq(contacts.userId, userId)))
|
||||
.where(
|
||||
and(
|
||||
eq(contacts.id, contactId),
|
||||
eq(contacts.emailAccountId, emailAccountId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!contact) {
|
||||
@@ -89,16 +129,8 @@ export const getContactById = async (contactId, userId) => {
|
||||
/**
|
||||
* 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ý');
|
||||
}
|
||||
export const removeContact = async (contactId, emailAccountId) => {
|
||||
const contact = await getContactById(contactId, emailAccountId);
|
||||
|
||||
// Delete contact (emails will be cascade deleted)
|
||||
await db.delete(contacts).where(eq(contacts.id, contactId));
|
||||
@@ -109,16 +141,8 @@ export const removeContact = async (userId, contactId) => {
|
||||
/**
|
||||
* 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ý');
|
||||
}
|
||||
export const updateContact = async (contactId, emailAccountId, { name, notes }) => {
|
||||
const contact = await getContactById(contactId, emailAccountId);
|
||||
|
||||
const [updated] = await db
|
||||
.update(contacts)
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import { db } from '../config/database.js';
|
||||
import { emails, contacts } from '../db/schema.js';
|
||||
import { eq, and, or, desc, like, sql } from 'drizzle-orm';
|
||||
import { eq, and, or, desc, like, sql, inArray } from 'drizzle-orm';
|
||||
import { NotFoundError } from '../utils/errors.js';
|
||||
|
||||
/**
|
||||
* Get all emails for a user (only from added contacts)
|
||||
* If emailAccountId is provided, filter by that account
|
||||
* Get all emails for an email account (only from added contacts)
|
||||
*/
|
||||
export const getUserEmails = async (userId, emailAccountId = null) => {
|
||||
const conditions = [eq(emails.userId, userId)];
|
||||
|
||||
if (emailAccountId) {
|
||||
conditions.push(eq(emails.emailAccountId, emailAccountId));
|
||||
}
|
||||
|
||||
const userEmails = await db
|
||||
export const getEmailsForAccount = async (emailAccountId) => {
|
||||
const accountEmails = await db
|
||||
.select({
|
||||
id: emails.id,
|
||||
jmapId: emails.jmapId,
|
||||
@@ -26,6 +19,7 @@ export const getUserEmails = async (userId, emailAccountId = null) => {
|
||||
subject: emails.subject,
|
||||
body: emails.body,
|
||||
isRead: emails.isRead,
|
||||
sentByUserId: emails.sentByUserId,
|
||||
date: emails.date,
|
||||
createdAt: emails.createdAt,
|
||||
emailAccountId: emails.emailAccountId,
|
||||
@@ -36,21 +30,26 @@ export const getUserEmails = async (userId, emailAccountId = null) => {
|
||||
},
|
||||
})
|
||||
.from(emails)
|
||||
.leftJoin(contacts, eq(emails.contactId, contacts.id))
|
||||
.where(and(...conditions))
|
||||
.innerJoin(contacts, eq(emails.contactId, contacts.id))
|
||||
.where(eq(emails.emailAccountId, emailAccountId))
|
||||
.orderBy(desc(emails.date));
|
||||
|
||||
return userEmails;
|
||||
return accountEmails;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get emails by thread ID
|
||||
*/
|
||||
export const getEmailThread = async (userId, threadId) => {
|
||||
export const getEmailThread = async (emailAccountId, threadId) => {
|
||||
const thread = await db
|
||||
.select()
|
||||
.from(emails)
|
||||
.where(and(eq(emails.userId, userId), eq(emails.threadId, threadId)))
|
||||
.where(
|
||||
and(
|
||||
eq(emails.emailAccountId, emailAccountId),
|
||||
eq(emails.threadId, threadId)
|
||||
)
|
||||
)
|
||||
.orderBy(emails.date);
|
||||
|
||||
if (thread.length === 0) {
|
||||
@@ -62,16 +61,15 @@ export const getEmailThread = async (userId, threadId) => {
|
||||
|
||||
/**
|
||||
* Search emails (from, to, subject)
|
||||
* If emailAccountId is provided, filter by that account
|
||||
*/
|
||||
export const searchEmails = async (userId, query, emailAccountId = null) => {
|
||||
export const searchEmails = async (emailAccountId, query) => {
|
||||
if (!query || query.trim().length < 2) {
|
||||
throw new Error('Search term must be at least 2 characters');
|
||||
}
|
||||
|
||||
const searchPattern = `%${query}%`;
|
||||
const conditions = [
|
||||
eq(emails.userId, userId),
|
||||
eq(emails.emailAccountId, emailAccountId),
|
||||
or(
|
||||
like(emails.from, searchPattern),
|
||||
like(emails.to, searchPattern),
|
||||
@@ -79,10 +77,6 @@ export const searchEmails = async (userId, query, emailAccountId = null) => {
|
||||
),
|
||||
];
|
||||
|
||||
if (emailAccountId) {
|
||||
conditions.push(eq(emails.emailAccountId, emailAccountId));
|
||||
}
|
||||
|
||||
const results = await db
|
||||
.select()
|
||||
.from(emails)
|
||||
@@ -94,173 +88,150 @@ export const searchEmails = async (userId, query, emailAccountId = null) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Get unread email count
|
||||
* Returns total count and counts per email account
|
||||
* Get unread email count for an email account (only from added contacts)
|
||||
*/
|
||||
export const getUnreadCount = async (userId) => {
|
||||
// Get total unread count
|
||||
const totalResult = await db
|
||||
export const getUnreadCountForAccount = async (emailAccountId) => {
|
||||
const result = await db
|
||||
.select({ count: sql`count(*)::int` })
|
||||
.from(emails)
|
||||
.where(and(eq(emails.userId, userId), eq(emails.isRead, false)));
|
||||
.innerJoin(contacts, eq(emails.contactId, contacts.id))
|
||||
.where(
|
||||
and(
|
||||
eq(emails.emailAccountId, emailAccountId),
|
||||
eq(emails.isRead, false)
|
||||
)
|
||||
);
|
||||
|
||||
const totalUnread = totalResult[0]?.count || 0;
|
||||
return result[0]?.count || 0;
|
||||
};
|
||||
|
||||
// Get unread count per email account
|
||||
/**
|
||||
* Get unread email count summary for all user's email accounts (only from added contacts)
|
||||
*/
|
||||
export const getUnreadCountSummary = async (emailAccountIds) => {
|
||||
if (!emailAccountIds || emailAccountIds.length === 0) {
|
||||
return {
|
||||
totalUnread: 0,
|
||||
byAccount: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Get unread count per email account (only from added contacts)
|
||||
const accountCounts = await db
|
||||
.select({
|
||||
emailAccountId: emails.emailAccountId,
|
||||
count: sql`count(*)::int`,
|
||||
})
|
||||
.from(emails)
|
||||
.where(and(eq(emails.userId, userId), eq(emails.isRead, false)))
|
||||
.innerJoin(contacts, eq(emails.contactId, contacts.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(emails.emailAccountId, emailAccountIds),
|
||||
eq(emails.isRead, false)
|
||||
)
|
||||
)
|
||||
.groupBy(emails.emailAccountId);
|
||||
|
||||
const totalUnread = accountCounts.reduce((sum, acc) => sum + acc.count, 0);
|
||||
|
||||
return {
|
||||
totalUnread,
|
||||
accounts: accountCounts.map((ac) => ({
|
||||
emailAccountId: ac.emailAccountId,
|
||||
unreadCount: ac.count,
|
||||
})),
|
||||
byAccount: accountCounts,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get unread count by contact for an email account
|
||||
*/
|
||||
export const getUnreadCountByContact = async (emailAccountId) => {
|
||||
const contactCounts = await db
|
||||
.select({
|
||||
contactId: emails.contactId,
|
||||
contactEmail: contacts.email,
|
||||
contactName: contacts.name,
|
||||
count: sql`count(*)::int`,
|
||||
})
|
||||
.from(emails)
|
||||
.leftJoin(contacts, eq(emails.contactId, contacts.id))
|
||||
.where(
|
||||
and(
|
||||
eq(emails.emailAccountId, emailAccountId),
|
||||
eq(emails.isRead, false)
|
||||
)
|
||||
)
|
||||
.groupBy(emails.contactId, contacts.email, contacts.name);
|
||||
|
||||
return contactCounts;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark thread as read
|
||||
*/
|
||||
export const markThreadAsRead = async (userId, threadId) => {
|
||||
console.log('🟦 markThreadAsRead called:', { userId, threadId });
|
||||
|
||||
const result = await db
|
||||
export const markThreadAsRead = async (emailAccountId, threadId) => {
|
||||
const updated = await db
|
||||
.update(emails)
|
||||
.set({ isRead: true, updatedAt: new Date() })
|
||||
.where(and(eq(emails.userId, userId), eq(emails.threadId, threadId), eq(emails.isRead, false)))
|
||||
.where(
|
||||
and(
|
||||
eq(emails.emailAccountId, emailAccountId),
|
||||
eq(emails.threadId, threadId),
|
||||
eq(emails.isRead, false)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
console.log('✅ markThreadAsRead result:', { count: result.length, threadId });
|
||||
|
||||
return { success: true, count: result.length };
|
||||
return updated.length;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark all emails from a contact as read
|
||||
*/
|
||||
export const markContactEmailsAsRead = async (userId, contactId) => {
|
||||
console.log('🟦 markContactEmailsAsRead called:', { userId, contactId });
|
||||
export const markContactEmailsAsRead = async (contactId, emailAccountId) => {
|
||||
const updated = await db
|
||||
.update(emails)
|
||||
.set({ isRead: true, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(emails.emailAccountId, emailAccountId),
|
||||
eq(emails.contactId, contactId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
// Get the contact info first
|
||||
const [contact] = await db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(eq(contacts.id, contactId))
|
||||
.limit(1);
|
||||
|
||||
console.log('👤 Contact info:', {
|
||||
id: contact?.id,
|
||||
email: contact?.email,
|
||||
name: contact?.name,
|
||||
});
|
||||
|
||||
// First, check what emails exist for this contact (including already read ones)
|
||||
const allContactEmails = await db
|
||||
.select({
|
||||
id: emails.id,
|
||||
contactId: emails.contactId,
|
||||
isRead: emails.isRead,
|
||||
from: emails.from,
|
||||
subject: emails.subject,
|
||||
})
|
||||
.from(emails)
|
||||
.where(and(eq(emails.userId, userId), eq(emails.contactId, contactId)));
|
||||
|
||||
console.log('📧 All emails for this contact (by contactId):', {
|
||||
total: allContactEmails.length,
|
||||
unread: allContactEmails.filter(e => !e.isRead).length,
|
||||
read: allContactEmails.filter(e => e.isRead).length,
|
||||
sampleEmails: allContactEmails.slice(0, 3),
|
||||
});
|
||||
|
||||
// Check if there are emails from this sender but with NULL or different contactId
|
||||
if (contact) {
|
||||
const emailsFromSender = await db
|
||||
.select({
|
||||
id: emails.id,
|
||||
contactId: emails.contactId,
|
||||
isRead: emails.isRead,
|
||||
from: emails.from,
|
||||
subject: emails.subject,
|
||||
})
|
||||
.from(emails)
|
||||
.where(and(
|
||||
eq(emails.userId, userId),
|
||||
or(
|
||||
eq(emails.from, contact.email),
|
||||
like(emails.from, `%<${contact.email}>%`)
|
||||
)
|
||||
))
|
||||
.limit(10);
|
||||
|
||||
console.log('📨 Emails from this sender email (any contactId):', {
|
||||
total: emailsFromSender.length,
|
||||
withContactId: emailsFromSender.filter(e => e.contactId === contactId).length,
|
||||
withNullContactId: emailsFromSender.filter(e => e.contactId === null).length,
|
||||
withDifferentContactId: emailsFromSender.filter(e => e.contactId && e.contactId !== contactId).length,
|
||||
sampleEmails: emailsFromSender.slice(0, 3),
|
||||
});
|
||||
}
|
||||
|
||||
// FIX: Mark emails as read by matching sender EMAIL, not just contactId
|
||||
// This fixes the issue with duplicate contacts having different IDs
|
||||
let result = [];
|
||||
|
||||
if (contact) {
|
||||
// Update ALL emails from this sender's email address:
|
||||
// 1. Set the correct contactId (fixes duplicate contact issue)
|
||||
// 2. Mark as read
|
||||
result = await db
|
||||
.update(emails)
|
||||
.set({
|
||||
contactId: contactId, // Fix contactId for duplicate contacts
|
||||
isRead: true,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(and(
|
||||
eq(emails.userId, userId),
|
||||
or(
|
||||
eq(emails.from, contact.email),
|
||||
like(emails.from, `%<${contact.email}>%`)
|
||||
),
|
||||
eq(emails.isRead, false)
|
||||
))
|
||||
.returning();
|
||||
|
||||
console.log('🔧 Fixed contactId and marked as read for emails from:', contact.email);
|
||||
} else {
|
||||
// Fallback: use old method if contact not found
|
||||
result = await db
|
||||
.update(emails)
|
||||
.set({ isRead: true, updatedAt: new Date() })
|
||||
.where(and(eq(emails.userId, userId), eq(emails.contactId, contactId), eq(emails.isRead, false)))
|
||||
.returning();
|
||||
}
|
||||
|
||||
console.log('✅ markContactEmailsAsRead result:', { count: result.length, contactId });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: result.length,
|
||||
emails: result, // Return the emails so controller can mark them on JMAP server
|
||||
};
|
||||
return updated.length;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get emails for a specific contact
|
||||
* Get contact by email address for specific email account
|
||||
*/
|
||||
export const getContactEmails = async (userId, contactId) => {
|
||||
export const getContactByEmail = async (emailAccountId, contactEmail) => {
|
||||
const [contact] = await db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(
|
||||
and(
|
||||
eq(contacts.emailAccountId, emailAccountId),
|
||||
eq(contacts.email, contactEmail)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return contact || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get contact emails with unread count
|
||||
*/
|
||||
export const getContactEmailsWithUnread = async (emailAccountId, contactId) => {
|
||||
const contactEmails = await db
|
||||
.select()
|
||||
.from(emails)
|
||||
.where(and(eq(emails.userId, userId), eq(emails.contactId, contactId)))
|
||||
.where(
|
||||
and(
|
||||
eq(emails.emailAccountId, emailAccountId),
|
||||
eq(emails.contactId, contactId)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(emails.date));
|
||||
|
||||
return contactEmails;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { db } from '../config/database.js';
|
||||
import { emailAccounts, users } from '../db/schema.js';
|
||||
import { emailAccounts, userEmailAccounts, users } from '../db/schema.js';
|
||||
import { encryptPassword, decryptPassword } from '../utils/password.js';
|
||||
import { validateJmapCredentials } from './email.service.js';
|
||||
import {
|
||||
@@ -12,40 +12,50 @@ import {
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
/**
|
||||
* Get all email accounts for a user
|
||||
* Get all email accounts for a user (cez many-to-many)
|
||||
*/
|
||||
export const getUserEmailAccounts = async (userId) => {
|
||||
const accounts = await db
|
||||
.select({
|
||||
id: emailAccounts.id,
|
||||
userId: emailAccounts.userId,
|
||||
email: emailAccounts.email,
|
||||
jmapAccountId: emailAccounts.jmapAccountId,
|
||||
isPrimary: emailAccounts.isPrimary,
|
||||
isActive: emailAccounts.isActive,
|
||||
isPrimary: userEmailAccounts.isPrimary,
|
||||
addedAt: userEmailAccounts.addedAt,
|
||||
createdAt: emailAccounts.createdAt,
|
||||
updatedAt: emailAccounts.updatedAt,
|
||||
})
|
||||
.from(emailAccounts)
|
||||
.where(eq(emailAccounts.userId, userId))
|
||||
.orderBy(emailAccounts.isPrimary, emailAccounts.createdAt);
|
||||
.from(userEmailAccounts)
|
||||
.innerJoin(emailAccounts, eq(userEmailAccounts.emailAccountId, emailAccounts.id))
|
||||
.where(eq(userEmailAccounts.userId, userId))
|
||||
.orderBy(userEmailAccounts.isPrimary, emailAccounts.createdAt);
|
||||
|
||||
return accounts;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a specific email account by ID
|
||||
* Get a specific email account by ID (check user has access)
|
||||
*/
|
||||
export const getEmailAccountById = async (accountId, userId) => {
|
||||
const [link] = await db
|
||||
.select()
|
||||
.from(userEmailAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(userEmailAccounts.emailAccountId, accountId),
|
||||
eq(userEmailAccounts.userId, userId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!link) {
|
||||
throw new NotFoundError('Email účet nenájdený alebo nemáte k nemu prístup');
|
||||
}
|
||||
|
||||
const [account] = await db
|
||||
.select()
|
||||
.from(emailAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(emailAccounts.id, accountId),
|
||||
eq(emailAccounts.userId, userId)
|
||||
)
|
||||
)
|
||||
.where(eq(emailAccounts.id, accountId))
|
||||
.limit(1);
|
||||
|
||||
if (!account) {
|
||||
@@ -59,40 +69,85 @@ export const getEmailAccountById = async (accountId, userId) => {
|
||||
* Get user's primary email account
|
||||
*/
|
||||
export const getPrimaryEmailAccount = async (userId) => {
|
||||
const [account] = await db
|
||||
.select()
|
||||
.from(emailAccounts)
|
||||
const [result] = await db
|
||||
.select({
|
||||
id: emailAccounts.id,
|
||||
email: emailAccounts.email,
|
||||
jmapAccountId: emailAccounts.jmapAccountId,
|
||||
isActive: emailAccounts.isActive,
|
||||
})
|
||||
.from(userEmailAccounts)
|
||||
.innerJoin(emailAccounts, eq(userEmailAccounts.emailAccountId, emailAccounts.id))
|
||||
.where(
|
||||
and(
|
||||
eq(emailAccounts.userId, userId),
|
||||
eq(emailAccounts.isPrimary, true)
|
||||
eq(userEmailAccounts.userId, userId),
|
||||
eq(userEmailAccounts.isPrimary, true)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return account || null;
|
||||
return result || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new email account with JMAP validation
|
||||
* Automatic many-to-many link vytvorí pre používateľa, ktorý účet vytvoril
|
||||
*/
|
||||
export const createEmailAccount = async (userId, email, emailPassword) => {
|
||||
// Check if email already exists for this user
|
||||
// Check if email account already exists (globally)
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(emailAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(emailAccounts.userId, userId),
|
||||
eq(emailAccounts.email, email)
|
||||
)
|
||||
)
|
||||
.where(eq(emailAccounts.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictError('Tento email účet už je pripojený');
|
||||
// Email account už existuje - skontroluj či user už má prístup
|
||||
const [userLink] = await db
|
||||
.select()
|
||||
.from(userEmailAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(userEmailAccounts.userId, userId),
|
||||
eq(userEmailAccounts.emailAccountId, existing.id)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (userLink) {
|
||||
throw new ConflictError('Tento email účet už máte pripojený');
|
||||
}
|
||||
|
||||
// Email account existuje, ale user ho nemá - môžeme ho zdieľať
|
||||
// Ale najprv overíme heslo
|
||||
try {
|
||||
await validateJmapCredentials(email, emailPassword);
|
||||
} catch (error) {
|
||||
throw new AuthenticationError('Nesprávne heslo k existujúcemu email účtu');
|
||||
}
|
||||
|
||||
// Check if this is the first email account for this user
|
||||
const existingAccounts = await getUserEmailAccounts(userId);
|
||||
const isFirst = existingAccounts.length === 0;
|
||||
|
||||
// Link user k existujúcemu accountu
|
||||
await db.insert(userEmailAccounts).values({
|
||||
userId,
|
||||
emailAccountId: existing.id,
|
||||
isPrimary: isFirst,
|
||||
});
|
||||
|
||||
return {
|
||||
id: existing.id,
|
||||
email: existing.email,
|
||||
jmapAccountId: existing.jmapAccountId,
|
||||
isPrimary: isFirst,
|
||||
isActive: existing.isActive,
|
||||
shared: true, // Indikuje že ide o zdieľaný account
|
||||
};
|
||||
}
|
||||
|
||||
// Email account neexistuje - vytvoríme nový
|
||||
// Validate JMAP credentials and get account ID
|
||||
let jmapAccountId;
|
||||
try {
|
||||
@@ -111,26 +166,36 @@ export const createEmailAccount = async (userId, email, emailPassword) => {
|
||||
const existingAccounts = await getUserEmailAccounts(userId);
|
||||
const isFirst = existingAccounts.length === 0;
|
||||
|
||||
// Create email account
|
||||
const [newAccount] = await db
|
||||
.insert(emailAccounts)
|
||||
.values({
|
||||
// Use transaction to create email account and link it to user
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// Create email account
|
||||
const [newAccount] = await tx
|
||||
.insert(emailAccounts)
|
||||
.values({
|
||||
email,
|
||||
emailPassword: encryptedPassword,
|
||||
jmapAccountId,
|
||||
isActive: true,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Link user to email account
|
||||
await tx.insert(userEmailAccounts).values({
|
||||
userId,
|
||||
email,
|
||||
emailPassword: encryptedPassword,
|
||||
jmapAccountId,
|
||||
emailAccountId: newAccount.id,
|
||||
isPrimary: isFirst, // First account is automatically primary
|
||||
isActive: true,
|
||||
})
|
||||
.returning();
|
||||
});
|
||||
|
||||
return newAccount;
|
||||
});
|
||||
|
||||
return {
|
||||
id: newAccount.id,
|
||||
email: newAccount.email,
|
||||
jmapAccountId: newAccount.jmapAccountId,
|
||||
isPrimary: newAccount.isPrimary,
|
||||
isActive: newAccount.isActive,
|
||||
createdAt: newAccount.createdAt,
|
||||
id: result.id,
|
||||
email: result.email,
|
||||
jmapAccountId: result.jmapAccountId,
|
||||
isPrimary: isFirst,
|
||||
isActive: result.isActive,
|
||||
shared: false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -138,9 +203,16 @@ export const createEmailAccount = async (userId, email, emailPassword) => {
|
||||
* Update email account password
|
||||
*/
|
||||
export const updateEmailAccountPassword = async (accountId, userId, newPassword) => {
|
||||
const account = await getEmailAccountById(accountId, userId);
|
||||
// Check user has access
|
||||
await getEmailAccountById(accountId, userId);
|
||||
|
||||
// Validate new JMAP credentials
|
||||
const [account] = await db
|
||||
.select()
|
||||
.from(emailAccounts)
|
||||
.where(eq(emailAccounts.id, accountId))
|
||||
.limit(1);
|
||||
|
||||
try {
|
||||
await validateJmapCredentials(account.email, newPassword);
|
||||
} catch (error) {
|
||||
@@ -173,10 +245,23 @@ export const updateEmailAccountPassword = async (accountId, userId, newPassword)
|
||||
* Toggle email account active status
|
||||
*/
|
||||
export const toggleEmailAccountStatus = async (accountId, userId, isActive) => {
|
||||
const account = await getEmailAccountById(accountId, userId);
|
||||
// Check user has access
|
||||
await getEmailAccountById(accountId, userId);
|
||||
|
||||
// Get user's link to check if it's primary
|
||||
const [link] = await db
|
||||
.select()
|
||||
.from(userEmailAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(userEmailAccounts.userId, userId),
|
||||
eq(userEmailAccounts.emailAccountId, accountId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
// Cannot deactivate primary account
|
||||
if (account.isPrimary && !isActive) {
|
||||
if (link.isPrimary && !isActive) {
|
||||
throw new ValidationError('Nemôžete deaktivovať primárny email účet');
|
||||
}
|
||||
|
||||
@@ -196,68 +281,126 @@ export const toggleEmailAccountStatus = async (accountId, userId, isActive) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Set an email account as primary
|
||||
* Set an email account as primary FOR SPECIFIC USER
|
||||
*/
|
||||
export const setPrimaryEmailAccount = async (accountId, userId) => {
|
||||
const account = await getEmailAccountById(accountId, userId);
|
||||
// Check user has access
|
||||
await getEmailAccountById(accountId, userId);
|
||||
|
||||
// Use transaction to prevent race conditions
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
// Remove primary flag from all accounts
|
||||
await db.transaction(async (tx) => {
|
||||
// Remove primary flag from all user's accounts
|
||||
await tx
|
||||
.update(emailAccounts)
|
||||
.set({ isPrimary: false, updatedAt: new Date() })
|
||||
.where(eq(emailAccounts.userId, userId));
|
||||
.update(userEmailAccounts)
|
||||
.set({ isPrimary: false })
|
||||
.where(eq(userEmailAccounts.userId, userId));
|
||||
|
||||
// Set new primary account
|
||||
const [updatedAccount] = await tx
|
||||
await tx
|
||||
.update(userEmailAccounts)
|
||||
.set({ isPrimary: true })
|
||||
.where(
|
||||
and(
|
||||
eq(userEmailAccounts.userId, userId),
|
||||
eq(userEmailAccounts.emailAccountId, accountId)
|
||||
)
|
||||
);
|
||||
|
||||
// Make sure email account is active
|
||||
await tx
|
||||
.update(emailAccounts)
|
||||
.set({
|
||||
isPrimary: true,
|
||||
isActive: true, // Primary account must be active
|
||||
isActive: true,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(emailAccounts.id, accountId))
|
||||
.returning();
|
||||
|
||||
return updatedAccount;
|
||||
.where(eq(emailAccounts.id, accountId));
|
||||
});
|
||||
|
||||
const [account] = await db
|
||||
.select()
|
||||
.from(emailAccounts)
|
||||
.where(eq(emailAccounts.id, accountId))
|
||||
.limit(1);
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
email: updated.email,
|
||||
isPrimary: updated.isPrimary,
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
isPrimary: true,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete an email account
|
||||
* NOTE: This will cascade delete all associated contacts and emails
|
||||
* Remove user's access to email account
|
||||
* Ak je to posledný používateľ, vymaže aj samotný email account
|
||||
*/
|
||||
export const deleteEmailAccount = async (accountId, userId) => {
|
||||
const account = await getEmailAccountById(accountId, userId);
|
||||
export const removeUserFromEmailAccount = async (accountId, userId) => {
|
||||
// Check user has access
|
||||
await getEmailAccountById(accountId, userId);
|
||||
|
||||
// Cannot delete primary account if it's the only one
|
||||
if (account.isPrimary) {
|
||||
const allAccounts = await getUserEmailAccounts(userId);
|
||||
if (allAccounts.length === 1) {
|
||||
throw new ValidationError('Nemôžete zmazať posledný email účet');
|
||||
// Get user's link
|
||||
const [link] = await db
|
||||
.select()
|
||||
.from(userEmailAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(userEmailAccounts.userId, userId),
|
||||
eq(userEmailAccounts.emailAccountId, accountId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
// If this is user's primary account, make sure they have another one to set as primary
|
||||
if (link.isPrimary) {
|
||||
const allUserAccounts = await getUserEmailAccounts(userId);
|
||||
if (allUserAccounts.length === 1) {
|
||||
throw new ValidationError('Nemôžete odstrániť posledný email účet');
|
||||
}
|
||||
|
||||
// If deleting primary account, make another account primary
|
||||
const otherAccount = allAccounts.find(acc => acc.id !== accountId);
|
||||
// Set another account as primary
|
||||
const otherAccount = allUserAccounts.find(acc => acc.id !== accountId);
|
||||
if (otherAccount) {
|
||||
await setPrimaryEmailAccount(otherAccount.id, userId);
|
||||
await db
|
||||
.update(userEmailAccounts)
|
||||
.set({ isPrimary: true })
|
||||
.where(
|
||||
and(
|
||||
eq(userEmailAccounts.userId, userId),
|
||||
eq(userEmailAccounts.emailAccountId, otherAccount.id)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete account (will cascade to contacts and emails)
|
||||
// Remove user's link
|
||||
await db
|
||||
.delete(emailAccounts)
|
||||
.where(eq(emailAccounts.id, accountId));
|
||||
.delete(userEmailAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(userEmailAccounts.userId, userId),
|
||||
eq(userEmailAccounts.emailAccountId, accountId)
|
||||
)
|
||||
);
|
||||
|
||||
// Check if this was the last user with access to this email account
|
||||
const [remainingLinks] = await db
|
||||
.select({ count: sql`count(*)::int` })
|
||||
.from(userEmailAccounts)
|
||||
.where(eq(userEmailAccounts.emailAccountId, accountId));
|
||||
|
||||
// If no users have access anymore, delete the email account itself
|
||||
if (remainingLinks.count === 0) {
|
||||
await db
|
||||
.delete(emailAccounts)
|
||||
.where(eq(emailAccounts.id, accountId));
|
||||
|
||||
return {
|
||||
message: 'Email účet bol úspešne odstránený (posledný používateľ)',
|
||||
deletedAccountId: accountId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'Email účet bol úspešne odstránený',
|
||||
message: 'Prístup k email účtu bol odobraný',
|
||||
deletedAccountId: accountId,
|
||||
};
|
||||
};
|
||||
@@ -267,7 +410,9 @@ export const deleteEmailAccount = async (accountId, userId) => {
|
||||
*/
|
||||
export const getEmailAccountWithCredentials = async (accountId, userId) => {
|
||||
logger.debug('getEmailAccountWithCredentials called', { accountId, userId });
|
||||
|
||||
const account = await getEmailAccountById(accountId, userId);
|
||||
|
||||
logger.debug('Account retrieved', {
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
@@ -286,3 +431,86 @@ export const getEmailAccountWithCredentials = async (accountId, userId) => {
|
||||
isActive: account.isActive,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all users with access to an email account
|
||||
*/
|
||||
export const getUsersWithAccessToAccount = async (accountId) => {
|
||||
const usersWithAccess = await db
|
||||
.select({
|
||||
userId: users.id,
|
||||
username: users.username,
|
||||
firstName: users.firstName,
|
||||
lastName: users.lastName,
|
||||
isPrimary: userEmailAccounts.isPrimary,
|
||||
addedAt: userEmailAccounts.addedAt,
|
||||
})
|
||||
.from(userEmailAccounts)
|
||||
.innerJoin(users, eq(userEmailAccounts.userId, users.id))
|
||||
.where(eq(userEmailAccounts.emailAccountId, accountId));
|
||||
|
||||
return usersWithAccess;
|
||||
};
|
||||
|
||||
/**
|
||||
* Share email account with another user
|
||||
*/
|
||||
export const shareEmailAccountWithUser = async (accountId, ownerId, targetUserId) => {
|
||||
// Check owner has access
|
||||
await getEmailAccountById(accountId, ownerId);
|
||||
|
||||
// Check target user exists
|
||||
const [targetUser] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, targetUserId))
|
||||
.limit(1);
|
||||
|
||||
if (!targetUser) {
|
||||
throw new NotFoundError('Cieľový používateľ nenájdený');
|
||||
}
|
||||
|
||||
// Check if target user already has access
|
||||
const [existingLink] = await db
|
||||
.select()
|
||||
.from(userEmailAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(userEmailAccounts.userId, targetUserId),
|
||||
eq(userEmailAccounts.emailAccountId, accountId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingLink) {
|
||||
throw new ConflictError('Používateľ už má prístup k tomuto email účtu');
|
||||
}
|
||||
|
||||
// Check if this is the first email account for target user
|
||||
const targetUserAccounts = await getUserEmailAccounts(targetUserId);
|
||||
const isFirst = targetUserAccounts.length === 0;
|
||||
|
||||
// Create link
|
||||
await db.insert(userEmailAccounts).values({
|
||||
userId: targetUserId,
|
||||
emailAccountId: accountId,
|
||||
isPrimary: isFirst,
|
||||
});
|
||||
|
||||
const [account] = await db
|
||||
.select()
|
||||
.from(emailAccounts)
|
||||
.where(eq(emailAccounts.id, accountId))
|
||||
.limit(1);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Email účet ${account.email} bol zdieľaný s používateľom ${targetUser.username}`,
|
||||
sharedWith: {
|
||||
userId: targetUser.id,
|
||||
username: targetUser.username,
|
||||
firstName: targetUser.firstName,
|
||||
lastName: targetUser.lastName,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -123,7 +123,7 @@ export const getIdentities = async (jmapConfig) => {
|
||||
* Discover potential contacts from JMAP (no DB storage)
|
||||
* Returns list of unique senders
|
||||
*/
|
||||
export const discoverContactsFromJMAP = async (jmapConfig, userId, searchTerm = '', limit = 50) => {
|
||||
export const discoverContactsFromJMAP = async (jmapConfig, emailAccountId, searchTerm = '', limit = 50) => {
|
||||
try {
|
||||
logger.info(`Discovering contacts from JMAP (search: "${searchTerm}")`);
|
||||
|
||||
@@ -167,11 +167,11 @@ export const discoverContactsFromJMAP = async (jmapConfig, userId, searchTerm =
|
||||
|
||||
const emailsList = getResponse.methodResponses[0][1].list;
|
||||
|
||||
// Get existing contacts for this user
|
||||
// Get existing contacts for this email account
|
||||
const existingContacts = await db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(eq(contacts.userId, userId));
|
||||
.where(eq(contacts.emailAccountId, emailAccountId));
|
||||
|
||||
const contactEmailsSet = new Set(existingContacts.map((c) => c.email.toLowerCase()));
|
||||
|
||||
@@ -216,7 +216,7 @@ export const discoverContactsFromJMAP = async (jmapConfig, userId, searchTerm =
|
||||
* Searches in: from, to, subject, and email body
|
||||
* Returns list of unique senders grouped by email address
|
||||
*/
|
||||
export const searchEmailsJMAP = async (jmapConfig, userId, query, limit = 50, offset = 0) => {
|
||||
export const searchEmailsJMAP = async (jmapConfig, emailAccountId, query, limit = 50, offset = 0) => {
|
||||
try {
|
||||
logger.info(`Searching emails in JMAP (query: "${query}", limit: ${limit}, offset: ${offset})`);
|
||||
|
||||
@@ -296,11 +296,11 @@ export const searchEmailsJMAP = async (jmapConfig, userId, query, limit = 50, of
|
||||
|
||||
const emailsList = getResponse.methodResponses[0][1].list;
|
||||
|
||||
// Get existing contacts for this user
|
||||
// Get existing contacts for this email account
|
||||
const existingContacts = await db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(eq(contacts.userId, userId));
|
||||
.where(eq(contacts.emailAccountId, emailAccountId));
|
||||
|
||||
const contactEmailsSet = new Set(existingContacts.map((c) => c.email.toLowerCase()));
|
||||
|
||||
@@ -342,30 +342,54 @@ export const searchEmailsJMAP = async (jmapConfig, userId, query, limit = 50, of
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync emails from a specific sender (when adding as contact)
|
||||
* 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,
|
||||
userId,
|
||||
emailAccountId,
|
||||
contactId,
|
||||
senderEmail,
|
||||
options = {}
|
||||
) => {
|
||||
const { limit = 500 } = options;
|
||||
const { limit = 50, daysBack = 30 } = options;
|
||||
|
||||
try {
|
||||
logger.info(`Syncing emails from sender: ${senderEmail} for account ${emailAccountId}`);
|
||||
logger.info(`Syncing emails with contact: ${senderEmail} for account ${emailAccountId}`);
|
||||
|
||||
// Query all emails from this sender
|
||||
const queryResponse = await jmapRequest(jmapConfig, [
|
||||
// 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('Inbox mailbox not found');
|
||||
}
|
||||
|
||||
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: 'OR',
|
||||
conditions: [{ from: senderEmail }, { to: senderEmail }],
|
||||
operator: 'AND',
|
||||
conditions: [
|
||||
{ inMailbox: inboxMailbox.id },
|
||||
{ from: senderEmail },
|
||||
{ after: dateThresholdISO }
|
||||
]
|
||||
},
|
||||
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||
limit,
|
||||
@@ -374,8 +398,39 @@ export const syncEmailsFromSender = async (
|
||||
],
|
||||
]);
|
||||
|
||||
const emailIds = queryResponse.methodResponses[0][1].ids;
|
||||
logger.info(`Found ${emailIds.length} emails from ${senderEmail}`);
|
||||
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 };
|
||||
@@ -427,6 +482,19 @@ export const syncEmailsFromSender = async (
|
||||
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()
|
||||
@@ -440,7 +508,6 @@ export const syncEmailsFromSender = async (
|
||||
|
||||
// Save email
|
||||
await db.insert(emails).values({
|
||||
userId,
|
||||
emailAccountId,
|
||||
contactId,
|
||||
jmapId: email.id,
|
||||
@@ -456,6 +523,7 @@ export const syncEmailsFromSender = async (
|
||||
'(Empty message)',
|
||||
date: email.receivedAt ? new Date(email.receivedAt) : new Date(),
|
||||
isRead,
|
||||
sentByUserId: null, // Prijatý email, nie odpoveď
|
||||
});
|
||||
|
||||
savedCount++;
|
||||
@@ -464,10 +532,10 @@ export const syncEmailsFromSender = async (
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`Synced ${savedCount} new emails from ${senderEmail}`);
|
||||
logger.success(`Synced ${savedCount} new emails with ${senderEmail}`);
|
||||
return { total: emailsList.length, saved: savedCount };
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync emails from sender', error);
|
||||
logger.error('Failed to sync emails with contact', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -636,13 +704,24 @@ export const sendEmail = async (jmapConfig, userId, emailAccountId, to, subject,
|
||||
|
||||
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({
|
||||
userId,
|
||||
emailAccountId,
|
||||
contactId: null, // Will be linked later if recipient is a contact
|
||||
contactId: recipientContact?.id || null, // Link to contact if recipient is in contacts
|
||||
jmapId: createdEmailId,
|
||||
messageId,
|
||||
threadId: threadId || messageId,
|
||||
@@ -653,8 +732,11 @@ export const sendEmail = async (jmapConfig, userId, emailAccountId, 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);
|
||||
|
||||
Reference in New Issue
Block a user