option for more emails,fix jmap service,add table email accounts

This commit is contained in:
richardtekula
2025-11-19 13:15:45 +01:00
parent 97f437c1c4
commit 1e7c1eab90
18 changed files with 1991 additions and 1299 deletions

View File

@@ -6,12 +6,19 @@ import { syncEmailsFromSender } from './jmap.service.js';
/**
* Get all contacts for a user
* If emailAccountId is provided, filter by that account, otherwise return all
*/
export const getUserContacts = async (userId) => {
export const getUserContacts = async (userId, emailAccountId = null) => {
const conditions = [eq(contacts.userId, userId)];
if (emailAccountId) {
conditions.push(eq(contacts.emailAccountId, emailAccountId));
}
const userContacts = await db
.select()
.from(contacts)
.where(eq(contacts.userId, userId))
.where(and(...conditions))
.orderBy(desc(contacts.addedAt));
return userContacts;
@@ -20,12 +27,18 @@ export const getUserContacts = async (userId) => {
/**
* Add a new contact and sync their emails
*/
export const addContact = async (userId, jmapConfig, email, name = '', notes = '') => {
// Check if contact already exists
export const addContact = async (userId, emailAccountId, jmapConfig, email, name = '', notes = '') => {
// Check if contact already exists for this email account
const [existing] = await db
.select()
.from(contacts)
.where(and(eq(contacts.userId, userId), eq(contacts.email, email)))
.where(
and(
eq(contacts.userId, userId),
eq(contacts.emailAccountId, emailAccountId),
eq(contacts.email, email)
)
)
.limit(1);
if (existing) {
@@ -37,6 +50,7 @@ export const addContact = async (userId, jmapConfig, email, name = '', notes = '
.insert(contacts)
.values({
userId,
emailAccountId,
email,
name: name || email.split('@')[0],
notes: notes || null,
@@ -45,7 +59,7 @@ export const addContact = async (userId, jmapConfig, email, name = '', notes = '
// Sync emails from this sender
try {
await syncEmailsFromSender(jmapConfig, userId, newContact.id, email);
await syncEmailsFromSender(jmapConfig, userId, emailAccountId, newContact.id, email);
} catch (error) {
console.error('Failed to sync emails for new contact:', error);
// Don't throw - contact was created successfully

View File

@@ -5,8 +5,15 @@ import { NotFoundError } from '../utils/errors.js';
/**
* Get all emails for a user (only from added contacts)
* If emailAccountId is provided, filter by that account
*/
export const getUserEmails = async (userId) => {
export const getUserEmails = async (userId, emailAccountId = null) => {
const conditions = [eq(emails.userId, userId)];
if (emailAccountId) {
conditions.push(eq(emails.emailAccountId, emailAccountId));
}
const userEmails = await db
.select({
id: emails.id,
@@ -21,6 +28,7 @@ export const getUserEmails = async (userId) => {
isRead: emails.isRead,
date: emails.date,
createdAt: emails.createdAt,
emailAccountId: emails.emailAccountId,
contact: {
id: contacts.id,
email: contacts.email,
@@ -29,7 +37,7 @@ export const getUserEmails = async (userId) => {
})
.from(emails)
.leftJoin(contacts, eq(emails.contactId, contacts.id))
.where(eq(emails.userId, userId))
.where(and(...conditions))
.orderBy(desc(emails.date));
return userEmails;
@@ -54,27 +62,31 @@ export const getEmailThread = async (userId, threadId) => {
/**
* Search emails (from, to, subject)
* If emailAccountId is provided, filter by that account
*/
export const searchEmails = async (userId, query) => {
export const searchEmails = async (userId, query, emailAccountId = null) => {
if (!query || query.trim().length < 2) {
throw new Error('Search term must be at least 2 characters');
}
const searchPattern = `%${query}%`;
const conditions = [
eq(emails.userId, userId),
or(
like(emails.from, searchPattern),
like(emails.to, searchPattern),
like(emails.subject, searchPattern)
),
];
if (emailAccountId) {
conditions.push(eq(emails.emailAccountId, emailAccountId));
}
const results = await db
.select()
.from(emails)
.where(
and(
eq(emails.userId, userId),
or(
like(emails.from, searchPattern),
like(emails.to, searchPattern),
like(emails.subject, searchPattern)
)
)
)
.where(and(...conditions))
.orderBy(desc(emails.date))
.limit(50);
@@ -83,14 +95,34 @@ export const searchEmails = async (userId, query) => {
/**
* Get unread email count
* Returns total count and counts per email account
*/
export const getUnreadCount = async (userId) => {
const result = await db
// Get total unread count
const totalResult = await db
.select({ count: sql`count(*)::int` })
.from(emails)
.where(and(eq(emails.userId, userId), eq(emails.isRead, false)));
return result[0]?.count || 0;
const totalUnread = totalResult[0]?.count || 0;
// Get unread count per email account
const accountCounts = await db
.select({
emailAccountId: emails.emailAccountId,
count: sql`count(*)::int`,
})
.from(emails)
.where(and(eq(emails.userId, userId), eq(emails.isRead, false)))
.groupBy(emails.emailAccountId);
return {
totalUnread,
accounts: accountCounts.map((ac) => ({
emailAccountId: ac.emailAccountId,
unreadCount: ac.count,
})),
};
};
/**

View File

@@ -0,0 +1,282 @@
import { eq, and } from 'drizzle-orm';
import { db } from '../config/database.js';
import { emailAccounts, users } from '../db/schema.js';
import { encryptPassword, decryptPassword } from '../utils/password.js';
import { validateJmapCredentials } from './email.service.js';
import {
NotFoundError,
ValidationError,
ConflictError,
AuthenticationError,
} from '../utils/errors.js';
/**
* Get all email accounts for a user
*/
export const getUserEmailAccounts = async (userId) => {
const accounts = await db
.select({
id: emailAccounts.id,
userId: emailAccounts.userId,
email: emailAccounts.email,
jmapAccountId: emailAccounts.jmapAccountId,
isPrimary: emailAccounts.isPrimary,
isActive: emailAccounts.isActive,
createdAt: emailAccounts.createdAt,
updatedAt: emailAccounts.updatedAt,
})
.from(emailAccounts)
.where(eq(emailAccounts.userId, userId))
.orderBy(emailAccounts.isPrimary, emailAccounts.createdAt);
return accounts;
};
/**
* Get a specific email account by ID
*/
export const getEmailAccountById = async (accountId, userId) => {
const [account] = await db
.select()
.from(emailAccounts)
.where(
and(
eq(emailAccounts.id, accountId),
eq(emailAccounts.userId, userId)
)
)
.limit(1);
if (!account) {
throw new NotFoundError('Email účet nenájdený');
}
return account;
};
/**
* Get user's primary email account
*/
export const getPrimaryEmailAccount = async (userId) => {
const [account] = await db
.select()
.from(emailAccounts)
.where(
and(
eq(emailAccounts.userId, userId),
eq(emailAccounts.isPrimary, true)
)
)
.limit(1);
return account || null;
};
/**
* Create a new email account with JMAP validation
*/
export const createEmailAccount = async (userId, email, emailPassword) => {
// Check if email already exists for this user
const [existing] = await db
.select()
.from(emailAccounts)
.where(
and(
eq(emailAccounts.userId, userId),
eq(emailAccounts.email, email)
)
)
.limit(1);
if (existing) {
throw new ConflictError('Tento email účet už je pripojený');
}
// Validate JMAP credentials and get account ID
let jmapAccountId;
try {
const validation = await validateJmapCredentials(email, emailPassword);
jmapAccountId = validation.accountId;
} catch (error) {
throw new AuthenticationError(
'Nepodarilo sa pripojiť k emailovému účtu. Skontrolujte prihlasovacie údaje.'
);
}
// Encrypt password
const encryptedPassword = encryptPassword(emailPassword);
// Check if this is the first email account for this user
const existingAccounts = await getUserEmailAccounts(userId);
const isFirst = existingAccounts.length === 0;
// Create email account
const [newAccount] = await db
.insert(emailAccounts)
.values({
userId,
email,
emailPassword: encryptedPassword,
jmapAccountId,
isPrimary: isFirst, // First account is automatically primary
isActive: true,
})
.returning();
return {
id: newAccount.id,
email: newAccount.email,
jmapAccountId: newAccount.jmapAccountId,
isPrimary: newAccount.isPrimary,
isActive: newAccount.isActive,
createdAt: newAccount.createdAt,
};
};
/**
* Update email account password
*/
export const updateEmailAccountPassword = async (accountId, userId, newPassword) => {
const account = await getEmailAccountById(accountId, userId);
// Validate new JMAP credentials
try {
await validateJmapCredentials(account.email, newPassword);
} catch (error) {
throw new AuthenticationError(
'Nepodarilo sa overiť nové heslo. Skontrolujte prihlasovacie údaje.'
);
}
// Encrypt password
const encryptedPassword = encryptPassword(newPassword);
// Update password
const [updated] = await db
.update(emailAccounts)
.set({
emailPassword: encryptedPassword,
updatedAt: new Date(),
})
.where(eq(emailAccounts.id, accountId))
.returning();
return {
id: updated.id,
email: updated.email,
updatedAt: updated.updatedAt,
};
};
/**
* Toggle email account active status
*/
export const toggleEmailAccountStatus = async (accountId, userId, isActive) => {
const account = await getEmailAccountById(accountId, userId);
// Cannot deactivate primary account
if (account.isPrimary && !isActive) {
throw new ValidationError('Nemôžete deaktivovať primárny email účet');
}
const [updated] = await db
.update(emailAccounts)
.set({
isActive,
updatedAt: new Date(),
})
.where(eq(emailAccounts.id, accountId))
.returning();
return {
id: updated.id,
isActive: updated.isActive,
};
};
/**
* Set an email account as primary
*/
export const setPrimaryEmailAccount = async (accountId, userId) => {
const account = await getEmailAccountById(accountId, userId);
// Remove primary flag from all accounts
await db
.update(emailAccounts)
.set({ isPrimary: false, updatedAt: new Date() })
.where(eq(emailAccounts.userId, userId));
// Set new primary account
const [updated] = await db
.update(emailAccounts)
.set({
isPrimary: true,
isActive: true, // Primary account must be active
updatedAt: new Date(),
})
.where(eq(emailAccounts.id, accountId))
.returning();
return {
id: updated.id,
email: updated.email,
isPrimary: updated.isPrimary,
};
};
/**
* Delete an email account
* NOTE: This will cascade delete all associated contacts and emails
*/
export const deleteEmailAccount = async (accountId, userId) => {
const account = await getEmailAccountById(accountId, userId);
// Cannot delete primary account if it's the only one
if (account.isPrimary) {
const allAccounts = await getUserEmailAccounts(userId);
if (allAccounts.length === 1) {
throw new ValidationError('Nemôžete zmazať posledný email účet');
}
// If deleting primary account, make another account primary
const otherAccount = allAccounts.find(acc => acc.id !== accountId);
if (otherAccount) {
await setPrimaryEmailAccount(otherAccount.id, userId);
}
}
// Delete account (will cascade to contacts and emails)
await db
.delete(emailAccounts)
.where(eq(emailAccounts.id, accountId));
return {
message: 'Email účet bol úspešne odstránený',
deletedAccountId: accountId,
};
};
/**
* Get email account with decrypted password (for JMAP operations)
*/
export const getEmailAccountWithCredentials = async (accountId, userId) => {
console.log('🔐 getEmailAccountWithCredentials called:', { accountId, userId });
const account = await getEmailAccountById(accountId, userId);
console.log('📦 Account retrieved:', {
id: account.id,
email: account.email,
hasPassword: !!account.emailPassword,
passwordLength: account.emailPassword?.length
});
const decryptedPassword = decryptPassword(account.emailPassword);
console.log('🔓 Password decrypted, length:', decryptedPassword?.length);
return {
id: account.id,
email: account.email,
emailPassword: decryptedPassword,
jmapAccountId: account.jmapAccountId,
isActive: account.isActive,
};
};

View File

@@ -11,7 +11,7 @@ import { decryptPassword } from '../utils/password.js';
*/
/**
* Get JMAP configuration for user
* Get JMAP configuration for user (legacy - for backward compatibility)
*/
export const getJmapConfig = (user) => {
if (!user.email || !user.emailPassword || !user.jmapAccountId) {
@@ -29,6 +29,24 @@ export const getJmapConfig = (user) => {
};
};
/**
* Get JMAP configuration from email account object
* NOTE: Expects emailPassword to be already decrypted (from getEmailAccountWithCredentials)
*/
export const getJmapConfigFromAccount = (emailAccount) => {
if (!emailAccount.email || !emailAccount.emailPassword || !emailAccount.jmapAccountId) {
throw new Error('Email account je neúplný');
}
// Password is already decrypted by getEmailAccountWithCredentials
return {
server: process.env.JMAP_SERVER || 'https://mail.truemail.sk/jmap/',
username: emailAccount.email,
password: emailAccount.emailPassword,
accountId: emailAccount.jmapAccountId,
};
};
/**
* Make JMAP API request
*/
@@ -329,6 +347,7 @@ export const searchEmailsJMAP = async (jmapConfig, userId, query, limit = 50, of
export const syncEmailsFromSender = async (
jmapConfig,
userId,
emailAccountId,
contactId,
senderEmail,
options = {}
@@ -336,7 +355,7 @@ export const syncEmailsFromSender = async (
const { limit = 500 } = options;
try {
logger.info(`Syncing emails from sender: ${senderEmail}`);
logger.info(`Syncing emails from sender: ${senderEmail} for account ${emailAccountId}`);
// Query all emails from this sender
const queryResponse = await jmapRequest(jmapConfig, [
@@ -402,6 +421,12 @@ export const syncEmailsFromSender = async (
const inReplyTo = Array.isArray(email.inReplyTo) ? email.inReplyTo[0] : email.inReplyTo;
const isRead = email.keywords && email.keywords['$seen'] === true;
// Skip emails without from or to (malformed data)
if (!fromEmail && !toEmail) {
logger.warn(`Skipping email ${messageId} - missing both from and to fields`);
continue;
}
// Skip if already exists
const [existing] = await db
.select()
@@ -416,6 +441,7 @@ export const syncEmailsFromSender = async (
// Save email
await db.insert(emails).values({
userId,
emailAccountId,
contactId,
jmapId: email.id,
messageId,
@@ -527,7 +553,7 @@ export const markEmailAsRead = async (jmapConfig, userId, jmapId, isRead) => {
/**
* Send email via JMAP
*/
export const sendEmail = async (jmapConfig, userId, to, subject, body, inReplyTo = null, threadId = null) => {
export const sendEmail = async (jmapConfig, userId, emailAccountId, to, subject, body, inReplyTo = null, threadId = null) => {
try {
logger.info(`Sending email to: ${to}`);
@@ -615,6 +641,7 @@ export const sendEmail = async (jmapConfig, userId, to, subject, body, inReplyTo
await db.insert(emails).values({
userId,
emailAccountId,
contactId: null, // Will be linked later if recipient is a contact
jmapId: createdEmailId,
messageId,