From eaa2e7ce213a2b6041c69300ada6e08df81ba90a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Feb 2026 21:22:04 +0000 Subject: [PATCH] feat: Add Identity service (Identity/get, Identity/set, Identity/changes) Implement RFC 8621 Section 6 Identity methods with schema validation, service layer, and Promise-based wrapper, enabling downstream consumers to use client.identity instead of low-level client.batch calls. https://claude.ai/code/session_01EHPdDuJwHEyaghPQ4KwBYE --- src/client/wrapper.ts | 45 ++++++++ src/identity/index.ts | 19 ++++ src/identity/schema.ts | 195 +++++++++++++++++++++++++++++++++++ src/identity/service.ts | 195 +++++++++++++++++++++++++++++++++++ src/index.ts | 1 + src/layers.ts | 5 +- tests/config/capabilities.ts | 6 +- 7 files changed, 462 insertions(+), 4 deletions(-) create mode 100644 src/identity/index.ts create mode 100644 src/identity/schema.ts create mode 100644 src/identity/service.ts diff --git a/src/client/wrapper.ts b/src/client/wrapper.ts index c585a69..b3caf45 100644 --- a/src/client/wrapper.ts +++ b/src/client/wrapper.ts @@ -4,6 +4,7 @@ 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 { IdentityService } from '../identity/service.ts' import { JMAPLive, JMAPLiveWithConfig } from '../layers.ts' import type { Id } from '../shared/common.ts' import type { @@ -54,6 +55,15 @@ import type { EmailSubmissionChangesArguments, EmailSubmissionChangesResponse, } from '../submission/schema.ts' +import type { + IdentityObject as IdentityObjectSchema, + IdentityGetArguments, + IdentityGetResponse, + IdentitySetArguments, + IdentitySetResponse, + IdentityChangesArguments, + IdentityChangesResponse, +} from '../identity/schema.ts' // Infer the actual TypeScript types from Schema types type Mailbox = Schema.Schema.Type @@ -71,6 +81,10 @@ type EmailSubmissionSetResponseResult = Schema.Schema.Type type EmailSubmissionQueryChangesResult = Schema.Schema.Type type EmailSubmissionChangesResult = Schema.Schema.Type +type IdentityObject = Schema.Schema.Type +type IdentityGetResult = Schema.Schema.Type +type IdentitySetResult = Schema.Schema.Type +type IdentityChangesResult = Schema.Schema.Type /** * Promise-based Mailbox namespace. @@ -161,6 +175,20 @@ export interface SubmissionNamespace { readonly getRecent: (limit?: number, accountId?: string) => Promise } +/** + * Promise-based Identity namespace. + * + * Core JMAP methods accept their full argument objects. + * Convenience methods accept an optional `accountId` override. + */ +export interface IdentityNamespace { + readonly get: (args: IdentityGetArguments) => Promise + readonly set: (args: IdentitySetArguments) => Promise + readonly changes: (args: IdentityChangesArguments) => Promise + readonly getAll: (accountId?: string) => Promise + readonly getDefault: (accountId?: string) => Promise +} + /** * Promise-based JMAP client that hides Effect internals. * @@ -204,6 +232,8 @@ export interface JMAPClientWrapper { readonly email: EmailNamespace /** Email submission (sending) operations */ readonly submission: SubmissionNamespace + /** Identity operations */ + readonly identity: IdentityNamespace /** Dispose the underlying runtime and release resources */ readonly dispose: () => Promise } @@ -395,6 +425,20 @@ const buildClient = async ( run(Effect.flatMap(EmailSubmissionService, svc => svc.getRecent(acct ?? accountId, limit))), } + // --- Identity namespace --- + const identity: IdentityNamespace = { + get: (args) => + run(Effect.flatMap(IdentityService, svc => svc.get(args))), + set: (args) => + run(Effect.flatMap(IdentityService, svc => svc.set(args))), + changes: (args) => + run(Effect.flatMap(IdentityService, svc => svc.changes(args))), + getAll: (acct?) => + run(Effect.flatMap(IdentityService, svc => svc.getAll(acct ?? accountId))), + getDefault: (acct?) => + run(Effect.flatMap(IdentityService, svc => svc.getDefault(acct ?? accountId))), + } + return { accountId, session, @@ -402,6 +446,7 @@ const buildClient = async ( mailbox, email, submission, + identity, dispose: () => runtime.dispose(), } } diff --git a/src/identity/index.ts b/src/identity/index.ts new file mode 100644 index 0000000..2ed4810 --- /dev/null +++ b/src/identity/index.ts @@ -0,0 +1,19 @@ +// Identity schema and types +export { + IdentityObject, + IdentityMutable, + IdentityGetArguments, + IdentityGetResponse, + IdentitySetArguments, + IdentitySetResponse, + IdentityChangesArguments, + IdentityChangesResponse, + IdentityHelpers, +} from './schema.ts' + +// Identity service +export { + IdentityService, + IdentityServiceLive, + type IdentityServiceInterface, +} from './service.ts' diff --git a/src/identity/schema.ts b/src/identity/schema.ts new file mode 100644 index 0000000..055617d --- /dev/null +++ b/src/identity/schema.ts @@ -0,0 +1,195 @@ +import { Schema } from 'effect' +import { Id, UnsignedInt, EmailAddress } from '../shared/common.ts' + +/** + * JMAP Identity schemas - RFC 8621 Section 6 + */ + +/** + * Core Identity object (full) + * Per RFC 8621 Section 6: replyTo and bcc are nullable (Type|null) + * All nullable fields are also optional since servers may omit them entirely + */ +export const IdentityObject = Schema.Struct({ + id: Id, + name: Schema.String, + email: Schema.String, + replyTo: Schema.optional(Schema.NullOr(Schema.Array(EmailAddress))), + bcc: Schema.optional(Schema.NullOr(Schema.Array(EmailAddress))), + textSignature: Schema.String, + htmlSignature: Schema.String, + mayDelete: Schema.Boolean +}) + +export type IdentityObject = Schema.Schema.Type + +/** + * Identity properties that can be set during creation + * Per RFC 8621: email is creatable only; name, replyTo, bcc, textSignature, + * htmlSignature are creatable and updatable + */ +export const IdentityMutable = Schema.Struct({ + name: Schema.optional(Schema.String), + email: Schema.optional(Schema.String), + replyTo: Schema.optional(Schema.NullOr(Schema.Array(EmailAddress))), + bcc: Schema.optional(Schema.NullOr(Schema.Array(EmailAddress))), + textSignature: Schema.optional(Schema.String), + htmlSignature: Schema.optional(Schema.String) +}) + +export type IdentityMutable = Schema.Schema.Type + +/** + * Arguments for Identity/get method + */ +export const IdentityGetArguments = Schema.Struct({ + accountId: Schema.String, + ids: Schema.Union(Schema.Array(Id), Schema.Null), + properties: Schema.optional(Schema.Array(Schema.String)) +}) + +export type IdentityGetArguments = Schema.Schema.Type + +/** + * Response for Identity/get method + */ +export const IdentityGetResponse = Schema.Struct({ + accountId: Schema.String, + state: Schema.String, + list: Schema.Array(IdentityObject), + notFound: Schema.Array(Id) +}) + +export type IdentityGetResponse = Schema.Schema.Type + +/** + * Arguments for Identity/set method + */ +export const IdentitySetArguments = Schema.Struct({ + accountId: Schema.String, + ifInState: Schema.optional(Schema.String), + create: Schema.optional(Schema.Record({ + key: Schema.String, + value: IdentityMutable + })), + update: Schema.optional(Schema.Record({ + key: Id, + value: Schema.Struct({ + name: Schema.optional(Schema.String), + replyTo: Schema.optional(Schema.NullOr(Schema.Array(EmailAddress))), + bcc: Schema.optional(Schema.NullOr(Schema.Array(EmailAddress))), + textSignature: Schema.optional(Schema.String), + htmlSignature: Schema.optional(Schema.String) + }) + })), + destroy: Schema.optional(Schema.Array(Id)) +}) + +export type IdentitySetArguments = Schema.Schema.Type + +/** + * Response for Identity/set method + * All nullable fields are also optional since servers may omit them entirely + */ +export const IdentitySetResponse = Schema.Struct({ + accountId: Schema.String, + oldState: Schema.String, + newState: Schema.String, + created: Schema.optional(Schema.NullOr( + Schema.Record({ + key: Schema.String, + value: Schema.Struct({ + id: Id, + mayDelete: Schema.optional(Schema.Boolean) + }) + }) + )), + updated: Schema.optional(Schema.NullOr( + Schema.Record({ + key: Id, + value: Schema.NullOr(Schema.Any) + }) + )), + destroyed: Schema.optional(Schema.NullOr(Schema.Array(Id))), + notCreated: Schema.optional(Schema.NullOr( + Schema.Record({ + key: Schema.String, + value: Schema.Any + }) + )), + notUpdated: Schema.optional(Schema.NullOr( + Schema.Record({ + key: Id, + value: Schema.Any + }) + )), + notDestroyed: Schema.optional(Schema.NullOr( + Schema.Record({ + key: Id, + value: Schema.Any + }) + )) +}) + +export type IdentitySetResponse = Schema.Schema.Type + +/** + * Arguments for Identity/changes method + */ +export const IdentityChangesArguments = Schema.Struct({ + accountId: Schema.String, + sinceState: Schema.String, + maxChanges: Schema.optional(UnsignedInt) +}) + +export type IdentityChangesArguments = Schema.Schema.Type + +/** + * Response for Identity/changes method + */ +export const IdentityChangesResponse = Schema.Struct({ + accountId: Schema.String, + oldState: Schema.String, + newState: Schema.String, + hasMoreChanges: Schema.Boolean, + created: Schema.Array(Id), + updated: Schema.Array(Id), + destroyed: Schema.Array(Id) +}) + +export type IdentityChangesResponse = Schema.Schema.Type + +/** + * Helper functions for working with identities + */ +export const IdentityHelpers = { + /** + * Check if an identity can be deleted + */ + canDelete: (identity: IdentityObject): boolean => + identity.mayDelete, + + /** + * Check if an identity has a custom reply-to address + */ + hasReplyTo: (identity: IdentityObject): boolean => + identity.replyTo !== null && identity.replyTo !== undefined && identity.replyTo.length > 0, + + /** + * Check if an identity has auto-bcc configured + */ + hasBcc: (identity: IdentityObject): boolean => + identity.bcc !== null && identity.bcc !== undefined && identity.bcc.length > 0, + + /** + * Check if an identity has a text signature + */ + hasTextSignature: (identity: IdentityObject): boolean => + identity.textSignature.length > 0, + + /** + * Check if an identity has an HTML signature + */ + hasHtmlSignature: (identity: IdentityObject): boolean => + identity.htmlSignature.length > 0, +} diff --git a/src/identity/service.ts b/src/identity/service.ts new file mode 100644 index 0000000..8814a1f --- /dev/null +++ b/src/identity/service.ts @@ -0,0 +1,195 @@ +import { Context, Effect, Layer } from "effect"; +import { HttpClient } from "@effect/platform"; + +import { JMAPClientService } from "../client/client.ts"; +import { Invocation } from "../client/types.ts"; +import { IdGenerator } from "../shared/id-generator.ts"; +import { + JMAPMethodError, + NetworkError, + AuthenticationError, + SessionError, +} from "../client/errors.ts"; +import { CAPABILITY_SETS } from "../client/capabilities.ts"; +import { extractMethodResponse } from "../client/response-utils.ts"; +import { + IdentityObject, + IdentityGetArguments, + IdentityGetResponse, + IdentitySetArguments, + IdentitySetResponse, + IdentityChangesArguments, + IdentityChangesResponse, +} from "./schema.ts"; +import * as Schema from "effect/Schema"; + +/** + * Identity Service Interface + */ +export interface IdentityServiceInterface { + /** + * Get identities by ID + */ + readonly get: ( + args: IdentityGetArguments, + ) => Effect.Effect< + Schema.Schema.Type, + JMAPMethodError | NetworkError | AuthenticationError | SessionError, + JMAPClientService | HttpClient.HttpClient | IdGenerator + >; + + /** + * Create, update, or destroy identities + */ + readonly set: ( + args: IdentitySetArguments, + ) => Effect.Effect< + Schema.Schema.Type, + JMAPMethodError | NetworkError | AuthenticationError | SessionError, + JMAPClientService | HttpClient.HttpClient | IdGenerator + >; + + /** + * Get changes to identities since a state + */ + readonly changes: ( + args: IdentityChangesArguments, + ) => Effect.Effect< + Schema.Schema.Type, + JMAPMethodError | NetworkError | AuthenticationError | SessionError, + JMAPClientService | HttpClient.HttpClient | IdGenerator + >; + + /** + * Get all identities for an account + */ + readonly getAll: ( + accountId: string, + ) => Effect.Effect< + readonly IdentityObject[], + JMAPMethodError | NetworkError | AuthenticationError | SessionError, + JMAPClientService | HttpClient.HttpClient | IdGenerator + >; + + /** + * Get the default identity (first identity returned) + */ + readonly getDefault: ( + accountId: string, + ) => Effect.Effect< + IdentityObject, + JMAPMethodError | NetworkError | AuthenticationError | SessionError, + JMAPClientService | HttpClient.HttpClient | IdGenerator + >; +} + +/** + * Identity Service Tag + */ +export class IdentityService extends Context.Tag("IdentityService")< + IdentityService, + IdentityServiceInterface +>() {} + +/** + * Live implementation of Identity Service + */ +const makeIdentityServiceLive = (): IdentityServiceInterface => { + const get: IdentityServiceInterface["get"] = (args) => + Effect.gen(function* () { + const client = yield* JMAPClientService; + const idGenerator = yield* IdGenerator; + const id = yield* idGenerator.generate; + const callId = `identity-get-${id}`; + + const methodCall: Invocation = ["Identity/get", args, callId]; + + const response = yield* client.batch([methodCall], [...CAPABILITY_SETS.SUBMISSION]); + return yield* extractMethodResponse( + response, + "Identity/get", + callId, + IdentityGetResponse, + ); + }); + + const set: IdentityServiceInterface["set"] = (args) => + Effect.gen(function* () { + const client = yield* JMAPClientService; + const idGenerator = yield* IdGenerator; + const id = yield* idGenerator.generate; + const callId = `identity-set-${id}`; + + const methodCall: Invocation = ["Identity/set", args, callId]; + + const response = yield* client.batch([methodCall], [...CAPABILITY_SETS.SUBMISSION]); + return yield* extractMethodResponse( + response, + "Identity/set", + callId, + IdentitySetResponse, + ); + }); + + const changes: IdentityServiceInterface["changes"] = (args) => + Effect.gen(function* () { + const client = yield* JMAPClientService; + const idGenerator = yield* IdGenerator; + const id = yield* idGenerator.generate; + const callId = `identity-changes-${id}`; + + const methodCall: Invocation = ["Identity/changes", args, callId]; + + const response = yield* client.batch([methodCall], [...CAPABILITY_SETS.SUBMISSION]); + return yield* extractMethodResponse( + response, + "Identity/changes", + callId, + IdentityChangesResponse, + ); + }); + + const getAll: IdentityServiceInterface["getAll"] = (accountId) => + Effect.gen(function* () { + const result = yield* get({ + accountId, + ids: null, + }); + + return result.list; + }); + + const getDefault: IdentityServiceInterface["getDefault"] = (accountId) => + Effect.gen(function* () { + const result = yield* get({ + accountId, + ids: null, + }); + + const first = result.list[0]; + if (!first) { + return yield* Effect.die( + new Error("No identities found for account") + ); + } + + return first; + }); + + return { + get, + set, + changes, + getAll, + getDefault, + }; +}; + +/** + * Live layer for Identity Service + * Dependencies: IdGenerator (required at runtime by service methods) + */ +export const IdentityServiceLive = Layer.effect( + IdentityService, + Effect.sync(makeIdentityServiceLive) +); diff --git a/src/index.ts b/src/index.ts index d54ac46..dd60284 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,4 +4,5 @@ export * from './shared/index.js' export * from './email/index.js' export * from './mailbox/index.js' export * from './submission/index.js' +export * from './identity/index.js' export * from './layers.js' \ No newline at end of file diff --git a/src/layers.ts b/src/layers.ts index 8230adf..61bfd67 100644 --- a/src/layers.ts +++ b/src/layers.ts @@ -4,6 +4,7 @@ import { JMAPClientLive as JMAPClientLiveImpl, defaultConfig, type JMAPClientCon import { MailboxServiceLive } from './mailbox/service.ts' import { EmailServiceLive } from './email/service.ts' import { EmailSubmissionServiceLive } from './submission/service.ts' +import { IdentityServiceLive } from './identity/service.ts' import { IdGeneratorLive } from './shared/id-generator.ts' export * from './client/live.ts' @@ -16,7 +17,7 @@ export * from './client/test.ts' * This layer includes: * - HTTP client (NodeHttpClient.layerUndici) * - JMAP client with default configuration - * - All JMAP services (Mailbox, Email, EmailSubmission) + * - All JMAP services (Mailbox, Email, EmailSubmission, Identity) * - ID generator * * @param sessionUrl - JMAP session URL (e.g., 'https://api.fastmail.com/jmap/session') @@ -55,6 +56,7 @@ export const JMAPLive = ( MailboxServiceLive, EmailServiceLive, EmailSubmissionServiceLive, + IdentityServiceLive, IdGeneratorLive ), NodeHttpClient.layer @@ -95,6 +97,7 @@ export const JMAPLiveWithConfig = ( MailboxServiceLive, EmailServiceLive, EmailSubmissionServiceLive, + IdentityServiceLive, IdGeneratorLive ), NodeHttpClient.layer diff --git a/tests/config/capabilities.ts b/tests/config/capabilities.ts index d855ee4..d62dbef 100644 --- a/tests/config/capabilities.ts +++ b/tests/config/capabilities.ts @@ -44,9 +44,9 @@ export const JMAPCapabilities = { 'SearchSnippet/get': false, // Identity methods (RFC 8621 Section 6) - 'Identity/get': false, - 'Identity/set': false, - 'Identity/changes': false, + 'Identity/get': true, + 'Identity/set': true, + 'Identity/changes': true, // EmailSubmission methods (RFC 8621 Section 7) 'EmailSubmission/get': true,