Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- **Edit email categories and sync Important.** Use the Categories menu in the
message toolbar or email list to review and change assignments. Important
remains independent of categories and stars. Sarv uses its native `Important`
flag; Gmail uses its native importance label. Changes are queued for the
message's own account and mailbox, and a changed mailbox identity stops a
stale operation. Gmail's built-in category tabs can be changed after OAuth
sign-in; unsupported server changes show an error before saving locally.
- **Respect existing server classifications.** Recognized provider category
folders, keywords, Gmail categories and Important suppress another automatic
AI categorization request. Server changes refresh local assignments, and an
intentional manual selection, including clearing every category, takes
precedence while syncing. A temporary failure reading Gmail categories
defers categorization until a successful sync. Conversation and contact
processing remain available; categorizer-driven actions do not run from a
provider classification alone.
- **Provider-first onboarding.** Choose email, connect with Sarv/Gmail OAuth or
manual IMAP/SMTP, then choose an optional AI provider and test a model. Sarv
email sign-in reuses its AI session. Provider cards advance immediately, and
Expand Down
8 changes: 4 additions & 4 deletions apps/desktop/electron/ipc/agent-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,7 @@ function generateId(prefix: string): string {
/**
* Get the agent repository from storage
*/
function getAgentRepo() {
const storage = requireStorage();
function getAgentRepo(storage = requireStorage()) {
const repos = (storage as any).getRepositories();
return repos.agent;
}
Expand Down Expand Up @@ -120,9 +119,11 @@ export async function logUserAction(
source?: ActionSource;
senderAddress?: string | null;
} = {},
accountStorage?: ReturnType<typeof requireStorage>,
): Promise<void> {
try {
const agentRepo = getAgentRepo();
const storage = accountStorage ?? requireStorage();
const agentRepo = getAgentRepo(storage);
await agentRepo.logAction({
id: generateId('act'),
emailId,
Expand All @@ -138,7 +139,6 @@ export async function logUserAction(
// Also update existing sender_stats aggregates (reuses existing system, no duplication)
const sender = options.senderAddress;
if (sender) {
const storage = requireStorage();
const statsUpdate: Record<string, number> = {};
if (actionType === 'read') statsUpdate.readCount = 1;
else if (actionType === 'delete') statsUpdate.deletedCount = 1;
Expand Down
12 changes: 8 additions & 4 deletions apps/desktop/electron/ipc/ai-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
import { processingBreakdown, type SQLiteStorage } from '@sarvinbox/storage-node';
import { ipcMain } from 'electron';

import { openAccountStorages, requireAccountStorage } from '../services/account-target';
import { openAccountStorages, requireAccountStorage, requireNamedOrActiveStorage } from '../services/account-target';
import { readRegistryAccounts } from '../services/accounts-registry';
import { getAutoBacklogCap, setAutoBacklogCap } from '../services/ai-backlog-cap';
import { getAllAiSecrets, setAiSecret, deleteAiSecret, isSecureStorageAvailable } from '../services/ai-secret-store';
Expand Down Expand Up @@ -197,9 +197,9 @@ export function registerAIHandlers(): void {
/**
* Get all category definitions
*/
ipcMain.handle('ai:getCategoryDefinitions', async () => {
ipcMain.handle('ai:getCategoryDefinitions', async (_event, accountId?: string) => {
try {
const storage = requireStorage();
const storage = await requireNamedOrActiveStorage(accountId);
const defs = storage.getCategoryDefinitions();
return { success: true, data: defs };
} catch (error) {
Expand All @@ -211,8 +211,12 @@ export function registerAIHandlers(): void {
/**
* Get category assignments for a batch of email IDs
*/
ipcMain.handle('ai:getEmailCategoriesBatch', async (_event, emailIds: string[]) => {
ipcMain.handle('ai:getEmailCategoriesBatch', async (_event, emailIds: string[], accountId?: string) => {
try {
if (accountId) {
const storage = await requireNamedOrActiveStorage(accountId);
return { success: true, data: storage.getEmailCategoriesBatch(emailIds) || {} };
}
// Query EVERY account and merge — the requested ids come from the unified
// list and can belong to any account. Each id lives in exactly one
// account's db, so the first non-empty result for an id wins.
Expand Down
53 changes: 24 additions & 29 deletions apps/desktop/electron/ipc/email-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ import {
stopManualBodyDownload,
getManualBodyDownloadState,
} from '../services/body-prefetch-scheduler';
import { changeEmailCategory } from '../services/classification-actions';
import { getHeaderBackfillState, kickHeaderBackfill } from '../services/header-backfill';
import { reportSenderVerdict } from '../services/reputation-service';
import { applyUserSpamVerdict } from '../services/spam-verdict-actions';
import { getSyncEngine, getMainWindow, getExtensionManager, requireStorage, requireSyncEngine } from '../shared';
import { getSyncEngine, getMainWindow, getExtensionManager, getAccountIdForStorage, requireStorage, requireSyncEngine } from '../shared';

import { logUserAction } from './agent-handlers';

Expand Down Expand Up @@ -1355,43 +1356,37 @@ export function registerEmailHandlers(): void {
}
});

/**
* Mark email as important/not important (local tag only, no IMAP sync)
*/
ipcMain.handle('emails:markImportant', async (_event, emailId: string, important: boolean) => {
/** Mark importance locally and queue the native provider flag durably. */
ipcMain.handle('emails:markImportant', async (_event, emailId: string, important: boolean, accountId?: string) => {
try {
const storage = requireStorage();

const { storage, syncEngine } = await resolveNamedOrActiveAccountTarget(accountId);
const result = await changeEmailCategory(storage, syncEngine, emailId, 'important', important);
const email = await storage.getEmail(emailId);
if (!email) {
return { success: false, error: 'Email not found' };
}

const tags = email.tags || '||';
const isCurrentlyImportant = tags.includes('|important|');

if (important && !isCurrentlyImportant) {
const tagList = tags.split('|').filter((t: string) => t.length > 0);
tagList.push('important');
await storage.updateEmail(emailId, { tags: '|' + tagList.join('|') + '|' });
} else if (!important && isCurrentlyImportant) {
const tagList = tags.split('|').filter((t: string) => t.length > 0 && t !== 'important');
await storage.updateEmail(emailId, { tags: tagList.length > 0 ? '|' + tagList.join('|') + '|' : '||' });
}

// Log action for agent learning
logUserAction(emailId, important ? 'important' : 'unimportant', {
threadId: email.threadId,
senderAddress: email.fromAddress,
});

return { success: true };
threadId: email?.threadId,
senderAddress: email?.fromAddress,
}, storage);
if (email) getMainWindow()?.webContents.send('emails:tags-updated', { emailId, tags: email.tags, accountId: getAccountIdForStorage(storage) });
return { success: true, data: result.categories, syncStatus: result.syncStatus };
} catch (error) {
logger.error('Mark important error:', error);
return { success: false, error: (error as Error).message };
}
});

ipcMain.handle('emails:setCategory', async (_event, emailId: string, slug: string, on: boolean, accountId?: string) => {
try {
const { storage, syncEngine } = await resolveNamedOrActiveAccountTarget(accountId);
const result = await changeEmailCategory(storage, syncEngine, emailId, slug, on);
const email = await storage.getEmail(emailId);
if (email) getMainWindow()?.webContents.send('emails:tags-updated', { emailId, tags: email.tags, accountId: getAccountIdForStorage(storage) });
return { success: true, data: result.categories, syncStatus: result.syncStatus };
} catch (error) {
logger.error('Change category error:', error);
return { success: false, error: (error as Error).message };
}
});

/**
* Sync starred emails from server
*/
Expand Down
18 changes: 11 additions & 7 deletions apps/desktop/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,8 +347,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
},
markStarred: (emailId: string, starred: boolean, accountId?: string) =>
ipcRenderer.invoke('emails:markStarred', emailId, starred, accountId),
markImportant: (emailId: string, important: boolean) =>
ipcRenderer.invoke('emails:markImportant', emailId, important),
markImportant: (emailId: string, important: boolean, accountId?: string) =>
ipcRenderer.invoke('emails:markImportant', emailId, important, accountId),
syncStarred: () => ipcRenderer.invoke('emails:syncStarred'),
moveToFolder: (emailId: string, folderId: string, accountId?: string) =>
ipcRenderer.invoke('emails:moveToFolder', emailId, folderId, accountId),
Expand Down Expand Up @@ -654,8 +654,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
getByCategory: (category: string, limit?: number, offset?: number, folderId?: string) =>
ipcRenderer.invoke('ai:getByCategory', category, limit, offset, folderId),
getCategoryCounts: (folderId?: string, mode?: 'unread' | 'total') => ipcRenderer.invoke('ai:getCategoryCounts', folderId, mode),
getCategoryDefinitions: () => ipcRenderer.invoke('ai:getCategoryDefinitions'),
getEmailCategoriesBatch: (emailIds: string[]) => ipcRenderer.invoke('ai:getEmailCategoriesBatch', emailIds),
getCategoryDefinitions: (accountId?: string) => ipcRenderer.invoke('ai:getCategoryDefinitions', accountId),
setEmailCategory: (emailId: string, slug: string, on: boolean, accountId?: string) =>
ipcRenderer.invoke('emails:setCategory', emailId, slug, on, accountId),
getEmailCategoriesBatch: (emailIds: string[], accountId?: string) => ipcRenderer.invoke('ai:getEmailCategoriesBatch', emailIds, accountId),
upsertCategoryDefinition: (def: any) => ipcRenderer.invoke('ai:upsertCategoryDefinition', def),
deleteCategoryDefinition: (slug: string) => ipcRenderer.invoke('ai:deleteCategoryDefinition', slug),
toggleCategoryDefinition: (slug: string, enabled: boolean) => ipcRenderer.invoke('ai:toggleCategoryDefinition', slug, enabled),
Expand Down Expand Up @@ -1359,7 +1361,7 @@ export interface ElectronAPI {
callback: (update: { emailId: string; accountId: string | null; tags: string }) => void
) => () => void;
markStarred: (emailId: string, starred: boolean, accountId?: string) => Promise<{ success: boolean; error?: string }>;
markImportant: (emailId: string, important: boolean) => Promise<{ success: boolean; error?: string }>;
markImportant: (emailId: string, important: boolean, accountId?: string) => Promise<{ success: boolean; error?: string }>;
syncStarred: () => Promise<{ success: boolean; data?: { synced: number; total: number }; error?: string }>;
moveToFolder: (emailId: string, folderId: string, accountId?: string) => Promise<{ success: boolean; error?: string }>;
restoreFromTrash: (emailIds: string[], accountId?: string) => Promise<{ success: boolean; data?: { restoredIds: string[]; failedIds: string[] }; error?: string }>;
Expand Down Expand Up @@ -1584,9 +1586,11 @@ export interface ElectronAPI {
Promise<{ success: boolean; data?: any[]; error?: string }>;
getCategoryCounts: (folderId?: string, mode?: 'unread' | 'total') =>
Promise<{ success: boolean; data?: Record<string, number>; error?: string }>;
getCategoryDefinitions: () =>
getCategoryDefinitions: (accountId?: string) =>
Promise<{ success: boolean; data?: any[]; error?: string }>;
getEmailCategoriesBatch: (emailIds: string[]) =>
setEmailCategory: (emailId: string, slug: string, on: boolean, accountId?: string) =>
Promise<{ success: boolean; data?: string[]; syncStatus?: 'queued' | 'success' | 'local-only'; error?: string }>;
getEmailCategoriesBatch: (emailIds: string[], accountId?: string) =>
Promise<{ success: boolean; data?: Record<string, string[]>; error?: string }>;
upsertCategoryDefinition: (def: any) =>
Promise<{ success: boolean; error?: string }>;
Expand Down
11 changes: 10 additions & 1 deletion apps/desktop/electron/services/ai-categorization-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* Runs entirely in the Electron main process for reliability.
*/

import { cleanLLMJsonResponse, tryParseLLMJson, salvageJsonArrayWithDiagnostics, extractBalancedJsonArray, cleanEmailHtmlForLLM, isConnectionError, isUpstreamError, describeNetworkError, classifyAIError, createLogger, applySecurityGate, buildSecurityContext, formatSecurityLines, PHISHING_PROMPT, SPAM_PROMPT, buildAIAuthHeaders } from '@sarvinbox/core';
import { cleanLLMJsonResponse, tryParseLLMJson, salvageJsonArrayWithDiagnostics, extractBalancedJsonArray, cleanEmailHtmlForLLM, isConnectionError, isUpstreamError, describeNetworkError, classifyAIError, createLogger, applySecurityGate, buildSecurityContext, formatSecurityLines, PHISHING_PROMPT, SPAM_PROMPT, buildAIAuthHeaders, existingCategoryClassification, automaticCategorizationDeferred } from '@sarvinbox/core';
import type { EmailRecord , AIErrorInfo, EmailSecurityContext } from '@sarvinbox/core';

import { getMainWindow, requireStorage } from '../shared';
Expand Down Expand Up @@ -777,6 +777,7 @@ Return JSON array:
if ((email.tags || '').includes('|read|')) {
continue;
}
if (existingCategoryClassification(email) || automaticCategorizationDeferred(email)) continue;
// Body for the LLM prompt — see categorizationBodyOf.
const body = categorizationBodyOf(email);
const fromAddr = (email.fromAddress || '').toLowerCase();
Expand Down Expand Up @@ -1200,6 +1201,14 @@ Return format (categories is an array of matching slugs from: ${categorySlugs}):

private async saveBatch(results: CategorizationResult[], originalEmails: EmailForCategorization[]): Promise<void> {
const storage = requireStorage();
// A provider refresh/manual edit received mid-request must win at save time.
const eligibleResults: CategorizationResult[] = [];
for (const result of results) {
const current = await storage.getEmail(result.emailId);
if (current && !existingCategoryClassification(current) && !automaticCategorizationDeferred(current)) eligibleResults.push(result);
}
results = eligibleResults;
if (results.length === 0) return;
const processedAt = Math.floor(Date.now() / 1000);

const batch = results.map(r => ({
Expand Down
Loading
Loading