option for more emails,fix jmap service,add table email accounts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
282
src/services/email-account.service.js
Normal file
282
src/services/email-account.service.js
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user