[ 
https://issues.apache.org/jira/browse/JAMES-3539?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=17432781#comment-17432781
 ] 

Benoit Tellier commented on JAMES-3539:
---------------------------------------

Hello all,

My team had been working on a technical split for this issue. Here are the 
various sub tasks we lan on doing to implement this JMAP feature.

h3. PushSubscriptionRepository API

This API would allow storing PushSubscriptions. We would first provide its API, 
memory implementation, contract, and test the memory implementation against the 
contract.

{code:scala}
case class PushSubscriptionId(value: UUID)
case class DeviceClientId(value: String)
case class VerificationCode(value: String)
case class PushSubscriptionServerURL(url: String)
case class PushSubscriptionExpiredTime(expired: ZonedDateTime)
case class PushSubscriptionKeys(p256dh: String, auth: String)

case class PushSubscriptionCreationRequest(deviceId: DeviceClientId,
                            types: Seq[TypeName],
                            url: PushSubscriptionServerURL,
                            expired: Option[PushSubscriptionExpiredTime],
                            keys: Option[PushSubscriptionKeys])

case class PushSubscription(id: PushSubscriptionId,
                                deviceClientId: DeviceClientId,
                                types: Seq[TypeName],
                                verificationCode: VerificationCode,
                                validated: Boolean,
                                expired: PushSubscriptionExpiredTime,
                                url: PushSubscriptionServerURL,
                                keys: Option[PushSubscriptionKeys])

trait PushSubscriptionRepository {

  def save(username: Username, pushSubscription: 
PushSubscriptionCreationRequest): Publisher[PushSubscriptionId]

  def updateExpireTime(username: Username, id: PushSubscriptionId, newExpire: 
ZonedDateTime): Publisher[Unit]

  def updateTypes(username: Username, id: PushSubscriptionId, types: 
Seq[TypeName]): Publisher[Unit]

  def revoke(username: Username, id: PushSubscriptionId): Publisher[Unit]

  def get(username: Username, ids: Seq[PushSubscriptionId]): 
Publisher[PushSubscription]

  def list(username: Username): Publisher[PushSubscription]

  def validateVerificationCode(username: Username, id: PushSubscriptionId): 
Publisher[Unit]

}
{code}

Remarks:

 - When updateExpireTime | get, if record not found => then throw exception
 - when updateExpireTime if newExpire < now() => then throw exception
 - When saveif newExpire < now() => then throw exception
 - Outdated subscriptions should not be returned
 - deviceID is unique


h3. PushSubscriptionRepository Cassandra implementation

Table structure:

{code:scala}
PK user TEXT
CK device_id TEXT
C id UUID
C expired timestamp()
C types frozen set
C url TEXT
C verification_code TEXT
C encrypt_public_key TEXT
C encrypt_auth_secret TEXT
C validated BOOLEAN
{code}

h3. Implement a WebPushClient

In order to implement PushSubscription feature, we need an HTTP client to post 
messages from James to the Push Server.

We will use the reactor-netty library for it (HTTP client).


{code:scala}
     class PushClientConfiguration {
        Option[Int] maxTimeoutSeconds;
        Option[Int] maxConnections;
        Option[Int] maxRetryTimes;
        Option[Int] requestPerSeconds;
        reactor.core.scheduler.Scheduler scheduler;
    }
   enum Urgency {
         Low, VeryLow, Normal, High
   }
   case class Topic(value: String)
   case class TTL(value: unsignedInt)
   case class PushRequest(
         ttl: TTL,
         topic: Option[Topic],
         urgency: Option[Urgency],
         payload: Array[Byte])

    interface WebPushClient {
        Mono<Void> push(URL pushServerUrl, PushRequest request);
    }

    class DefaultWebPushClient implements WebPushClient {
        private reactor.netty.http.client.HttpClient httpClient;

        public 
DefaultPushSubscriptionClient(PushSubscriptionClientConfiguration 
configuration) {
            httpClient = ...
        }

        @Override
        public Mono<Void> push(URL pushServerUrl, PushRequest request) {
            httpClient.post()
                ....
        }
    }
{code}

