From 0ecc634596ef4acebfd1965b8fe8a33d4a7869d3 Mon Sep 17 00:00:00 2001 From: Benedikt Koehler Date: Mon, 13 Apr 2026 17:27:43 +0200 Subject: [PATCH 1/2] fix: avoid replaying old emails on first cursor sync --- src/channels/email/connection.ts | 17 ++++- tests/email.connection.test.ts | 116 ++++++++++++++++++++++++++++--- 2 files changed, 121 insertions(+), 12 deletions(-) diff --git a/src/channels/email/connection.ts b/src/channels/email/connection.ts index d8ccf208..f111c680 100644 --- a/src/channels/email/connection.ts +++ b/src/channels/email/connection.ts @@ -246,12 +246,23 @@ export function createEmailConnectionManager( try { const uidValidity = resolveMailboxUidValidity(activeClient); const storedCursor = persistedCursorState.get(folder); + const hasStoredCursor = + Boolean(storedCursor) && storedCursor.uidValidity === uidValidity; let lastProcessedUid = - storedCursor && storedCursor.uidValidity === uidValidity - ? storedCursor.lastProcessedUid - : 0; + hasStoredCursor && storedCursor ? storedCursor.lastProcessedUid : 0; const maxKnownUid = resolveMailboxUidNext(activeClient) - 1; + if (!hasStoredCursor) { + persistedCursorState.set(folder, { + uidValidity, + lastProcessedUid: maxKnownUid, + }); + await savePersistedFolderCursorState( + config.address, + persistedCursorState, + ); + return; + } if (maxKnownUid <= lastProcessedUid) { if ( !storedCursor || diff --git a/tests/email.connection.test.ts b/tests/email.connection.test.ts index 1c92efac..63b9c0ba 100644 --- a/tests/email.connection.test.ts +++ b/tests/email.connection.test.ts @@ -34,7 +34,7 @@ afterEach(() => { }); describe('email connection manager', () => { - test('processes existing folder messages once and resumes from the saved UID cursor', async () => { + test('seeds a missing cursor from the current mailbox head and only processes later UIDs', async () => { const dataDir = fs.mkdtempSync( path.join(os.tmpdir(), 'hybridclaw-email-connection-'), ); @@ -105,14 +105,9 @@ describe('email connection manager', () => { await runManager(); - expect(search).toHaveBeenCalledWith({ all: true }, { uid: true }); - expect(processedUids).toEqual([1, 2]); - expect(messageFlagsAdd).toHaveBeenNthCalledWith(1, [1], ['\\Seen'], { - uid: true, - }); - expect(messageFlagsAdd).toHaveBeenNthCalledWith(2, [2], ['\\Seen'], { - uid: true, - }); + expect(search).not.toHaveBeenCalled(); + expect(processedUids).toEqual([]); + expect(messageFlagsAdd).not.toHaveBeenCalled(); processedUids.length = 0; await runManager(); @@ -122,5 +117,108 @@ describe('email connection manager', () => { uidNext = 4; await runManager(); expect(processedUids).toEqual([3]); + expect(search).toHaveBeenCalledWith({ all: true }, { uid: true }); + expect(messageFlagsAdd).toHaveBeenCalledTimes(1); + expect(messageFlagsAdd).toHaveBeenCalledWith([3], ['\\Seen'], { + uid: true, + }); + }); + + test('resumes from a saved cursor and processes messages that arrived while offline', async () => { + const dataDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'hybridclaw-email-connection-'), + ); + tempDirs.push(dataDir); + + const cursorStatePath = path.join( + dataDir, + 'email', + `${Buffer.from(BASE_EMAIL_CONFIG.address).toString('base64url').replace(/=+$/g, '')}-cursor-state.json`, + ); + fs.mkdirSync(path.dirname(cursorStatePath), { recursive: true }); + fs.writeFileSync( + cursorStatePath, + JSON.stringify( + { + version: 1, + folders: { + INBOX: { + uidValidity: '1', + lastProcessedUid: 1, + }, + }, + }, + null, + 2, + ), + ); + + const processedUids: number[] = []; + const search = vi.fn(async () => [1, 2, 3]); + const messageFlagsAdd = vi.fn(async () => true); + const fetch = vi.fn(async function* (uids: number[]) { + for (const uid of uids) { + yield { + uid, + source: Buffer.from(`raw-${uid}`, 'utf8'), + }; + } + }); + + vi.doMock('../src/config/config.js', () => ({ + DATA_DIR: dataDir, + })); + vi.doMock('imapflow', () => ({ + ImapFlow: class { + mailbox = { + path: 'INBOX', + uidNext: 4, + uidValidity: 1n, + }; + connect = vi.fn(async () => {}); + logout = vi.fn(async () => {}); + close = vi.fn(() => {}); + removeAllListeners = vi.fn(() => {}); + on = vi.fn(() => this); + getMailboxLock = vi.fn(async (folder: string) => { + this.mailbox = { + path: folder, + uidNext: 4, + uidValidity: 1n, + }; + return { + release: vi.fn(), + }; + }); + search = search; + fetch = fetch; + messageFlagsAdd = messageFlagsAdd; + }, + })); + + const { createEmailConnectionManager } = await import( + '../src/channels/email/connection.js' + ); + const manager = createEmailConnectionManager( + BASE_EMAIL_CONFIG, + 'secret', + async (messages) => { + for (const message of messages) { + processedUids.push(message.uid); + } + }, + ); + + await manager.start(); + await manager.stop(); + + expect(search).toHaveBeenCalledWith({ all: true }, { uid: true }); + expect(processedUids).toEqual([2, 3]); + expect(messageFlagsAdd).toHaveBeenNthCalledWith(1, [2], ['\\Seen'], { + uid: true, + }); + expect(messageFlagsAdd).toHaveBeenNthCalledWith(2, [3], ['\\Seen'], { + uid: true, + }); }); }); From e0cbbe0d8f3bc6716fee27ab6cc3f484342ffc23 Mon Sep 17 00:00:00 2001 From: Benedikt Koehler Date: Mon, 13 Apr 2026 17:44:19 +0200 Subject: [PATCH 2/2] fix: narrow email cursor compatibility checks --- src/channels/email/connection.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/channels/email/connection.ts b/src/channels/email/connection.ts index f111c680..9b9bf2be 100644 --- a/src/channels/email/connection.ts +++ b/src/channels/email/connection.ts @@ -246,13 +246,16 @@ export function createEmailConnectionManager( try { const uidValidity = resolveMailboxUidValidity(activeClient); const storedCursor = persistedCursorState.get(folder); - const hasStoredCursor = - Boolean(storedCursor) && storedCursor.uidValidity === uidValidity; - let lastProcessedUid = - hasStoredCursor && storedCursor ? storedCursor.lastProcessedUid : 0; + const compatibleStoredCursor = + storedCursor && storedCursor.uidValidity === uidValidity + ? storedCursor + : null; + let lastProcessedUid = compatibleStoredCursor + ? compatibleStoredCursor.lastProcessedUid + : 0; const maxKnownUid = resolveMailboxUidNext(activeClient) - 1; - if (!hasStoredCursor) { + if (!compatibleStoredCursor) { persistedCursorState.set(folder, { uidValidity, lastProcessedUid: maxKnownUid,