diff --git a/AGENTS.md b/AGENTS.md index 9264b94..3d2d7fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,9 @@ A TypeScript library implementing RFC 8621 JMAP for Mail using Effect-TS. 1. **Services via Context.Tag**: Services are defined using `Context.Tag` 2. **Layers for dependency injection**: Services are provided via `Layer` -3. **Effect generators**: Use `Effect.gen(function* () { ... })` for async operations +3. **Effect.gen vs Effect.flatMap/pipe**: + - Use `Effect.gen(function* () { ... })` for complex flows with intermediate values or multiple steps + - Use `Effect.flatMap` / `.pipe()` for simple one-step service calls (e.g., `Effect.flatMap(Service, svc => svc.method(args))`) 4. **Schema validation**: Effect Schema for type-safe parsing 5. **Branded types**: Use `Schema.brand()` for type safety (e.g., `Id`, `State`) diff --git a/README.md b/README.md index a886b19..9389384 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,34 @@ pnpm add effect-jmap ## Quick Start -The easiest way to get started is with the `JMAPLive` function, which includes everything you need: +The simplest way to use effect-jmap is with the Promise-based client — no Effect knowledge required: + +```typescript +import { createJMAPClient } from 'effect-jmap' + +const client = await createJMAPClient( + 'https://api.fastmail.com/jmap/session', + 'your-bearer-token-here' +) + +// Account ID is auto-discovered +const mailboxes = await client.mailbox.getAll() +console.log(`Found ${mailboxes.length} mailboxes`) + +// Query emails +const emails = await client.email.query({ + accountId: client.accountId, + filter: { inMailbox: 'inbox-id' }, + limit: 10, +}) + +// Clean up when done +await client.dispose() +``` + +## Using with Effect + +For full control using Effect-TS layers and services: ```typescript import { Effect } from 'effect' @@ -27,25 +54,21 @@ import { // Create a complete layer with one function call const mainLayer = JMAPLive( - 'https://api.fastmail.com/jmap/session', // Your JMAP session URL - 'your-bearer-token-here' // Your authentication token + 'https://api.fastmail.com/jmap/session', + 'your-bearer-token-here' ) -// Use the services const program = Effect.gen(function* () { - // Get session to find account ID const jmapClient = yield* JMAPClientService const sessionInfo = yield* jmapClient.getSession const accountId = Object.keys(sessionInfo.accounts)[0] - // Use mailbox service const mailboxService = yield* MailboxService const mailboxes = yield* mailboxService.getAll(accountId) console.log(`Found ${mailboxes.length} mailboxes`) }) -// Run the program Effect.runPromise(program.pipe(Effect.provide(mainLayer))) ``` @@ -57,7 +80,7 @@ For fine-grained control over the HTTP client, JMAP client configuration, or spe import { Effect, Layer } from 'effect' import { NodeHttpClient } from '@effect/platform-node' import { - createJMAPClient, + createJMAPClientLayer, defaultConfig, AppLive, MailboxService, @@ -67,7 +90,7 @@ import { // Option 1: Manual layer composition const mainLayer = Layer.mergeAll( NodeHttpClient.layerUndici, - createJMAPClient(sessionUrl, bearerToken), + createJMAPClientLayer(sessionUrl, bearerToken), AppLive // Includes all services + IdGenerator ) @@ -81,7 +104,7 @@ const customConfig = { const customLayer = Layer.mergeAll( NodeHttpClient.layerUndici, - createJMAPClientWithConfig(customConfig), + createJMAPClientLayerWithConfig(customConfig), AppLive ) diff --git a/src/client/client.ts b/src/client/client.ts index 23b6ba7..b2d89d9 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -16,6 +16,8 @@ export interface JMAPClientConfig { readonly retryDelay?: number readonly maxBatchSize?: number readonly enableRequestLogging?: boolean + /** Pre-fetched session to avoid the initial HTTP round-trip to the session endpoint. */ + readonly initialSession?: Session } /** @@ -70,7 +72,9 @@ interface SessionState { * Live implementation of JMAP Client */ const makeJMAPClientLive = (config: JMAPClientConfig): JMAPClientInterface => { - let sessionState: SessionState | null = null + let sessionState: SessionState | null = config.initialSession + ? { session: config.initialSession, lastUpdated: new Date() } + : null const defaultHeaders = { 'Content-Type': 'application/json', diff --git a/src/client/index.ts b/src/client/index.ts index 40c54a1..71622a4 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -11,8 +11,8 @@ export { // Live layer factory export { JMAPClientLive as JMAPClientLiveImpl, - createJMAPClient, - createJMAPClientWithConfig, + createJMAPClientLayer, + createJMAPClientLayerWithConfig, type JMAPClientConfig as JMAPConfig, } from './live.ts' @@ -52,3 +52,14 @@ export { JMAP_CAPABILITIES, CAPABILITY_SETS, type JMAPCapability, type Capabilit // Response utilities export { extractMethodResponse } from './response-utils.ts' + +// Promise-based client wrapper +export { + createJMAPClient, + createJMAPClientWithConfig, + createJMAPClientFromLayer, + type JMAPClientWrapper, + type MailboxNamespace, + type EmailNamespace, + type SubmissionNamespace, +} from './wrapper.ts' diff --git a/src/client/live.ts b/src/client/live.ts index 0f90509..e3ec40b 100644 --- a/src/client/live.ts +++ b/src/client/live.ts @@ -20,11 +20,11 @@ export type { JMAPClientConfig } /** * Convenience function to create a live JMAP client layer with default config */ -export const createJMAPClient = (sessionUrl: string, bearerToken: string): Layer.Layer => +export const createJMAPClientLayer = (sessionUrl: string, bearerToken: string): Layer.Layer => JMAPClientLiveImpl(defaultConfig(sessionUrl, bearerToken)) /** * Convenience function to create a live JMAP client layer with custom config */ -export const createJMAPClientWithConfig = (config: JMAPClientConfig): Layer.Layer => +export const createJMAPClientLayerWithConfig = (config: JMAPClientConfig): Layer.Layer => JMAPClientLiveImpl(config) \ No newline at end of file diff --git a/src/client/wrapper.ts b/src/client/wrapper.ts new file mode 100644 index 0000000..c585a69 --- /dev/null +++ b/src/client/wrapper.ts @@ -0,0 +1,407 @@ +import { Effect, ManagedRuntime } from 'effect' +import { JMAPClientService, type JMAPClientConfig } from './client.ts' +import type { Session, Response, Invocation } from './types.ts' +import { MailboxService } from '../mailbox/service.ts' +import { EmailService } from '../email/service.ts' +import { EmailSubmissionService } from '../submission/service.ts' +import { JMAPLive, JMAPLiveWithConfig } from '../layers.ts' +import type { Id } from '../shared/common.ts' +import type { + Envelope, + EmailSubmissionSetResult as SingleSubmissionResult, + EmailSubmissionObject, +} from '../submission/schema.ts' +import type { JMAPDate } from '../shared/common.ts' +import type * as Schema from 'effect/Schema' +import type { + MailboxGetArguments, + MailboxGetResponse, + MailboxSetArguments, + MailboxSetResponse, + MailboxQueryArguments, + MailboxQueryResponse, + MailboxQueryChangesArguments, + MailboxQueryChangesResponse, + MailboxMutable, + MailboxCreated, + Mailbox as MailboxSchema, +} from '../mailbox/schema.ts' +import type { MailboxRole } from '../mailbox/schema.ts' +import type { + EmailGetArguments, + EmailGetResponse, + EmailSetArguments, + EmailSetResponse, + EmailQueryArguments, + EmailQueryResponse, + EmailQueryChangesArguments, + EmailQueryChangesResponse, + EmailCopyArguments, + EmailCopyResponse, + EmailImportArguments, + EmailImportResponse, + Email as EmailSchema, +} from '../email/schema.ts' +import type { + EmailSubmissionGetArguments, + EmailSubmissionGetResponse, + EmailSubmissionSetArguments, + EmailSubmissionSetResponse, + EmailSubmissionQueryArguments, + EmailSubmissionQueryResponse, + EmailSubmissionQueryChangesArguments, + EmailSubmissionQueryChangesResponse, + EmailSubmissionChangesArguments, + EmailSubmissionChangesResponse, +} from '../submission/schema.ts' + +// Infer the actual TypeScript types from Schema types +type Mailbox = Schema.Schema.Type +type Email = Schema.Schema.Type +type MailboxGetResult = Schema.Schema.Type +type MailboxSetResult = Schema.Schema.Type +type MailboxQueryResult = Schema.Schema.Type +type MailboxQueryChangesResult = Schema.Schema.Type +type EmailGetResult = Schema.Schema.Type +type EmailSetResult = Schema.Schema.Type +type EmailQueryResult = Schema.Schema.Type +type EmailQueryChangesResult = Schema.Schema.Type +type EmailSubmissionGetResult = Schema.Schema.Type +type EmailSubmissionSetResponseResult = Schema.Schema.Type +type EmailSubmissionQueryResult = Schema.Schema.Type +type EmailSubmissionQueryChangesResult = Schema.Schema.Type +type EmailSubmissionChangesResult = Schema.Schema.Type + +/** + * Promise-based Mailbox namespace. + * + * All methods that originally require `accountId` as their first parameter + * accept an optional `accountId` override — defaulting to the client's + * auto-discovered primary account ID. + */ +export interface MailboxNamespace { + readonly get: (args: MailboxGetArguments) => Promise + readonly set: (args: MailboxSetArguments) => Promise + readonly query: (args: MailboxQueryArguments) => Promise + readonly queryChanges: (args: MailboxQueryChangesArguments) => Promise + readonly getAll: (accountId?: string) => Promise + readonly findByRole: (role: MailboxRole | null, accountId?: string) => Promise + readonly getHierarchy: (parentId?: Id, accountId?: string) => Promise + readonly create: (mailbox: MailboxMutable & { name: string }, accountId?: string) => Promise + readonly update: (mailboxId: Id, updates: Partial, accountId?: string) => Promise + readonly destroy: (mailboxIds: Id[], accountId?: string) => Promise +} + +/** + * Promise-based Email namespace. + * + * Core JMAP methods (get/set/query/queryChanges/copy/import) accept their + * full argument objects which already contain accountId. + * + * Convenience methods accept an optional `accountId` override. + */ +export interface EmailNamespace { + readonly get: (args: EmailGetArguments) => Promise + readonly set: (args: EmailSetArguments) => Promise + readonly query: (args: EmailQueryArguments) => Promise + readonly queryChanges: (args: EmailQueryChangesArguments) => Promise + readonly copy: (args: EmailCopyArguments) => Promise + readonly import: (args: EmailImportArguments) => Promise + readonly getByMailbox: ( + mailboxId: Id, + options?: { limit?: number; properties?: string[]; sort?: Array<{ property: string; isAscending?: boolean }> }, + accountId?: string, + ) => Promise + readonly search: ( + searchQuery: string, + options?: { limit?: number; mailboxId?: Id; properties?: string[] }, + accountId?: string, + ) => Promise + readonly getUnread: (mailboxId?: Id, limit?: number, accountId?: string) => Promise + readonly markRead: (emailIds: Id[], read: boolean, accountId?: string) => Promise + readonly flag: (emailIds: Id[], flagged: boolean, accountId?: string) => Promise + readonly move: (emailIds: Id[], fromMailboxId: Id, toMailboxId: Id, accountId?: string) => Promise + readonly updateKeywords: ( + emailIds: Id[], + keywordsToAdd: string[], + keywordsToRemove: string[], + accountId?: string, + ) => Promise + readonly getWithContent: (emailIds: Id[], maxBodyValueBytes?: number, accountId?: string) => Promise + readonly getEmailContent: (emailId: Id, maxBodyValueBytes?: number, accountId?: string) => Promise + readonly destroy: (emailIds: Id[], accountId?: string) => Promise +} + +/** + * Promise-based EmailSubmission namespace. + * + * Core JMAP methods accept their full argument objects. + * Convenience methods accept an optional `accountId` override. + */ +export interface SubmissionNamespace { + readonly get: (args: EmailSubmissionGetArguments) => Promise + readonly set: (args: EmailSubmissionSetArguments) => Promise + readonly query: (args: EmailSubmissionQueryArguments) => Promise + readonly queryChanges: (args: EmailSubmissionQueryChangesArguments) => Promise + readonly changes: (args: EmailSubmissionChangesArguments) => Promise + readonly send: ( + identityId: Id, + emailId: Id, + options?: { + envelope?: Envelope | null; + sendAt?: JMAPDate; + onSuccessUpdateEmail?: Record | null; + onSuccessDestroyEmail?: Id[] | boolean | null; + }, + accountId?: string, + ) => Promise + readonly getDeliveryStatus: (submissionId: Id, accountId?: string) => Promise + readonly cancelScheduled: (submissionId: Id, accountId?: string) => Promise + readonly getByEmailId: (emailId: Id, accountId?: string) => Promise + readonly getRecent: (limit?: number, accountId?: string) => Promise +} + +/** + * Promise-based JMAP client that hides Effect internals. + * + * Created via {@link createJMAPClient} or {@link createJMAPClientWithConfig}. + * + * @example + * ```typescript + * import { createJMAPClient } from 'effect-jmap' + * + * const client = await createJMAPClient( + * 'https://api.fastmail.com/jmap/session', + * 'your-bearer-token' + * ) + * + * // All methods return Promises — no Effect knowledge needed + * const mailboxes = await client.mailbox.getAll() + * const emails = await client.email.query({ accountId: client.accountId, filter: { inMailbox: 'inbox-id' } }) + * + * // Errors are the same TaggedError types from effect-jmap + * try { + * await client.email.get({ accountId: client.accountId, ids: ['bad-id'] }) + * } catch (e) { + * if (e instanceof NetworkError) { ... } + * if (e instanceof AuthenticationError) { ... } + * } + * + * // Clean up when done + * await client.dispose() + * ``` + */ +export interface JMAPClientWrapper { + /** The auto-discovered primary account ID for mail */ + readonly accountId: string + /** The raw JMAP session data */ + readonly session: Session + /** Low-level JMAP batch API */ + readonly batch: (methodCalls: ReadonlyArray, using?: ReadonlyArray) => Promise + /** Mailbox operations */ + readonly mailbox: MailboxNamespace + /** Email operations */ + readonly email: EmailNamespace + /** Email submission (sending) operations */ + readonly submission: SubmissionNamespace + /** Dispose the underlying runtime and release resources */ + readonly dispose: () => Promise +} + +/** + * Create a Promise-based JMAP client with default configuration. + * + * This fetches the JMAP session upfront to validate credentials and + * discover the primary account ID. All subsequent method calls use + * the same underlying Effect runtime (layers are constructed once). + * + * @param sessionUrl - JMAP session URL (e.g., 'https://api.fastmail.com/jmap/session') + * @param bearerToken - API token for authentication + * @param options - Optional settings (e.g., pass a cached `session` to skip the initial HTTP fetch) + * @returns A Promise-based JMAP client + * @throws {AuthenticationError} If the bearer token is invalid + * @throws {NetworkError} If the session endpoint is unreachable + * @throws {SessionError} If the session response is malformed + * + * @example + * ```typescript + * const client = await createJMAPClient( + * 'https://api.fastmail.com/jmap/session', + * 'fmu1-...' + * ) + * const mailboxes = await client.mailbox.getAll() + * await client.dispose() + * ``` + */ +export const createJMAPClient = async ( + sessionUrl: string, + bearerToken: string, + options?: { session?: Session }, +): Promise => { + const layer = JMAPLive(sessionUrl, bearerToken, options?.session) + return buildClient(layer, options?.session) +} + +/** + * Create a Promise-based JMAP client with custom configuration. + * + * @param config - Custom JMAP client configuration (timeout, retries, user agent, etc.) + * @returns A Promise-based JMAP client + * + * @example + * ```typescript + * import { createJMAPClientWithConfig, defaultConfig } from 'effect-jmap' + * + * const client = await createJMAPClientWithConfig({ + * ...defaultConfig('https://api.fastmail.com/jmap/session', 'token'), + * timeout: 60000, + * maxRetries: 5, + * }) + * ``` + */ +export const createJMAPClientWithConfig = async ( + config: JMAPClientConfig, +): Promise => { + const layer = JMAPLiveWithConfig(config) + return buildClient(layer, config.initialSession) +} + +/** + * Create a Promise-based JMAP client from a pre-built Effect layer. + * + * This is useful for advanced scenarios like custom layer composition + * or testing with mock layers. + * + * @param layer - A fully-constructed layer providing all JMAP services + * @returns A Promise-based JMAP client + */ +export const createJMAPClientFromLayer = async ( + layer: ReturnType, +): Promise => { + return buildClient(layer) +} + +/** + * Internal: build the client wrapper from a fully-constructed layer. + * When `cachedSession` is provided, it is used directly (skipping the HTTP fetch). + */ +const buildClient = async ( + layer: ReturnType, + cachedSession?: Session, +): Promise => { + const runtime = ManagedRuntime.make(layer) + + const run = (effect: Effect.Effect): Promise => + runtime.runPromise(effect) as Promise + + // Use cached session or fetch from server (validates credentials and discovers account ID) + const session = cachedSession ?? await run( + Effect.flatMap(JMAPClientService, client => client.getSession), + ) + + const accountId = + session.primaryAccounts?.['urn:ietf:params:jmap:mail'] ?? + Object.keys(session.accounts)[0] + + if (!accountId) { + throw new Error('No account ID found in JMAP session') + } + + // Low-level batch + const batch = (methodCalls: ReadonlyArray, using?: ReadonlyArray): Promise => + run(Effect.flatMap(JMAPClientService, client => client.batch(methodCalls, using))) + + // --- Mailbox namespace --- + const mailbox: MailboxNamespace = { + get: (args) => + run(Effect.flatMap(MailboxService, svc => svc.get(args))), + set: (args) => + run(Effect.flatMap(MailboxService, svc => svc.set(args))), + query: (args) => + run(Effect.flatMap(MailboxService, svc => svc.query(args))), + queryChanges: (args) => + run(Effect.flatMap(MailboxService, svc => svc.queryChanges(args))), + getAll: (acct?) => + run(Effect.flatMap(MailboxService, svc => svc.getAll(acct ?? accountId))), + findByRole: (role, acct?) => + run(Effect.flatMap(MailboxService, svc => svc.findByRole(acct ?? accountId, role))), + getHierarchy: (parentId?, acct?) => + run(Effect.flatMap(MailboxService, svc => svc.getHierarchy(acct ?? accountId, parentId))), + create: (mailboxData, acct?) => + run(Effect.flatMap(MailboxService, svc => svc.create(acct ?? accountId, mailboxData))), + update: (mailboxId, updates, acct?) => + run(Effect.flatMap(MailboxService, svc => svc.update(acct ?? accountId, mailboxId, updates))), + destroy: (mailboxIds, acct?) => + run(Effect.flatMap(MailboxService, svc => svc.destroy(acct ?? accountId, mailboxIds))), + } + + // --- Email namespace --- + const email: EmailNamespace = { + get: (args) => + run(Effect.flatMap(EmailService, svc => svc.get(args))), + set: (args) => + run(Effect.flatMap(EmailService, svc => svc.set(args))), + query: (args) => + run(Effect.flatMap(EmailService, svc => svc.query(args))), + queryChanges: (args) => + run(Effect.flatMap(EmailService, svc => svc.queryChanges(args))), + copy: (args) => + run(Effect.flatMap(EmailService, svc => svc.copy(args))), + import: (args) => + run(Effect.flatMap(EmailService, svc => svc.import(args))), + getByMailbox: (mailboxId, options?, acct?) => + run(Effect.flatMap(EmailService, svc => svc.getByMailbox(acct ?? accountId, mailboxId, options))), + search: (searchQuery, options?, acct?) => + run(Effect.flatMap(EmailService, svc => svc.search(acct ?? accountId, searchQuery, options))), + getUnread: (mailboxId?, limit?, acct?) => + run(Effect.flatMap(EmailService, svc => svc.getUnread(acct ?? accountId, mailboxId, limit))), + markRead: (emailIds, read, acct?) => + run(Effect.flatMap(EmailService, svc => svc.markRead(acct ?? accountId, emailIds, read))), + flag: (emailIds, flagged, acct?) => + run(Effect.flatMap(EmailService, svc => svc.flag(acct ?? accountId, emailIds, flagged))), + move: (emailIds, fromMailboxId, toMailboxId, acct?) => + run(Effect.flatMap(EmailService, svc => svc.move(acct ?? accountId, emailIds, fromMailboxId, toMailboxId))), + updateKeywords: (emailIds, keywordsToAdd, keywordsToRemove, acct?) => + run(Effect.flatMap(EmailService, svc => svc.updateKeywords(acct ?? accountId, emailIds, keywordsToAdd, keywordsToRemove))), + getWithContent: (emailIds, maxBodyValueBytes?, acct?) => + run(Effect.flatMap(EmailService, svc => svc.getWithContent(acct ?? accountId, emailIds, maxBodyValueBytes))), + getEmailContent: (emailId, maxBodyValueBytes?, acct?) => + run(Effect.flatMap(EmailService, svc => svc.getEmailContent(acct ?? accountId, emailId, maxBodyValueBytes))), + destroy: (emailIds, acct?) => + run(Effect.flatMap(EmailService, svc => svc.destroy(acct ?? accountId, emailIds))), + } + + // --- Submission namespace --- + const submission: SubmissionNamespace = { + get: (args) => + run(Effect.flatMap(EmailSubmissionService, svc => svc.get(args))), + set: (args) => + run(Effect.flatMap(EmailSubmissionService, svc => svc.set(args))), + query: (args) => + run(Effect.flatMap(EmailSubmissionService, svc => svc.query(args))), + queryChanges: (args) => + run(Effect.flatMap(EmailSubmissionService, svc => svc.queryChanges(args))), + changes: (args) => + run(Effect.flatMap(EmailSubmissionService, svc => svc.changes(args))), + send: (identityId, emailId, options?, acct?) => + run(Effect.flatMap(EmailSubmissionService, svc => svc.send(acct ?? accountId, identityId, emailId, options))), + getDeliveryStatus: (submissionId, acct?) => + run(Effect.flatMap(EmailSubmissionService, svc => svc.getDeliveryStatus(acct ?? accountId, submissionId))), + cancelScheduled: (submissionId, acct?) => + run(Effect.flatMap(EmailSubmissionService, svc => svc.cancelScheduled(acct ?? accountId, submissionId))), + getByEmailId: (emailId, acct?) => + run(Effect.flatMap(EmailSubmissionService, svc => svc.getByEmailId(acct ?? accountId, emailId))), + getRecent: (limit?, acct?) => + run(Effect.flatMap(EmailSubmissionService, svc => svc.getRecent(acct ?? accountId, limit))), + } + + return { + accountId, + session, + batch, + mailbox, + email, + submission, + dispose: () => runtime.dispose(), + } +} diff --git a/src/layers.ts b/src/layers.ts index a07d1a3..8230adf 100644 --- a/src/layers.ts +++ b/src/layers.ts @@ -43,11 +43,15 @@ export * from './client/test.ts' */ export const JMAPLive = ( sessionUrl: string, - bearerToken: string + bearerToken: string, + initialSession?: import('./client/types.ts').Session, ) => { + const config = initialSession + ? { ...defaultConfig(sessionUrl, bearerToken), initialSession } + : defaultConfig(sessionUrl, bearerToken) return Layer.provideMerge( Layer.mergeAll( - JMAPClientLiveImpl(defaultConfig(sessionUrl, bearerToken)), + JMAPClientLiveImpl(config), MailboxServiceLive, EmailServiceLive, EmailSubmissionServiceLive, diff --git a/tests/unit/client-wrapper.test.ts b/tests/unit/client-wrapper.test.ts new file mode 100644 index 0000000..8fafc41 --- /dev/null +++ b/tests/unit/client-wrapper.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { Layer } from 'effect' +import { createJMAPClientFromLayer, type JMAPClientWrapper } from '../../src/client/wrapper.ts' +import { MailboxServiceLive } from '../../src/mailbox/service.ts' +import { EmailServiceLive } from '../../src/email/service.ts' +import { EmailSubmissionServiceLive } from '../../src/submission/service.ts' +import { IdGeneratorLive } from '../../src/shared/id-generator.ts' +import { testJMAPClient } from '../utils/test-utils.ts' +import { JMAPFixtures } from '../fixtures/jmap-responses.ts' + +/** + * Build a test layer that mirrors JMAPLive but uses the mock client. + */ +const testLayer = Layer.mergeAll( + testJMAPClient, + MailboxServiceLive, + EmailServiceLive, + EmailSubmissionServiceLive, + IdGeneratorLive, +) + +describe('JMAPClientWrapper (createJMAPClient)', () => { + let client: JMAPClientWrapper + + afterEach(async () => { + if (client) { + await client.dispose() + } + }) + + describe('creation', () => { + it('should create a client from a layer', async () => { + client = await createJMAPClientFromLayer(testLayer as any) + + expect(client).toBeDefined() + expect(client.accountId).toBe('account-1') + expect(client.session).toBeDefined() + expect(client.session.apiUrl).toBe(JMAPFixtures.session.apiUrl) + }) + + it('should expose the primary account ID', async () => { + client = await createJMAPClientFromLayer(testLayer as any) + + expect(client.accountId).toBe( + JMAPFixtures.session.primaryAccounts['urn:ietf:params:jmap:mail'], + ) + }) + + it('should expose the full session object', async () => { + client = await createJMAPClientFromLayer(testLayer as any) + + expect(client.session.username).toBe('test@example.com') + expect(client.session.accounts).toBeDefined() + }) + }) + + describe('mailbox namespace', () => { + it('should get all mailboxes using auto-discovered accountId', async () => { + client = await createJMAPClientFromLayer(testLayer as any) + const mailboxes = await client.mailbox.getAll() + + expect(mailboxes).toEqual(JMAPFixtures.mailboxes) + }) + + it('should allow explicit accountId override', async () => { + client = await createJMAPClientFromLayer(testLayer as any) + const mailboxes = await client.mailbox.getAll('account-1') + + expect(mailboxes).toEqual(JMAPFixtures.mailboxes) + }) + + it('should query mailboxes', async () => { + client = await createJMAPClientFromLayer(testLayer as any) + const result = await client.mailbox.query({ + accountId: 'account-1', + filter: { role: 'inbox' }, + }) + + expect(result.ids).toEqual(['mailbox-1']) + }) + + it('should get mailboxes by args', async () => { + client = await createJMAPClientFromLayer(testLayer as any) + const result = await client.mailbox.get({ + accountId: 'account-1', + ids: null, + }) + + expect(result.list).toEqual(JMAPFixtures.mailboxes) + expect(result.state).toBe('state-1') + }) + }) + + describe('email namespace', () => { + it('should query emails', async () => { + client = await createJMAPClientFromLayer(testLayer as any) + const result = await client.email.query({ + accountId: 'account-1', + filter: {}, + }) + + expect(result).toBeDefined() + expect(result.ids).toBeDefined() + }) + + it('should get emails by args', async () => { + client = await createJMAPClientFromLayer(testLayer as any) + const result = await client.email.get({ + accountId: 'account-1', + ids: null, + }) + + expect(result).toBeDefined() + expect(result.list).toBeDefined() + }) + }) + + describe('dispose', () => { + it('should dispose without error', async () => { + client = await createJMAPClientFromLayer(testLayer as any) + await expect(client.dispose()).resolves.toBeUndefined() + }) + }) + + describe('batch', () => { + it('should expose low-level batch API', async () => { + client = await createJMAPClientFromLayer(testLayer as any) + const response = await client.batch([ + ['Mailbox/get', { accountId: 'account-1', ids: null }, 'call-1'], + ]) + + expect(response.methodResponses).toBeDefined() + expect(response.methodResponses.length).toBeGreaterThan(0) + }) + }) +})