h3. JMAP wiring

h4. PushSubscritpion/set create

{code:scala}
[[ "PushSubscription/set", {
  "create": {
    "4f29": {
      "deviceClientId": "a889-ffea-910",
      "url": "https://example.com/push";,
      "types": [ "Mailbox", "Email" ],
      "expires": "2021-11-13T02:14:29Z"
    }
  }
}, "0" ]]
{code}

That returns something like:

{code:scala}
[[ "PushSubscription/set", {
  "created": {
    "4f29": {
      "id": "P43dcfa4-1dd4-41ef-9156-2c89b3b19c60",
      "expires": "2021-11-12T02:14:29Z"
    }
  }
}, "0" ]]
{code}

Notes:
* accountId and states arguments are not being taken into account
* if `expires` is omitted in the request, the server must set one (like now + 
7days) and it should be returned in the response
* the server should limit expiracy to a maximum of 7 days

h4. PushSubscription/get method

The request is a classic /get jmap method call:

{code:scala}
[[ "PushSubscription/get", {
  "ids": null
}, "0" ]]
{code}

or

{code:scala}
[[ "PushSubscription/get", {
  "ids": ["a"]
}, "0" ]]
{code}

with the response looking like:

{code:scala}
[[ "PushSubscription/get", {
  "list": [{
      "id": "e50b2c1d-9553-41a3-b0a7-a7d26b599ee1",
      "deviceClientId": "b37ff8001ca0",
      "verificationCode": "b210ef734fe5f439c1ca386421359f7b",
      "expires": "2018-07-31T00:13:21Z",
      "types": [ "Todo" ]
  }, {
      "id": "f2d0aab5-e976-4e8b-ad4b-b380a5b987e4",
      "deviceClientId": "X8980fc",
      "verificationCode": "f3d4618a9ae15c8b7f5582533786d531",
      "expires": "2018-07-12T05:55:00Z",
      "types": [ "Mailbox", "Email", "EmailDelivery" ]
  }],
  "notFound": []
}, "0" ]]
{code}

Note: 
* no accountId and no state in the request (push subs are not tied to specific 
accounts)
* `url` and `keys` properties are forbidden to be returned. If the properties 
field has one of those specified, it should be rejected with a forbidden error.
 * set up property filtering on the returned JSON (like mailbox / email)
 * not yet validated subscription codes should not be returned

h4. PushSubscription/set update (subscription code)

This is the update part for subscription code. The request would look like: 

{code:scala}
[[ "PushSubscription/set", {
  "update": {
    "P43dcfa4-1dd4-41ef-9156-2c89b3b19c60": {
      "verificationCode": "da1f097b11ca17f06424e30bf02bfa67"
    }
  }
}, "0" ]]
{code}

And the response:

{code:scala}
[[ "PushSubscription/set", {
  "updated": {
    "P43dcfa4-1dd4-41ef-9156-2c89b3b19c60": { }
  }
}, "0" ]]
{code}

The verification code should match the one sent to the url provided by the 
client after he created the push subscription. If not, the update is rejected. 

Stored validated property should be set to true.

h4. PushSubscription/set update (types)

Clients should be able to changes the types of notifications they want to get 
through the push. It's a standard /set update request:

{code:scala}
[[ "PushSubscription/set", {
  "update": {
    "P43dcfa4-1dd4-41ef-9156-2c89b3b19c60": {
      "types": [ "Mailbox", "Email", "EmailDelivery" ]
    }
  }
}, "0" ]]
{code}

and the response:

{code:scala}
[[ "PushSubscription/set", {
  "updated": {
    "P43dcfa4-1dd4-41ef-9156-2c89b3b19c60": { }
  }
}, "0" ]]
{code}

h4. PushSubscription/set update (expires)

Clients can update the expiration of their push subscription any time (up to 
max 7 days) as long as their PushSubscription is valid. A request is a classic 
update one:

