diff --git a/package.json b/package.json index e2b4318..4d5bbe9 100644 --- a/package.json +++ b/package.json @@ -31,9 +31,13 @@ "url": "https://github.com/OpenPaaS-Suite/jmap-client-ts/issues" }, "homepage": "https://github.com/OpenPaaS-Suite/jmap-client-ts#README.md", + "dependencies": { + "isomorphic-ws": "4.0.1" + }, "devDependencies": { "@types/jest": "^26.0.20", "@types/node-fetch": "^2.5.8", + "@types/ws": "^8.5.0", "@typescript-eslint/eslint-plugin": "^4.13.0", "@typescript-eslint/parser": "^4.13.0", "axios": "^0.21.1", @@ -48,9 +52,11 @@ "lint-staged": "^10.5.3", "node-fetch": "^2.6.1", "prettier": "2.1.2", - "testcontainers": "^6.4.1", + "rxjs": "^7.5.5", + "testcontainers": "^8.4.0", "ts-jest": "^26.4.4", "ts-node": "9.0.0", - "typescript": "4.0.3" + "typescript": "^4.6.2", + "ws": "^8.5.0" } } diff --git a/src/index.ts b/src/index.ts index 6b4db0e..0fcf8a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,7 +26,9 @@ import { IEmailChangesResponse, IInvocation, } from './types'; - +import { PushClient } from './push'; +import { Observable } from 'rxjs'; +import * as WebSocket from 'isomorphic-ws'; export class Client { private readonly DEFAULT_USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail']; @@ -35,18 +37,23 @@ export class Client { private sessionUrl: string; private overriddenApiUrl?: string; + private overriddenPushUrl?: string; private session?: ISession; + private pushClient?: PushClient; + constructor({ sessionUrl, accessToken, overriddenApiUrl, + overriddenPushUrl, transport, httpHeaders, }: { sessionUrl: string; accessToken: string; overriddenApiUrl?: string; + overriddenPushUrl?: string; transport: Transport; httpHeaders?: { [headerName: string]: string }; }) { @@ -54,12 +61,21 @@ export class Client { if (overriddenApiUrl) { this.overriddenApiUrl = overriddenApiUrl; } + if (overriddenPushUrl) { + this.overriddenPushUrl = overriddenPushUrl; + } this.transport = transport; this.httpHeaders = { Accept: 'application/json;jmapVersion=rfc-8621', Authorization: `Bearer ${accessToken}`, ...(httpHeaders ? httpHeaders : {}), }; + this.pushClient = new PushClient({ + client: this, + transport, + webSocketConstructor: (url: string) => new WebSocket(url), + httpHeaders: this.httpHeaders, + }); } public fetchSession(): Promise { @@ -140,7 +156,7 @@ export class Client { } private request(methodName: IMethodName, args: IArguments) { - const apiUrl = this.overriddenApiUrl || this.getSession().apiUrl; + const apiUrl = this.getApiUrl(); return this.transport .post<{ sessionState: string; @@ -164,6 +180,32 @@ export class Client { }); } + public getApiUrl(): string { + return this.overriddenApiUrl || this.getSession().apiUrl; + } + + public getPushUrl(): string { + return ( + this.overriddenPushUrl || this.getSession().capabilities['urn:ietf:params:jmap:websocket'].url + ); + } + + public pushStart(): Promise { + if (this.pushClient) { + return this.pushClient.start(); + } + + this.throwWebsocketUndefined(); + } + + public pushMailbox(): Observable<{ [accountId: string]: string }> { + if (this.pushClient) { + return this.pushClient.mailbox(); + } + + this.throwWebsocketUndefined(); + } + private replaceAccountId(input: U): U { return input.accountId !== null ? input @@ -176,4 +218,8 @@ export class Client { private getCapabilities() { return this.session?.capabilities ? Object.keys(this.session.capabilities) : this.DEFAULT_USING; } + + private throwWebsocketUndefined(): never { + throw new Error('Websocket API is not defined'); + } } diff --git a/src/push.ts b/src/push.ts new file mode 100644 index 0000000..1c01300 --- /dev/null +++ b/src/push.ts @@ -0,0 +1,142 @@ +import { defer, Observable, Subject } from 'rxjs'; +import { Client } from '.'; +import { ENTITY_TYPES, IEntityType, IStateChange, ITypeState, IWebSocketPushEnable } from './types'; +import { Transport } from './utils/transport'; +import * as ws from 'isomorphic-ws'; + +export class PushClient { + private client: Client; + private transport: Transport; + private httpHeaders: { [headerName: string]: string }; + private webSocket?: ws; + private webSocketSubjects: { [_ in IEntityType]: Subject<{ [accountId: string]: string }> }; + + private started?: Promise; + private enabledDataTypes: { [_ in IEntityType]?: boolean }; + + constructor({ + client, + transport, + httpHeaders, + }: { + client: Client; + transport: Transport; + httpHeaders: { [headerName: string]: string }; + }) { + this.client = client; + this.transport = transport; + this.httpHeaders = httpHeaders; + this.webSocketSubjects = { + Mailbox: new Subject(), + Email: new Subject(), + EmailSubmission: new Subject(), + }; + this.enabledDataTypes = {}; + } + + public start(): Promise { + if (!this.started) { + this.started = new Promise(resolve => { + this.transport + .post<{ + value: string; + }>(`${this.client.getApiUrl()}/ws/ticket`, '', this.httpHeaders) + .then(response => { + const ticket = response.value; + const webSocket = new ws(`${this.client.getPushUrl()}?ticket=${ticket}`); + webSocket.onopen = () => { + this.webSocket = webSocket; + this.sendSubscriptions(webSocket, this.enabledDataTypes); + resolve(); + }; + webSocket.onclose = () => { + for (const subject of Object.values(this.webSocketSubjects)) { + subject.complete(); + } + + delete this.webSocket; + }; + webSocket.onmessage = message => { + const data = JSON.parse(message.data as string); + if (data['@type'] == 'StateChange') { + for (const entityType of ENTITY_TYPES) { + if (this.stateChangeContainsType(data, entityType)) { + this.webSocketSubjects[entityType].next( + this.transformStateChange(data.changed, entityType), + ); + } + } + } + }; + webSocket.onerror = event => { + console.log(`Error with websocket: ${JSON.stringify(event)}`); + for (const subject of Object.values(this.webSocketSubjects)) { + subject.error(event); + } + + delete this.webSocket; + }; + }); + }); + } + + return this.started; + } + + public stop(): void { + this.webSocket?.close(); + } + + public mailbox(): Observable<{ [accountId: string]: string }> { + return defer(() => { + if (!this.enabledDataTypes.Mailbox) { + this.enabledDataTypes.Mailbox = true; + + if (this.webSocket) { + this.sendSubscriptions(this.webSocket, this.enabledDataTypes); + } + } + + return this.webSocketSubjects.Mailbox.asObservable(); + }); + } + + private sendSubscriptions( + webSocket: ws, + enabledDataTypes: { + [_ in IEntityType]?: boolean; + }, + ) { + const payload: IWebSocketPushEnable = { + '@type': 'WebSocketPushEnable', + dataTypes: Object.keys(enabledDataTypes), + }; + webSocket.send(JSON.stringify(payload)); + } + + private stateChangeContainsType(stateChange: IStateChange, type: IEntityType): boolean { + for (const accountId of Object.keys(stateChange.changed)) { + if (stateChange.changed[accountId][type]) { + return true; + } + } + + return false; + } + + private transformStateChange( + changed: { [accountId: string]: ITypeState }, + type: IEntityType, + ): { [accountId: string]: string } { + const changedFlattened: { [accountId: string]: string } = {}; + + for (const accountId of Object.keys(changed)) { + const entityState = changed[accountId][type]; + if (entityState) { + changedFlattened[accountId] = entityState; + } + } + + return changedFlattened; + } +} diff --git a/src/types.ts b/src/types.ts index ca20d83..26120fe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,6 @@ +export const ENTITY_TYPES = ['Mailbox', 'Email', 'EmailSubmission'] as const; +export type IEntityType = typeof ENTITY_TYPES[number]; + export type IMethodName = | 'Mailbox/get' | 'Mailbox/changes' @@ -137,6 +140,37 @@ export type IEmailQueryArguments = IQueryArguments; export type IEmailQueryResponse = IQueryResponse; +/** + * See https://jmap.io/spec-core.html#the-statechange-object + */ +export interface IStateChange { + '@type': 'StateChange'; + changed: { + [accountId: string]: ITypeState; + }; +} + +/** + * See https://tools.ietf.org/html/rfc8887#section-4.3.5.2 + */ +export interface IWebSocketPushEnable { + '@type': 'WebSocketPushEnable'; + dataTypes: string[] | null; + pushState?: string; +} + +/** + * See https://tools.ietf.org/html/rfc8887#section-4.3.5.3 + */ +export interface IWebSocketPushDisable { + '@type': 'WebSocketPushDisable'; +} + +/** + * See https://jmap.io/spec-core.html#the-statechange-object + */ +export type ITypeState = Partial<{ [_ in IEntityType]: string }>; + /** * See https://jmap.io/spec-core.html#query */ @@ -167,14 +201,20 @@ export interface IRequest { * See https://jmap.io/spec-core.html#the-jmap-session-resource */ export interface ICapabilities { - maxSizeUpload: number; - maxConcurrentUpload: number; - maxSizeRequest: number; - maxConcurrentRequests: number; - maxCallsInRequest: number; - maxObjectsInGet: number; - maxObjectsInSet: number; - collationAlgorithms: string[]; + 'urn:ietf:params:jmap:core': { + maxSizeUpload: number; + maxConcurrentUpload: number; + maxSizeRequest: number; + maxConcurrentRequests: number; + maxCallsInRequest: number; + maxObjectsInGet: number; + maxObjectsInSet: number; + collationAlgorithms: string[]; + }; + 'urn:ietf:params:jmap:websocket': { + url: string; + supportsPush: boolean; + }; } /** diff --git a/tests/integration.spec.ts b/tests/integration.spec.ts index f44f731..2c1857f 100644 --- a/tests/integration.spec.ts +++ b/tests/integration.spec.ts @@ -3,7 +3,7 @@ import { GenericContainer, StartedTestContainer } from 'testcontainers'; import { Client } from '../src/index'; import { AxiosTransport } from '../src/utils/axios-transport'; import axios from 'axios'; -import { IEmailChangesResponse, IMailboxChangesResponse } from '../src/types'; +import { IEmailChangesResponse, IError, IMailboxChangesResponse } from '../src/types'; describe('jmap-client-ts', () => { const DEFAULT_TIMEOUT = 60000; @@ -14,19 +14,24 @@ describe('jmap-client-ts', () => { let webadminUrl: string; let sessionUrl: string; let overriddenApiUrl: string; + let overriddenPushUrl: string; let currentUserNumber = 0; let currentUser: string; let container: StartedTestContainer; let client: Client; beforeAll(async () => { - container = await new GenericContainer('linagora/james-memory', 'branch-master') + container = await new GenericContainer('linagora/tmail-backend:memory-branch-master') .withExposedPorts(JMAP_PORT, WEBADMIN_PORT) + .withCopyFileToContainer('./tests/jwt_privatekey', '/root/conf/jwt_privatekey') + .withCopyFileToContainer('./tests/jwt_publickey', '/root/conf/jwt_publickey') + .withCopyFileToContainer('./tests/jmap.properties', '/root/conf/jmap.properties') .start(); webadminUrl = `http://${container.getHost()}:${container.getMappedPort(WEBADMIN_PORT)}`; sessionUrl = `http://${container.getHost()}:${container.getMappedPort(JMAP_PORT)}/jmap/session`; overriddenApiUrl = `http://${container.getHost()}:${container.getMappedPort(JMAP_PORT)}/jmap`; + overriddenPushUrl = `ws://${container.getHost()}:${container.getMappedPort(JMAP_PORT)}/jmap/ws`; }, DEFAULT_TIMEOUT); beforeEach(async () => { @@ -45,6 +50,7 @@ describe('jmap-client-ts', () => { accessToken: '', httpHeaders: generateHeaders(currentUser, PASSWORD), overriddenApiUrl, + overriddenPushUrl, transport: new AxiosTransport(axios), }); @@ -56,17 +62,33 @@ describe('jmap-client-ts', () => { }); it('should get error correctly', async () => { - let error = null; + let error: IError | null = null; try { await client.mailbox_get({ accountId: 'unknown-account-id', ids: null, }); - } catch (e) { - error = e; + } catch (e: any) { + error = e as IError; } - expect(error.type).toEqual('accountNotFound'); + expect(error && error.type).toEqual('accountNotFound'); + }); + + it('should have push working', () => { + return new Promise(resolve => { + client.pushStart().then(() => { + const subscription = client.pushMailbox().subscribe(change => { + expect(change[client.getFirstAccountId()]).toBeDefined(); + subscription.unsubscribe(); + resolve(); + }); + client.mailbox_get({ + accountId: client.getFirstAccountId(), + ids: null, + }); + }); + }); }); it('should have mailbox_get working', async () => { diff --git a/tests/jmap.properties b/tests/jmap.properties new file mode 100644 index 0000000..ecd1c86 --- /dev/null +++ b/tests/jmap.properties @@ -0,0 +1,31 @@ +# Configuration file for JMAP +# Read https://james.apache.org/server/config-jmap.html for further details + +enabled=true + +tls.keystoreURL=file://conf/keystore +tls.secret=james72laBalle + +# +# If you wish to use OAuth authentication, you should provide a valid JWT public key. +# The following entry specify the link to the URL of the public key file, +# which should be a PEM format file. +# +jwt.publickeypem.url=file://conf/jwt_publickey + +# Should simple Email/query be resolved against a Cassandra projection, or should we resolve them against ElasticSearch? +# This enables a higher resilience, but the projection needs to be correctly populated. False by default. +view.email.query.enabled=true + +# For generate short lived token +jwt.privatekeypem.url=file://conf/jwt_privatekey + +# Gives an URL for OpenID discovery being exposed on .well-known/webfinger endpoint +# CF https://openid.net/specs/openid-connect-discovery-1_0.html +# oidc.provider.url=https://auth.linagora.com/auth/realms/jmap + +# Allow using tickets for authentication +authentication.strategy.rfc8621=BasicAuthenticationStrategy,com.linagora.tmail.james.jmap.ticket.TicketAuthenticationStrategy + +# Make HTTP headers not needed for websocket connections +jmap.version.default=rfc-8621 \ No newline at end of file diff --git a/tests/jwt_privatekey b/tests/jwt_privatekey new file mode 100644 index 0000000..234c15c --- /dev/null +++ b/tests/jwt_privatekey @@ -0,0 +1,39 @@ +-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/tests/jwt_publickey b/tests/jwt_publickey new file mode 100644 index 0000000..8608c5d --- /dev/null +++ b/tests/jwt_publickey @@ -0,0 +1,11 @@ +-----BEGIN PUBLIC KEY----- +MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEA0/dyPosrRWdIwdsBCeX0 +N+ZKAYdWeYamoCP4BXf/oXF+ED7Wdtfa4d02Y8Mn97drHpBoMauqtknslzsYjZ5v +941yEtYpIYcEDXeCmaxlZX0kA8LzbyaknlTqExICxtRQsy3M1C0pbaiu4rpz3mZX +Xbri36SxY8OWmJ4BNTHtWdd2bwSM0OG5NHOopt8obwVhqNCDCkiMV59blQNTU+9v +XM5OQeQi4ZKWIYi4JWmmH4Jzn0/TimdI5kJJOMkGpZ2YVz1q/yMzJ2aXDr4sTcY5 +3q+x1XbvVi3pEU8gQUzjLbsNQxDE2noBa0uBDIR2bVY9ja3leKUu2bYWyINNUIWk +vQvUcBRkJ9fQ+GiesLy89JZkxW9ieguMnN3xy4ZIxRyGFI8Xc3Vo8K1wVgfl9g5R +GQKKgm2+3EJShdugcwat4XR5VkGe4sYToca4yBbGU8dyIpPxBy5t5GlRwKbaYhS1 +g5YB7yNDbc4fQaxrSMsmJPdF1LcPlOJzrVnNrwSBsY1ZAgMBAAE= +-----END PUBLIC KEY----- \ No newline at end of file