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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
100 changes: 100 additions & 0 deletions src/shell/client/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { IMAPClient } from './index'

type TestableClient = {
_capabilities: Set<string>
enableExtensions: () => Promise<void>
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)
})
})
})
23 changes: 23 additions & 0 deletions src/shell/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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[] = [],
Expand Down
196 changes: 196 additions & 0 deletions src/shell/commands/enable.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})