{code:scala}
[[ "PushSubscription/set", {
  "updated": {
    "P43dcfa4-1dd4-41ef-9156-2c89b3b19c60": {
      "expires": "2021-11-15T02:22:50Z"
    }
  }
}, "0" ]]
{code}

and the response:

{code:scala}
[[ "PushSubscription/set", {
  "updated": {
    "P43dcfa4-1dd4-41ef-9156-2c89b3b19c60": { }
  }
}, "0" ]]
{code}

Notes:
* if the value of expires set by the server is different than the one in the 
request (like it's been fixed by the server limit), the expires value should be 
returned in the response.

* expires in the past should be rejected

h4. PushSubscription/set delete

Classic /set delete to destroy a PushSubscription:

{code:scala}
[[ "PushSubscription/set", {
  "destroy": ["P43dcfa4-1dd4-41ef-9156-2c89b3b19c60"]
}, "0" ]]
{code}

with the response:

{code:scala}
[[ "PushSubscription/set", {
  "destroyed": ["P43dcfa4-1dd4-41ef-9156-2c89b3b19c60"]
}, "0" ]]
{code}

h4. PushSubscription/set create side-effects with validation code

After we create the push subscription `PushSubscription/set create` we send a 
PushVerification object to the url of the push.

{code:scala}
When a PushSubscription is created, the server MUST immediately push a 
PushVerification object to the URL. It has the following properties:

    @type: String This MUST be the string “PushVerification”.
    pushSubscriptionId: String The id of the push subscription that was created.
    verificationCode: String The verification code to add to the push 
subscription. This MUST contain sufficient entropy to avoid the client being 
able to guess the code via brute force.
{code}

PushSubscriptionSetCreateProcessor should use the WebPushClient to perform this 
task upon successfull `PushSubscription/set create` methods.

DoD: Integration tests +mockHTTP server

h3. Implement PushSubscriptionListener


In order to implement the PushSubscription feature, 
(https://jmap.io/spec-core.html#pushsubscription)
We need to handle events from the mailbox, then post a message to the Push 
Server.
We will use `event-bus` for that.

- Create `PushSubscriptionListener` implements `ReactiveGroupEventListener` - 
being on the JMAP event bus we handle `StateChangedEvent`
- Using `PushSubscriptionRepository` (#4399) for retrieve 
`List[PushSubscription]` 
- Filter the StateChange events to match the typesnames of the PushSubscription.
- For each case, use `WebPushClient` (#4401) to post a message to Push Server

h3. WebPushClient: Encryption

Following #4401 let's define a way to encrypt webPush messages:

{code:scala}
import com.google.crypto.tink.HybridEncrypt;

 case class PushRequest(
         ttl: TTL,
         topic: Option[Topic],
         urgency: Option[Urgency],
         payload: Array[Byte]) {
               
    def encrypt(hybridEncrypt: HybridEncrypt):  PushRequest = ???
{code}

>From a `PushSubscription` object introduced in #4399 add a methods to get the 
>corresponding option for getting the encryption keys as a 
>`Option[HybridEncrypt]`:

{code:scala}
case class PushSubscriptionKeys(p256dh: String, auth: String) {
  // Val so that we agressively validate it!
  val asHybridEncrypt: HybridEncrypt = new WebPushHybridEncrypt.Builder()
       .withAuthSecret(authSecret)
       .withRecipientPublicKey(recipientPublicKey)
       .build()
}
{code}

Encrypt the messages if required...

 - For sending the validation code 
 - For pushing Jmat state change events to the push gateway
 
h3. JMAP webpush integration test

Write the following scenario (1 test)

 - [ ] Correct behaviour

{code:scala}
 -> Set up a push gateway on URL http:127.0.0.1:x/push

WHEN bob creates a push subscription to http:127.0.0.1:x/push
THEN a validation code is sent to http:127.0.0.1:x/push
GIVEN bob retrieves the validation code from the mock server
WHEN bob updates the validation code via JMAP
THEN  it suceed
WHEN bob receives a mail
THEN bob has a stateChange on the push gateway
{code}

 - [ ] Expired subscription

{code:scala}
 -> Set up a push gateway on URL http:127.0.0.1:x/push

WHEN bob creates a push subscription to http:127.0.0.1:x/push
THEN a validation code is sent to http:127.0.0.1:x/push
GIVEN bob retrieves the validation code from the mock server
WHEN bob updates the validation code via JMAP
THEN  it suceed
GIVEN 8 days passes
WHEN bob receives a mail
THEN bob has no stateChange on the push gateway
{code}

 - [ ] Deleted subscription

{code:scala}
 -> Set up a push gateway on URL http:127.0.0.1:x/push

WHEN bob creates a push subscription to http:127.0.0.1:x/push
THEN a validation code is sent to http:127.0.0.1:x/push
GIVEN bob retrieves the validation code from the mock server
WHEN bob updates the validation code via JMAP
THEN  it suceed
GIVEN bob deletes the push subscription
WHEN bob receives a mail
THEN bob has no stateChange on the push gateway
{code}

 - [ ] Not validated code

{code:scala}
 -> Set up a push gateway on URL http:127.0.0.1:x/push

WHEN bob creates a push subscription to http:127.0.0.1:x/push
THEN a validation code is sent to http:127.0.0.1:x/push
[no code validation]
GIVEN bob receives a mail
THEN bob has no stateChange on the push gateway
{code}

 - [ ] Encryption keys


{code:scala}
 -> Set up a push gateway on URL http:127.0.0.1:x/push

WHEN bob creates a push subscription to http:127.0.0.1:x/push with encryption 
keys
THEN an encrypted validation code is sent to http:127.0.0.1:x/push
GIVEN bob retrieves the validation code from the mock server
WHEN bob updates the validation code via JMAP
THEN  it suceed
WHEN bob receives a mail
THEN bob has an encrypted stateChange on the push gateway
{code}

h3. JMAP WebPush set Urgency header

Urgency header field defines conditions for when notification should be pushed 
(batterie state, network)

*high* for EmailDelivery

everything else *low*

Decision taken in the PushSubscriptionListener

h3. JMAP webPush set Topic header

Topics enable the Push gateway to reduce the volume of notifications (sending 
only the latest notofocation for each topic).

Topic => hash(types) of the stateChange - be carefull to ensure hash(a, b) == 
hash(b, a) that the hash is not sensible to the order. We do not need a 
cryptographic hash, something as simple as hashCode of the set of display names 
might be enough...

Set by the PushSubscriptionListener

> JMAP PushSubscription (web hooks) is not implemented
> ----------------------------------------------------
>
>                 Key: JAMES-3539
>                 URL: https://issues.apache.org/jira/browse/JAMES-3539
>             Project: James Server
>          Issue Type: Sub-task
>          Components: JMAP
>    Affects Versions: 3.6.0
>            Reporter: Benoit Tellier
>            Assignee: Antoine Duprat
>            Priority: Major
>         Attachments: Screenshot from 2021-10-18 11-40-02.png
>
>          Time Spent: 20m
>  Remaining Estimate: 0h
>
> https://jmap.io/spec-core.html#pushsubscription
> {code:java}
> Clients may create a PushSubscription to register a URL with the JMAP server. 
> The JMAP server will then make an HTTP POST request to this URL for each push 
> notification it wishes to send to the client.
> {code}
> I can be used to group notifications with other apps, for instance in a 
> groupware.
> This is not implemented so far.
> I read the RFCs, but have no opinion so far on the best way to implement 
> this. Support for this is likely not needed but we could imagine accept 
> contributions.



--
This message was sent by Atlassian Jira
(v8.3.4#803005)

---------------------------------------------------------------------
To unsubscribe, e-mail: server-dev-unsubscr...@james.apache.org
For additional commands, e-mail: server-dev-h...@james.apache.org

Reply via email to