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
20 changes: 17 additions & 3 deletions src/channels/email/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,12 +246,26 @@ export function createEmailConnectionManager(
try {
const uidValidity = resolveMailboxUidValidity(activeClient);
const storedCursor = persistedCursorState.get(folder);
let lastProcessedUid =
const compatibleStoredCursor =
storedCursor && storedCursor.uidValidity === uidValidity
? storedCursor.lastProcessedUid
: 0;
? storedCursor
: null;
let lastProcessedUid = compatibleStoredCursor
? compatibleStoredCursor.lastProcessedUid
: 0;

const maxKnownUid = resolveMailboxUidNext(activeClient) - 1;
if (!compatibleStoredCursor) {
persistedCursorState.set(folder, {
uidValidity,
lastProcessedUid: maxKnownUid,
});
await savePersistedFolderCursorState(
config.address,
persistedCursorState,
);
return;
}
if (maxKnownUid <= lastProcessedUid) {
if (
!storedCursor ||
Expand Down
116 changes: 107 additions & 9 deletions tests/email.connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-'),
);
Expand Down Expand Up @@ -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();
Expand All @@ -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,
});
});
});
Loading