Skip to content
Draft
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
56 changes: 47 additions & 9 deletions src/main/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -928,12 +928,43 @@ export function hasEmailsForAccount(accountId: string): boolean {
export function getInboxThreadIds(accountId: string): Set<string> {
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<string> {
const db = getDatabase();
const stmt = db.prepare(`SELECT id FROM emails WHERE account_id = ?`);
Expand Down Expand Up @@ -1369,17 +1400,24 @@ function stripLargeDataUris(body: string): string {
);
}

function rowToDashboardEmail(row: Record<string, unknown>): 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<string, unknown>): 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;
Expand Down
12 changes: 4 additions & 8 deletions src/main/ipc/sync.ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,17 +271,15 @@ 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();
pendingReauthClient = null;

// 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`);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<IpcResponse<DashboardEmail[]>> => {
if (useFakeData) {
const { DEMO_INBOX_EMAILS, DEMO_EXPECTED_ANALYSIS } = await import("../demo/fake-inbox");
Expand Down
112 changes: 109 additions & 3 deletions src/main/services/email-sync.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type GmailClient, isAuthError } from "./gmail-client";
import { type GmailClient, isAuthError, isNotFoundError } from "./gmail-client";
import {
saveEmail,
deleteEmail,
Expand All @@ -7,6 +7,7 @@ import {
hasEmailsForAccount,
getEmailIds,
getInboxThreadIds,
getVisibleInboxLabelStates,
getEmail,
updateEmailLabelIds,
deleteArchiveReadyForThreads,
Expand All @@ -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 = {
Expand Down Expand Up @@ -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<string> = new Set();
private inboxLabelReconcileDone: Set<string> = new Set();
private onNewEmails?: (accountId: string, emails: DashboardEmail[]) => void;
private onNewSentEmails?: (accountId: string, emails: DashboardEmail[]) => void;
private onSyncStatusChange?: (accountId: string, status: SyncStatus) => void;
Expand Down Expand Up @@ -124,6 +134,11 @@ class EmailSyncService {
*/
async registerAccount(client: GmailClient): Promise<AccountInfo> {
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([
Expand Down Expand Up @@ -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<void> {
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<void> {
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
Expand Down Expand Up @@ -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,
Expand All @@ -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);

Expand Down Expand Up @@ -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);
Expand Down
Loading