initialize git, basic setup for crm

This commit is contained in:
richardtekula
2025-11-18 13:53:28 +01:00
commit da01d586fc
47 changed files with 12776 additions and 0 deletions

View 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,
});
};

View 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;
};

View 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;
};

View 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;
};

View 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);
});
};

View 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;
}
};