diff --git a/src/main/db/index.ts b/src/main/db/index.ts index 85772919..4315e6df 100644 --- a/src/main/db/index.ts +++ b/src/main/db/index.ts @@ -928,12 +928,43 @@ export function hasEmailsForAccount(accountId: string): boolean { export function getInboxThreadIds(accountId: string): Set { const db = getDatabase(); const stmt = db.prepare( - `SELECT DISTINCT thread_id FROM emails WHERE account_id = ? AND (label_ids IS NULL OR label_ids LIKE '%"INBOX"%')`, + `SELECT DISTINCT thread_id + FROM emails + WHERE account_id = ? + AND ( + label_ids IS NULL + OR EXISTS (SELECT 1 FROM json_each(label_ids) WHERE value = 'INBOX') + )`, ); const rows = stmt.all(accountId) as { thread_id: string }[]; return new Set(rows.map((r) => r.thread_id)); } +export type EmailLabelState = { + id: string; + labelIds: string[] | undefined; +}; + +export function getVisibleInboxLabelStates(accountId: string, limit: number): EmailLabelState[] { + const db = getDatabase(); + const stmt = db.prepare(` + SELECT id, label_ids as labelIds + FROM emails + WHERE account_id = ? + AND ( + label_ids IS NULL + OR EXISTS (SELECT 1 FROM json_each(label_ids) WHERE value = 'INBOX') + ) + ORDER BY date DESC + LIMIT ? + `); + const rows = stmt.all(accountId, limit) as Array<{ id: string; labelIds: string | null }>; + return rows.map((row) => ({ + id: row.id, + labelIds: parseLabelIds(row.labelIds), + })); +} + export function getEmailIds(accountId: string): Set { const db = getDatabase(); const stmt = db.prepare(`SELECT id FROM emails WHERE account_id = ?`); @@ -1369,17 +1400,24 @@ function stripLargeDataUris(body: string): string { ); } -function rowToDashboardEmail(row: Record): DashboardEmail { - // Parse labelIds from JSON string if present - let labelIds: string[] | undefined; - if (row.labelIds && typeof row.labelIds === "string") { - try { - labelIds = JSON.parse(row.labelIds); - } catch { - labelIds = undefined; +function parseLabelIds(value: unknown): string[] | undefined { + if (!value || typeof value !== "string") return undefined; + + try { + const parsed: unknown = JSON.parse(value); + if (Array.isArray(parsed) && parsed.every((label) => typeof label === "string")) { + return parsed; } + } catch { + return undefined; } + return undefined; +} + +function rowToDashboardEmail(row: Record): DashboardEmail { + const labelIds = parseLabelIds(row.labelIds); + // Parse attachments from JSON string if present // eslint-disable-next-line @typescript-eslint/consistent-type-imports let attachments: import("../../shared/types").AttachmentMeta[] | undefined; diff --git a/src/main/ipc/sync.ipc.ts b/src/main/ipc/sync.ipc.ts index 7cd50294..ee72f43c 100644 --- a/src/main/ipc/sync.ipc.ts +++ b/src/main/ipc/sync.ipc.ts @@ -271,10 +271,7 @@ export function registerSyncIpc(): void { return { success: false, error: "Another re-authentication is already in progress" }; } - const client = activeClients.get(accountId); - if (!client) { - return { success: false, error: "Account not connected" }; - } + const client = activeClients.get(accountId) ?? new GmailClient(accountId); pendingReauthClient = client; await client.reauth(); @@ -282,6 +279,7 @@ export function registerSyncIpc(): void { // Re-register with sync service and restart sync await emailSyncService.registerAccount(client); + activeClients.set(accountId, client); emailSyncService.startSync(accountId); log.info(`[Auth] Re-authenticated account ${accountId}, sync restarted`); @@ -873,9 +871,7 @@ export function registerSyncIpc(): void { } catch (err) { log.error({ err: err }, `[Sync] Failed to connect account ${account.id}`); - // Still store the client reference so reauth can use it - const client = new GmailClient(account.id); - activeClients.set(account.id, client); + activeClients.delete(account.id); connectedAccounts.push({ accountId: account.id, @@ -1556,7 +1552,7 @@ export function registerSyncIpc(): void { accountId, query, maxResults = 50, - }: { accountId: string; query: string; maxResults?: number }, + }: { accountId?: string; query: string; maxResults?: number }, ): Promise> => { if (useFakeData) { const { DEMO_INBOX_EMAILS, DEMO_EXPECTED_ANALYSIS } = await import("../demo/fake-inbox"); diff --git a/src/main/services/email-sync.ts b/src/main/services/email-sync.ts index a6ce380e..e27c70ce 100644 --- a/src/main/services/email-sync.ts +++ b/src/main/services/email-sync.ts @@ -1,4 +1,4 @@ -import { type GmailClient, isAuthError } from "./gmail-client"; +import { type GmailClient, isAuthError, isNotFoundError } from "./gmail-client"; import { saveEmail, deleteEmail, @@ -7,6 +7,7 @@ import { hasEmailsForAccount, getEmailIds, getInboxThreadIds, + getVisibleInboxLabelStates, getEmail, updateEmailLabelIds, deleteArchiveReadyForThreads, @@ -25,6 +26,14 @@ import { createLogger } from "./logger"; const log = createLogger("sync"); const DEFAULT_SYNC_INTERVAL = 30000; // 30 seconds +const INBOX_LABEL_RECONCILE_LIMIT = 25; +const INBOX_LABEL_RECONCILE_CONCURRENCY = 5; + +function sameLabelSet(left: string[], right: string[]): boolean { + if (left.length !== right.length) return false; + const rightSet = new Set(right); + return left.every((label) => rightSet.has(label)); +} export type SyncStatus = "idle" | "syncing" | "error"; export type AccountInfo = { @@ -54,6 +63,7 @@ class EmailSyncService { private healthCheckIntervalId: NodeJS.Timeout | null = null; // Tracks whether we've done the one-time sent backfill per account private sentBackfillDone: Set = new Set(); + private inboxLabelReconcileDone: Set = new Set(); private onNewEmails?: (accountId: string, emails: DashboardEmail[]) => void; private onNewSentEmails?: (accountId: string, emails: DashboardEmail[]) => void; private onSyncStatusChange?: (accountId: string, status: SyncStatus) => void; @@ -124,6 +134,11 @@ class EmailSyncService { */ async registerAccount(client: GmailClient): Promise { const accountId = client.getAccountId(); + const existingAccount = this.accounts.get(accountId); + + if (existingAccount?.intervalId) { + clearInterval(existingAccount.intervalId); + } // Get profile to retrieve email address, and display name for account setup const [profile, displayName] = await Promise.all([ @@ -344,6 +359,78 @@ class EmailSyncService { this.onSyncProgress = callback; } + /** + * Repair locally visible inbox rows against Gmail's current labels once per session. + * This backfills stale rows from older label-scoped History API syncs that could + * miss INBOX/UNREAD removals after the local history cursor had already advanced. + */ + private async reconcileVisibleInboxLabels(accountId: string): Promise { + if (this.inboxLabelReconcileDone.has(accountId)) return; + this.inboxLabelReconcileDone.add(accountId); + + const account = this.accounts.get(accountId); + if (!account) return; + + const candidates = getVisibleInboxLabelStates(accountId, INBOX_LABEL_RECONCILE_LIMIT); + if (candidates.length === 0) return; + + const removedIds: string[] = []; + const labelUpdates: { emailId: string; labelIds: string[] }[] = []; + + const reconcileOne = async (candidate: { id: string; labelIds: string[] | undefined }) => { + try { + const remoteLabels = await account.client.getMessageLabelIds(candidate.id); + const localLabels = candidate.labelIds ?? ["INBOX"]; + + if ( + remoteLabels === null || + (!remoteLabels.includes("INBOX") && !remoteLabels.includes("SENT")) + ) { + await this.deleteLocalEmailAfterRemoteRemoval(accountId, candidate.id); + removedIds.push(candidate.id); + return; + } + + if (!sameLabelSet(localLabels, remoteLabels)) { + updateEmailLabelIds(candidate.id, remoteLabels); + labelUpdates.push({ emailId: candidate.id, labelIds: remoteLabels }); + } + } catch (err) { + log.warn({ err }, `[Sync] Failed to reconcile Gmail labels for ${candidate.id}`); + } + }; + + for (let i = 0; i < candidates.length; i += INBOX_LABEL_RECONCILE_CONCURRENCY) { + await Promise.all( + candidates.slice(i, i + INBOX_LABEL_RECONCILE_CONCURRENCY).map(reconcileOne), + ); + } + + if (removedIds.length > 0) { + this.onEmailsRemoved?.(accountId, removedIds); + } + if (labelUpdates.length > 0) { + this.onEmailsUpdated?.(accountId, labelUpdates); + } + + if (removedIds.length > 0 || labelUpdates.length > 0) { + log.info( + `[Sync] Reconciled ${removedIds.length} removed and ${labelUpdates.length} updated inbox labels for ${account.email}`, + ); + } + } + + private async deleteLocalEmailAfterRemoteRemoval( + accountId: string, + emailId: string, + ): Promise { + const email = getEmail(emailId); + if (email?.draft?.gmailDraftId) { + await deleteGmailDraftById(accountId, email.draft.gmailDraftId).catch(() => {}); + } + deleteEmail(emailId, accountId); + } + /** * Fetch recent sent emails that belong to inbox threads and save them to the DB. * Runs once per account per app session to backfill sent replies the incremental @@ -921,14 +1008,22 @@ class EmailSyncService { // Fetch new emails if (changes.newMessageIds.length > 0) { - log.info(`[Sync] ${changes.newMessageIds.length} new emails for ${account.email}`); + const uniqueNewMessageIds = [...new Set(changes.newMessageIds)]; + log.info(`[Sync] ${uniqueNewMessageIds.length} new emails for ${account.email}`); const newEmails: DashboardEmail[] = []; + const removedByCurrentLabels: string[] = []; - for (const id of changes.newMessageIds) { + for (const id of uniqueNewMessageIds) { try { const email = await client.readEmail(id); if (email) { + if (!email.labelIds?.includes("INBOX") && !email.labelIds?.includes("SENT")) { + await this.deleteLocalEmailAfterRemoteRemoval(accountId, id); + removedByCurrentLabels.push(id); + continue; + } + saveEmail(email, accountId); newEmails.push({ ...email, @@ -939,10 +1034,19 @@ class EmailSyncService { }); } } catch (err) { + if (isNotFoundError(err)) { + await this.deleteLocalEmailAfterRemoteRemoval(accountId, id); + removedByCurrentLabels.push(id); + continue; + } log.error({ err: err }, `[Sync] Failed to fetch email ${id}`); } } + if (removedByCurrentLabels.length > 0) { + this.onEmailsRemoved?.(accountId, removedByCurrentLabels); + } + if (newEmails.length > 0) { this.onNewEmails?.(accountId, newEmails); @@ -1085,6 +1189,8 @@ class EmailSyncService { this.onEmailsUpdated?.(accountId, labelUpdates); } + await this.reconcileVisibleInboxLabels(accountId); + // One-time backfill of sent emails for inbox threads (once per app session) if (!this.sentBackfillDone.has(accountId)) { this.sentBackfillDone.add(accountId); diff --git a/src/main/services/gmail-client.ts b/src/main/services/gmail-client.ts index 2b5bc280..44a195da 100644 --- a/src/main/services/gmail-client.ts +++ b/src/main/services/gmail-client.ts @@ -135,6 +135,21 @@ export function isAuthError(error: unknown): boolean { return false; } +export function isNotFoundError(error: unknown): boolean { + if (typeof error !== "object" || error === null) return false; + + const code = Reflect.get(error, "code"); + if (code === 404) return true; + + const status = Reflect.get(error, "status"); + if (status === 404) return true; + + const response = Reflect.get(error, "response"); + if (typeof response !== "object" || response === null) return false; + + return Reflect.get(response, "status") === 404; +} + export class GmailClient { private oauth2Client: OAuth2Client | null = null; private gmail: ReturnType | null = null; @@ -1433,6 +1448,28 @@ export class GmailClient { }); } + /** + * Fetch only the current Gmail labels for a message. + */ + async getMessageLabelIds(messageId: string): Promise { + const gmail = this.gmail!; + + try { + const response = await gmail.users.messages.get({ + userId: "me", + id: messageId, + format: "minimal", + }); + return response.data.labelIds ?? []; + } catch (err: unknown) { + if (isNotFoundError(err)) { + return null; + } + + throw err; + } + } + /** * Mark all messages in a thread as read (removes UNREAD label from every message) */ @@ -1637,15 +1674,16 @@ export class GmailClient { const unreadMessageIds: string[] = []; let latestHistoryId = startHistoryId; - // Fetch history for a single label, accumulating into the shared arrays above - const fetchLabel = async (labelId: string) => { + // Fetch unfiltered history. Using labelId-scoped history can miss exactly + // the label removals we care about: after Gmail removes INBOX/UNREAD, the + // message may no longer match the scoped label query. + const fetchHistory = async () => { let pageToken: string | undefined; do { const response = await gmail.users.history.list({ userId: "me", startHistoryId, historyTypes: ["messageAdded", "messageDeleted", "labelAdded", "labelRemoved"], - labelId, pageToken, }); @@ -1654,7 +1692,12 @@ export class GmailClient { for (const item of history) { if (item.messagesAdded) { for (const msg of item.messagesAdded) { - if (msg.message?.id && msg.message?.labelIds?.includes(labelId)) { + const labels = msg.message?.labelIds ?? []; + if ( + msg.message?.id && + (labels.includes("INBOX") || labels.includes("SENT")) && + !labels.includes("DRAFT") + ) { newMessageIds.push(msg.message.id); } } @@ -1688,6 +1731,12 @@ export class GmailClient { if (labelChange.labelIds?.includes("UNREAD")) { unreadMessageIds.push(labelChange.message.id); } + // Existing archived messages can re-enter the inbox without being + // reported as messageAdded. Fetch them so local label state is + // replaced with Gmail's current labels. + if (labelChange.labelIds?.includes("INBOX")) { + newMessageIds.push(labelChange.message.id); + } // Detect draft-to-sent conversions: when a user sends our synced // Gmail draft, the History API reports it as labelsAdded (SENT) // rather than messagesAdded. Treat it as a new message so @@ -1709,8 +1758,7 @@ export class GmailClient { }; try { - // Fetch INBOX and SENT history in parallel - await Promise.all([fetchLabel("INBOX"), fetchLabel("SENT")]); + await fetchHistory(); this.lastHistoryId = latestHistoryId; diff --git a/tests/unit/database.spec.ts b/tests/unit/database.spec.ts index 92a30beb..1bd2cfdb 100644 --- a/tests/unit/database.spec.ts +++ b/tests/unit/database.spec.ts @@ -68,6 +68,23 @@ function sanitizeFtsQuery(query: string): string { .join(" "); } +function hasInboxLabel(db: DB, accountId: string): Array<{ id: string; labelIds: string | null }> { + return db + .prepare( + ` + SELECT id, label_ids as labelIds + FROM emails + WHERE account_id = ? + AND ( + label_ids IS NULL + OR EXISTS (SELECT 1 FROM json_each(label_ids) WHERE value = 'INBOX') + ) + ORDER BY date DESC + `, + ) + .all(accountId) as Array<{ id: string; labelIds: string | null }>; +} + function createTestDb(): DB { const db = new DatabaseCtor!(":memory:"); db.pragma("journal_mode = WAL"); @@ -1128,6 +1145,19 @@ test.describe("Database CRUD operations", () => { expect(ids).not.toContain("e3"); }); + test("json_each inbox membership does not match labels that merely contain INBOX as a substring", () => { + saveEmail( + db, + makeEmail({ id: "e1", threadId: "t1", labelIds: ["PROJECT_INBOX_ARCHIVE"] }), + "acct1", + ); + saveEmail(db, makeEmail({ id: "e2", threadId: "t2", labelIds: ["INBOX"] }), "acct1"); + saveEmail(db, makeEmail({ id: "e3", threadId: "t3" }), "acct1"); + + const ids = hasInboxLabel(db, "acct1").map((row) => row.id).sort(); + expect(ids).toEqual(["e2", "e3"]); + }); + test("deleteEmail removes email and associated analyses/drafts", () => { saveEmail(db, makeEmail({ id: "e1", threadId: "t1" }), "acct1"); saveAnalysis(db, "e1", true, "needs reply", "high"); diff --git a/tests/unit/email-sync.spec.ts b/tests/unit/email-sync.spec.ts index 3417c6fb..050799db 100644 --- a/tests/unit/email-sync.spec.ts +++ b/tests/unit/email-sync.spec.ts @@ -86,6 +86,10 @@ function filterDeletedMessages(deletedMessageIds: string[], newMessageIds: strin return deletedMessageIds.filter((id) => !newSet.has(id)); } +function deduplicateNewMessages(newMessageIds: string[]): string[] { + return [...new Set(newMessageIds)]; +} + test.describe("Deleted/new message dedup (draft-sent overlap)", () => { test("removes IDs that appear in both deleted and new lists", () => { const deleted = ["msg-1", "msg-2", "msg-3"]; @@ -118,6 +122,10 @@ test.describe("Deleted/new message dedup (draft-sent overlap)", () => { test("handles both empty lists", () => { expect(filterDeletedMessages([], [])).toEqual([]); }); + + test("deduplicates new message IDs before fetching email bodies", () => { + expect(deduplicateNewMessages(["msg-1", "msg-2", "msg-1"])).toEqual(["msg-1", "msg-2"]); + }); }); // ============================================================================