diff --git a/package.json b/package.json index 3260321..28631cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imap-sdk", - "version": "1.1.2", + "version": "1.1.3", "description": "IMAP SDK for Node", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/shell/client/index.test.ts b/src/shell/client/index.test.ts new file mode 100644 index 0000000..f76994d --- /dev/null +++ b/src/shell/client/index.test.ts @@ -0,0 +1,100 @@ +import { IMAPClient } from './index' + +type TestableClient = { + _capabilities: Set + enableExtensions: () => Promise + run: IMAPClient['run'] +} + +const createTestClient = (): TestableClient => + new IMAPClient({ + host: 'imap.example.com', + logger: false, + port: 993, + secure: true, + }) as unknown as TestableClient + +describe('IMAPClient', () => { + describe('enableExtensions', () => { + it('should enable CONDSTORE when capability is present', async () => { + const client = createTestClient() + const runCalls: { command: string; args: unknown[] }[] = [] + + client._capabilities = new Set(['IMAP4rev1', 'ENABLE', 'CONDSTORE']) + client.run = ((command: string, ...args: unknown[]) => { + runCalls.push({ args, command }) + return Promise.resolve(new Set(['CONDSTORE'])) + }) as typeof client.run + + await client.enableExtensions() + + expect(runCalls.length).toBe(1) + expect(runCalls[0].command).toBe('ENABLE') + expect(runCalls[0].args).toEqual([['CONDSTORE']]) + }) + + it('should enable QRESYNC when capability is present', async () => { + const client = createTestClient() + const runCalls: { command: string; args: unknown[] }[] = [] + + client._capabilities = new Set(['IMAP4rev1', 'ENABLE', 'QRESYNC']) + client.run = ((command: string, ...args: unknown[]) => { + runCalls.push({ args, command }) + return Promise.resolve(new Set(['QRESYNC'])) + }) as typeof client.run + + await client.enableExtensions() + + expect(runCalls.length).toBe(1) + expect(runCalls[0].args).toEqual([['QRESYNC']]) + }) + + it('should enable UTF8=ACCEPT when capability is present', async () => { + const client = createTestClient() + const runCalls: { command: string; args: unknown[] }[] = [] + + client._capabilities = new Set(['IMAP4rev1', 'ENABLE', 'UTF8=ACCEPT']) + client.run = ((command: string, ...args: unknown[]) => { + runCalls.push({ args, command }) + return Promise.resolve(new Set(['UTF8=ACCEPT'])) + }) as typeof client.run + + await client.enableExtensions() + + expect(runCalls.length).toBe(1) + expect(runCalls[0].args).toEqual([['UTF8=ACCEPT']]) + }) + + it('should enable all supported extensions when present', async () => { + const client = createTestClient() + const runCalls: { command: string; args: unknown[] }[] = [] + + client._capabilities = new Set(['IMAP4rev1', 'ENABLE', 'CONDSTORE', 'QRESYNC', 'UTF8=ACCEPT']) + client.run = ((command: string, ...args: unknown[]) => { + runCalls.push({ args, command }) + return Promise.resolve(new Set(['CONDSTORE', 'QRESYNC', 'UTF8=ACCEPT'])) + }) as typeof client.run + + await client.enableExtensions() + + expect(runCalls.length).toBe(1) + expect(runCalls[0].command).toBe('ENABLE') + expect(runCalls[0].args).toEqual([['CONDSTORE', 'QRESYNC', 'UTF8=ACCEPT']]) + }) + + it('should not call ENABLE when no supported extensions are present', async () => { + const client = createTestClient() + const runCalls: { command: string; args: unknown[] }[] = [] + + client._capabilities = new Set(['IMAP4rev1', 'IDLE']) + client.run = ((command: string, ...args: unknown[]) => { + runCalls.push({ args, command }) + return Promise.resolve(new Set()) + }) as typeof client.run + + await client.enableExtensions() + + expect(runCalls.length).toBe(0) + }) + }) +}) diff --git a/src/shell/client/index.ts b/src/shell/client/index.ts index 6b0f168..6c65ea0 100644 --- a/src/shell/client/index.ts +++ b/src/shell/client/index.ts @@ -285,9 +285,32 @@ export class IMAPClient extends EventEmitter implements CommandContext, AsyncDis } this.stateMachine.send({ type: 'AUTHENTICATED', user }) + + await this.enableExtensions() + return user } + private async enableExtensions(): Promise { + const extensionsToEnable: string[] = [] + + if (this._capabilities.has('CONDSTORE')) { + extensionsToEnable.push('CONDSTORE') + } + + if (this._capabilities.has('QRESYNC')) { + extensionsToEnable.push('QRESYNC') + } + + if (this._capabilities.has('UTF8=ACCEPT')) { + extensionsToEnable.push('UTF8=ACCEPT') + } + + if (extensionsToEnable.length > 0) { + await this.run('ENABLE', extensionsToEnable) + } + } + async exec( command: string, attributes: readonly CommandAttribute[] = [], diff --git a/src/shell/commands/enable.test.ts b/src/shell/commands/enable.test.ts new file mode 100644 index 0000000..7afc851 --- /dev/null +++ b/src/shell/commands/enable.test.ts @@ -0,0 +1,196 @@ +import { enable } from './enable' +import { createMockContext, createMockLoggerWithWarn, createMockResponse } from './test-utils' + +describe('enable command', () => { + describe('preconditions', () => { + it('should return empty set if ENABLE capability is not present', async () => { + const ctx = createMockContext({ + capabilities: new Set(['IMAP4rev1', 'CONDSTORE']), + state: 'AUTHENTICATED', + }) + + const result = await enable(ctx, ['CONDSTORE']) + + expect(result).toEqual(new Set()) + expect(ctx.mockExecCalls.length).toBe(0) + }) + + it('should return empty set if state is not AUTHENTICATED', async () => { + const ctx = createMockContext({ + capabilities: new Set(['IMAP4rev1', 'ENABLE', 'CONDSTORE']), + state: 'SELECTED', + }) + + const result = await enable(ctx, ['CONDSTORE']) + + expect(result).toEqual(new Set()) + expect(ctx.mockExecCalls.length).toBe(0) + }) + + it('should return empty set if no requested extensions are available', async () => { + const ctx = createMockContext({ + capabilities: new Set(['IMAP4rev1', 'ENABLE']), + state: 'AUTHENTICATED', + }) + + const result = await enable(ctx, ['CONDSTORE', 'QRESYNC']) + + expect(result).toEqual(new Set()) + expect(ctx.mockExecCalls.length).toBe(0) + }) + }) + + describe('success cases', () => { + it('should send ENABLE command with available extensions', async () => { + const ctx = createMockContext({ + capabilities: new Set(['IMAP4rev1', 'ENABLE', 'CONDSTORE']), + state: 'AUTHENTICATED', + }) + + await enable(ctx, ['CONDSTORE']) + + expect(ctx.mockExecCalls.length).toBe(1) + expect(ctx.mockExecCalls[0].command).toBe('ENABLE') + expect(ctx.mockExecCalls[0].attributes).toEqual([{ type: 'ATOM', value: 'CONDSTORE' }]) + }) + + it('should filter out extensions not in capabilities', async () => { + const ctx = createMockContext({ + capabilities: new Set(['IMAP4rev1', 'ENABLE', 'CONDSTORE']), + state: 'AUTHENTICATED', + }) + + await enable(ctx, ['CONDSTORE', 'QRESYNC', 'UTF8=ACCEPT']) + + expect(ctx.mockExecCalls.length).toBe(1) + expect(ctx.mockExecCalls[0].attributes).toEqual([{ type: 'ATOM', value: 'CONDSTORE' }]) + }) + + it('should enable multiple extensions when available', async () => { + const ctx = createMockContext({ + capabilities: new Set(['IMAP4rev1', 'ENABLE', 'CONDSTORE', 'QRESYNC']), + state: 'AUTHENTICATED', + }) + + await enable(ctx, ['CONDSTORE', 'QRESYNC']) + + expect(ctx.mockExecCalls.length).toBe(1) + expect(ctx.mockExecCalls[0].attributes).toEqual([ + { type: 'ATOM', value: 'CONDSTORE' }, + { type: 'ATOM', value: 'QRESYNC' }, + ]) + }) + + it('should uppercase extension names', async () => { + const ctx = createMockContext({ + capabilities: new Set(['IMAP4rev1', 'ENABLE', 'CONDSTORE']), + state: 'AUTHENTICATED', + }) + + await enable(ctx, ['condstore']) + + expect(ctx.mockExecCalls[0].attributes).toEqual([{ type: 'ATOM', value: 'CONDSTORE' }]) + }) + + it('should parse ENABLED response and call addEnabled', async () => { + const enabledExtensions: string[] = [] + const ctx = createMockContext({ + addEnabled: (ext: string) => { + enabledExtensions.push(ext) + }, + capabilities: new Set(['IMAP4rev1', 'ENABLE', 'CONDSTORE']), + state: 'AUTHENTICATED', + }) + + const enablePromise = enable(ctx, ['CONDSTORE']) + + await ctx.triggerUntagged( + 'ENABLED', + createMockResponse({ + attributes: [{ type: 'ATOM', value: 'CONDSTORE' }] as never, + command: 'ENABLED', + tag: '*', + }), + ) + + const result = await enablePromise + + expect(result).toEqual(new Set(['CONDSTORE'])) + expect(enabledExtensions).toEqual(['CONDSTORE']) + }) + + it('should handle multiple enabled extensions in response', async () => { + const enabledExtensions: string[] = [] + const ctx = createMockContext({ + addEnabled: (ext: string) => { + enabledExtensions.push(ext) + }, + capabilities: new Set(['IMAP4rev1', 'ENABLE', 'CONDSTORE', 'QRESYNC']), + state: 'AUTHENTICATED', + }) + + const enablePromise = enable(ctx, ['CONDSTORE', 'QRESYNC']) + + await ctx.triggerUntagged( + 'ENABLED', + createMockResponse({ + attributes: [ + { type: 'ATOM', value: 'CONDSTORE' }, + { type: 'ATOM', value: 'QRESYNC' }, + ] as never, + command: 'ENABLED', + tag: '*', + }), + ) + + const result = await enablePromise + + expect(result).toEqual(new Set(['CONDSTORE', 'QRESYNC'])) + expect(enabledExtensions).toEqual(['CONDSTORE', 'QRESYNC']) + }) + + it('should ignore ENABLED response with no attributes', async () => { + const enabledExtensions: string[] = [] + const ctx = createMockContext({ + addEnabled: (ext: string) => { + enabledExtensions.push(ext) + }, + capabilities: new Set(['IMAP4rev1', 'ENABLE', 'CONDSTORE']), + state: 'AUTHENTICATED', + }) + + const enablePromise = enable(ctx, ['CONDSTORE']) + + await ctx.triggerUntagged( + 'ENABLED', + createMockResponse({ + attributes: undefined, + command: 'ENABLED', + tag: '*', + }), + ) + + const result = await enablePromise + + expect(result).toEqual(new Set()) + expect(enabledExtensions).toEqual([]) + }) + }) + + describe('error cases', () => { + it('should return empty set and log warning on exec error', async () => { + const warnCalls: unknown[] = [] + const ctx = createMockContext({ + capabilities: new Set(['IMAP4rev1', 'ENABLE', 'CONDSTORE']), + exec: () => Promise.reject(new Error('Connection failed')), + log: createMockLoggerWithWarn(warnCalls), + state: 'AUTHENTICATED', + }) + + const result = await enable(ctx, ['CONDSTORE']) + + expect(result).toEqual(new Set()) + expect(warnCalls.length).toBe(1) + }) + }) +})