From b59a170f8539dfc7d245a023094a2027e3f232f0 Mon Sep 17 00:00:00 2001
From: blavenie <benoit.lavenier@e-is.pro>
Date: Tue, 13 Dec 2016 11:02:57 +0100
Subject: [PATCH] - on index 'event' and 'message' : Add field "read_signature"
 to mark an event as read

---
 .../AbstractRestPostMarkAsReadAction.java     |  82 ++++++++
 .../rest/security/RestSecurityController.java |   4 +-
 .../service/AbstractService.java              | 110 ++++++-----
 .../gchange/service/CommentService.java       | 181 ++++++++++++++++++
 .../gchange/service/MarketService.java        |  65 +------
 .../gchange/service/RegistryService.java      |  17 +-
 .../gchange/service/ServiceModule.java        |   1 +
 .../elasticsearch/user/model/Message.java     | 104 ++++++++++
 .../elasticsearch/user/model/UserEvent.java   |  23 +++
 .../elasticsearch/user/rest/RestModule.java   |   8 +-
 .../message/RestMessageMarkAsReadAction.java  |  44 +++++
 .../user/RestUserEventMarkAsReadAction.java   |  44 +++++
 .../service/BlockchainUserEventService.java   |  34 +++-
 .../user/service/MessageService.java          |  28 +++
 .../user/service/UserEventService.java        |  28 +++
 .../user/service/UserService.java             |  13 ++
 duniter4j-es-user/src/main/misc/curl_test.sh  |  27 ++-
 17 files changed, 664 insertions(+), 149 deletions(-)
 create mode 100644 duniter4j-es-core/src/main/java/org/duniter/elasticsearch/rest/AbstractRestPostMarkAsReadAction.java
 create mode 100644 duniter4j-es-gchange/src/main/java/org/duniter/elasticsearch/gchange/service/CommentService.java
 create mode 100644 duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/model/Message.java
 create mode 100644 duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/rest/message/RestMessageMarkAsReadAction.java
 create mode 100644 duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/rest/user/RestUserEventMarkAsReadAction.java

diff --git a/duniter4j-es-core/src/main/java/org/duniter/elasticsearch/rest/AbstractRestPostMarkAsReadAction.java b/duniter4j-es-core/src/main/java/org/duniter/elasticsearch/rest/AbstractRestPostMarkAsReadAction.java
new file mode 100644
index 00000000..dc782f39
--- /dev/null
+++ b/duniter4j-es-core/src/main/java/org/duniter/elasticsearch/rest/AbstractRestPostMarkAsReadAction.java
@@ -0,0 +1,82 @@
+package org.duniter.elasticsearch.rest;
+
+/*
+ * #%L
+ * duniter4j-elasticsearch-plugin
+ * %%
+ * Copyright (C) 2014 - 2016 EIS
+ * %%
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation, either version 3 of the 
+ * License, or (at your option) any later version.
+ * 
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * 
+ * You should have received a copy of the GNU General Public 
+ * License along with this program.  If not, see
+ * <http://www.gnu.org/licenses/gpl-3.0.html>.
+ * #L%
+ */
+
+import org.duniter.core.exception.BusinessException;
+import org.duniter.elasticsearch.exception.DuniterElasticsearchException;
+import org.duniter.elasticsearch.rest.security.RestSecurityController;
+import org.elasticsearch.client.Client;
+import org.elasticsearch.common.logging.ESLogger;
+import org.elasticsearch.common.logging.ESLoggerFactory;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.rest.*;
+
+import static org.elasticsearch.rest.RestRequest.Method.POST;
+import static org.elasticsearch.rest.RestStatus.OK;
+
+public abstract class AbstractRestPostMarkAsReadAction extends BaseRestHandler {
+
+    private static ESLogger log = null;
+
+    private final JsonReadUpdater updater;
+
+
+    public AbstractRestPostMarkAsReadAction(Settings settings, RestController controller, Client client,
+                                            RestSecurityController securityController,
+                                            String indexName,
+                                            String typeName,
+                                            JsonReadUpdater updater) {
+        super(settings, controller, client);
+        controller.registerHandler(POST,
+                String.format("/%s/%s/{id}/_read", indexName, typeName),
+                this);
+        securityController.allowIndexType(POST, indexName, typeName);
+        log = ESLoggerFactory.getLogger(String.format("[%s]", indexName));
+        this.updater = updater;
+    }
+
+    @Override
+    protected void handleRequest(final RestRequest request, RestChannel restChannel, Client client) throws Exception {
+        String id = request.param("id");
+
+        try {
+            updater.handleSignature(request.content().toUtf8(), id);
+            restChannel.sendResponse(new BytesRestResponse(OK, id));
+        }
+        catch(DuniterElasticsearchException | BusinessException e) {
+            log.error(e.getMessage(), e);
+            restChannel.sendResponse(new XContentThrowableRestResponse(request, e));
+        }
+        catch(Exception e) {
+            log.error(e.getMessage(), e);
+        }
+    }
+
+
+    public interface JsonReadUpdater {
+        void handleSignature(String signature, String id) throws DuniterElasticsearchException, BusinessException;
+    }
+
+
+
+}
\ No newline at end of file
diff --git a/duniter4j-es-core/src/main/java/org/duniter/elasticsearch/rest/security/RestSecurityController.java b/duniter4j-es-core/src/main/java/org/duniter/elasticsearch/rest/security/RestSecurityController.java
index b52fb2a5..69e7ee7e 100644
--- a/duniter4j-es-core/src/main/java/org/duniter/elasticsearch/rest/security/RestSecurityController.java
+++ b/duniter4j-es-core/src/main/java/org/duniter/elasticsearch/rest/security/RestSecurityController.java
@@ -60,7 +60,9 @@ public class RestSecurityController extends AbstractLifecycleComponent<RestSecur
             allowRules = new TreeSet<>();
             allowRulesByMethod.put(method, allowRules);
         }
-        allowRules.add(regexPath);
+        if (!allowRules.contains(regexPath)) {
+            allowRules.add(regexPath);
+        }
         return this;
     }
 
diff --git a/duniter4j-es-core/src/main/java/org/duniter/elasticsearch/service/AbstractService.java b/duniter4j-es-core/src/main/java/org/duniter/elasticsearch/service/AbstractService.java
index a82a7a19..dce12461 100644
--- a/duniter4j-es-core/src/main/java/org/duniter/elasticsearch/service/AbstractService.java
+++ b/duniter4j-es-core/src/main/java/org/duniter/elasticsearch/service/AbstractService.java
@@ -69,10 +69,7 @@ import org.elasticsearch.search.SearchHitField;
 import org.nuiton.i18n.I18n;
 
 import java.io.*;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Set;
+import java.util.*;
 import java.util.regex.Pattern;
 
 /**
@@ -247,6 +244,20 @@ public abstract class AbstractService implements Bean {
         return value;
     }
 
+    /**
+     * Retrieve some field from a document id, and check if all field not null
+     * @param docId
+     * @return
+     */
+    protected Map<String, Object> getMandatoryFieldsById(String index, String type, String docId, String... fieldNames) {
+        Map<String, Object> fields = getFieldsById(index, type, docId, fieldNames);
+        if (MapUtils.isEmpty(fields)) throw new NotFoundException(String.format("Document [%s/%s/%s] not exists.", index, type, docId));
+        Arrays.stream(fieldNames).forEach((fieldName) -> {
+            if (!fields.containsKey(fieldName)) throw new NotFoundException(String.format("Document [%s/%s/%s] should have the madatory field [%s].", index, type, docId, fieldName));
+        });
+        return fields;
+    }
+
     /**
      * Retrieve some field from a document id
      * @param docId
@@ -259,7 +270,7 @@ public abstract class AbstractService implements Bean {
                 .setTypes(type)
                 .setSearchType(SearchType.DFS_QUERY_THEN_FETCH);
 
-        searchRequest.setQuery(QueryBuilders.matchQuery("_id", docId));
+        searchRequest.setQuery(QueryBuilders.idsQuery().ids(docId));
         searchRequest.addFields(fieldNames);
 
         // Execute query
@@ -280,13 +291,54 @@ public abstract class AbstractService implements Bean {
         }
         catch(SearchPhaseExecutionException | JsonSyntaxException e) {
             // Failed or no item on index
-            throw new TechnicalException(String.format("[%s/%s] Unable to retrieve fields [%s] for document [%s]",
+            throw new TechnicalException(String.format("[%s/%s] Unable to retrieve fields [%s] for id [%s]",
                     index, type,
                     Joiner.on(',').join(fieldNames).toString(),
                     docId), e);
         }
     }
 
+    /**
+     * Retrieve some field from a document id
+     * @param index
+     * @param type
+     * @param ids
+     * @param fieldName
+     * @return
+     */
+    protected Map<String, Object> getFieldByIds(String index, String type, Set<String> ids, String fieldName) {
+        // Prepare request
+        SearchRequestBuilder searchRequest = client
+                .prepareSearch(index)
+                .setTypes(type)
+                .setSearchType(SearchType.DFS_QUERY_THEN_FETCH);
+
+        searchRequest.setQuery(QueryBuilders.idsQuery().ids(ids));
+        searchRequest.addFields(fieldName);
+
+        // Execute query
+        try {
+            SearchResponse response = searchRequest.execute().actionGet();
+
+            Map<String, Object> result = new HashMap<>();
+            // Read query result
+            SearchHit[] searchHits = response.getHits().getHits();
+            for (SearchHit searchHit : searchHits) {
+                Map<String, SearchHitField> hitFields = searchHit.getFields();
+                if (hitFields.get(fieldName) != null) {
+                    result.put(searchHit.getId(), hitFields.get(fieldName).getValue());
+                }
+            }
+            return result;
+        }
+        catch(SearchPhaseExecutionException | JsonSyntaxException e) {
+            // Failed or no item on index
+            throw new TechnicalException(String.format("[%s/%s] Unable to retrieve field [%s] for ids [%s]",
+                    index, type, fieldName,
+                    Joiner.on(',').join(ids).toString()), e);
+        }
+    }
+
     /**
      * Retrieve a field from a document id
      * @param docId
@@ -467,52 +519,6 @@ public abstract class AbstractService implements Bean {
         }
     }
 
-    protected XContentBuilder createRecordCommentType(String index, String type) {
-        String stringAnalyzer = pluginSettings.getDefaultStringAnalyzer();
-
-        try {
-            XContentBuilder mapping = XContentFactory.jsonBuilder().startObject().startObject(type)
-                    .startObject("properties")
-
-                    // issuer
-                    .startObject("issuer")
-                    .field("type", "string")
-                    .field("index", "not_analyzed")
-                    .endObject()
-
-                    // time
-                    .startObject("time")
-                    .field("type", "integer")
-                    .endObject()
-
-                    // message
-                    .startObject("message")
-                    .field("type", "string")
-                    .field("analyzer", stringAnalyzer)
-                    .endObject()
-
-                    // record
-                    .startObject("record")
-                    .field("type", "string")
-                    .field("index", "not_analyzed")
-                    .endObject()
-
-                    // reply to
-                    .startObject("reply_to")
-                    .field("type", "string")
-                    .field("index", "not_analyzed")
-                    .endObject()
-
-                    .endObject()
-                    .endObject().endObject();
-
-            return mapping;
-        }
-        catch(IOException ioe) {
-            throw new TechnicalException(String.format("Error while getting mapping for index [%s/%s]: %s", index, type, ioe.getMessage()), ioe);
-        }
-    }
-
     protected void flushDeleteBulk(final String index, final String type, BulkRequestBuilder bulkRequest) {
         if (bulkRequest.numberOfActions() > 0) {
             BulkResponse bulkResponse = bulkRequest.get();
diff --git a/duniter4j-es-gchange/src/main/java/org/duniter/elasticsearch/gchange/service/CommentService.java b/duniter4j-es-gchange/src/main/java/org/duniter/elasticsearch/gchange/service/CommentService.java
new file mode 100644
index 00000000..d5813262
--- /dev/null
+++ b/duniter4j-es-gchange/src/main/java/org/duniter/elasticsearch/gchange/service/CommentService.java
@@ -0,0 +1,181 @@
+package org.duniter.elasticsearch.gchange.service;
+
+/*
+ * #%L
+ * UCoin Java Client :: Core API
+ * %%
+ * Copyright (C) 2014 - 2015 EIS
+ * %%
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation, either version 3 of the 
+ * License, or (at your option) any later version.
+ * 
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * 
+ * You should have received a copy of the GNU General Public 
+ * License along with this program.  If not, see
+ * <http://www.gnu.org/licenses/gpl-3.0.html>.
+ * #L%
+ */
+
+
+import com.fasterxml.jackson.databind.JsonNode;
+import org.apache.commons.collections4.MapUtils;
+import org.duniter.core.client.model.elasticsearch.RecordComment;
+import org.duniter.core.client.service.bma.WotRemoteService;
+import org.duniter.core.exception.TechnicalException;
+import org.duniter.core.service.CryptoService;
+import org.duniter.elasticsearch.exception.DocumentNotFoundException;
+import org.duniter.elasticsearch.gchange.PluginSettings;
+import org.duniter.elasticsearch.gchange.model.MarketRecord;
+import org.duniter.elasticsearch.gchange.model.event.GchangeEventCodes;
+import org.duniter.elasticsearch.service.AbstractService;
+import org.duniter.elasticsearch.user.model.UserEvent;
+import org.duniter.elasticsearch.user.service.UserEventService;
+import org.duniter.elasticsearch.user.service.UserService;
+import org.elasticsearch.client.Client;
+import org.elasticsearch.common.inject.Inject;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.common.xcontent.XContentFactory;
+import org.nuiton.i18n.I18n;
+
+import java.io.IOException;
+import java.util.Map;
+
+/**
+ * Created by Benoit on 30/03/2015.
+ */
+public class CommentService extends AbstractService {
+
+    private UserEventService userEventService;
+    private UserService userService;
+
+    @Inject
+    public CommentService(Client client,
+                          PluginSettings pluginSettings,
+                          CryptoService cryptoService,
+                          UserService userService,
+                          UserEventService userEventService) {
+        super("gchange.comment", client, pluginSettings, cryptoService);
+        this.userEventService = userEventService;
+        this.userService = userService;
+    }
+
+
+    public String indexCommentFromJson(final String index, final String recordType, final String type, final String json) {
+        JsonNode commentObj = readAndVerifyIssuerSignature(json);
+        String issuer = getMandatoryField(commentObj, RecordComment.PROPERTY_ISSUER).asText();
+
+        if (logger.isDebugEnabled()) {
+            logger.debug(String.format("Indexing a %s from issuer [%s]", type, issuer.substring(0, 8)));
+        }
+        String commentId = indexDocumentFromJson(index, type, json);
+
+        // Notify record issuer
+        notifyRecordIssuerForComment(index, recordType, commentObj, true, commentId);
+
+        return commentId;
+    }
+
+    public void updateCommentFromJson(final String index, final String recordType, final String type, final String json, final String id) {
+        JsonNode commentObj = readAndVerifyIssuerSignature(json);
+
+        if (logger.isDebugEnabled()) {
+            String issuer = getMandatoryField(commentObj, RecordComment.PROPERTY_ISSUER).asText();
+            logger.debug(String.format("Indexing a %s from issuer [%s]", type, issuer.substring(0, 8)));
+        }
+
+        updateDocumentFromJson(index, type, id, json);
+
+        // Notify record issuer
+        notifyRecordIssuerForComment(index, recordType, commentObj, false, id);
+
+    }
+
+    public XContentBuilder createRecordCommentType(String index, String type) {
+        String stringAnalyzer = pluginSettings.getDefaultStringAnalyzer();
+
+        try {
+            XContentBuilder mapping = XContentFactory.jsonBuilder().startObject().startObject(type)
+                    .startObject("properties")
+
+                    // issuer
+                    .startObject("issuer")
+                    .field("type", "string")
+                    .field("index", "not_analyzed")
+                    .endObject()
+
+                    // time
+                    .startObject("time")
+                    .field("type", "integer")
+                    .endObject()
+
+                    // message
+                    .startObject("message")
+                    .field("type", "string")
+                    .field("analyzer", stringAnalyzer)
+                    .endObject()
+
+                    // record
+                    .startObject("record")
+                    .field("type", "string")
+                    .field("index", "not_analyzed")
+                    .endObject()
+
+                    // reply to
+                    .startObject("reply_to")
+                    .field("type", "string")
+                    .field("index", "not_analyzed")
+                    .endObject()
+
+                    .endObject()
+                    .endObject().endObject();
+
+            return mapping;
+        }
+        catch(IOException ioe) {
+            throw new TechnicalException(String.format("Error while getting mapping for index [%s/%s]: %s", index, type, ioe.getMessage()), ioe);
+        }
+    }
+
+
+    /* -- Internal methods -- */
+
+    /**
+     * Notify user when new comment
+     */
+    private void notifyRecordIssuerForComment(final String index, final String recordType, JsonNode actualObj, boolean isNewComment, String commentId) {
+        String issuer = getMandatoryField(actualObj, RecordComment.PROPERTY_ISSUER).asText();
+
+        // Notify issuer of record (is not same as comment writer)
+        String recordId = getMandatoryField(actualObj, RecordComment.PROPERTY_RECORD).asText();
+        Map<String, Object> recordFields = getFieldsById(index, recordType, recordId,
+                MarketRecord.PROPERTY_TITLE, MarketRecord.PROPERTY_ISSUER);
+        if (MapUtils.isEmpty(recordFields)) { // record not found
+            throw new DocumentNotFoundException(I18n.t("duniter.market.error.comment.recordNotFound", recordId));
+        }
+        String recordIssuer = recordFields.get(MarketRecord.PROPERTY_ISSUER).toString();
+
+        // Get user title
+        String issuerTitle = userService.getProfileTitle(issuer);
+
+        String recordTitle = recordFields.get(MarketRecord.PROPERTY_TITLE).toString();
+        if (!issuer.equals(recordIssuer)) {
+            userEventService.notifyUser(
+                    UserEvent.newBuilder(UserEvent.EventType.INFO, GchangeEventCodes.NEW_COMMENT.name())
+                    .setMessage(
+                            isNewComment ? I18n.n("duniter.market.event.newComment") : I18n.n("duniter.market.event.updateComment"),
+                            issuerTitle != null ? issuerTitle : issuer.substring(0, 8),
+                            recordTitle)
+                    .setRecipient(recordIssuer)
+                    .setReference(index, recordType, recordId)
+                    .setReferenceAnchor(commentId)
+                    .build());
+        }
+    }
+
+}
diff --git a/duniter4j-es-gchange/src/main/java/org/duniter/elasticsearch/gchange/service/MarketService.java b/duniter4j-es-gchange/src/main/java/org/duniter/elasticsearch/gchange/service/MarketService.java
index 2e2a4a3b..85cff3c9 100644
--- a/duniter4j-es-gchange/src/main/java/org/duniter/elasticsearch/gchange/service/MarketService.java
+++ b/duniter4j-es-gchange/src/main/java/org/duniter/elasticsearch/gchange/service/MarketService.java
@@ -65,18 +65,18 @@ public class MarketService extends AbstractService {
 
     private WotRemoteService wotRemoteService;
     private UserEventService userEventService;
-    private UserService userService;
+    private CommentService commentService;
 
     @Inject
     public MarketService(Client client, PluginSettings settings,
                          CryptoService cryptoService,
+                         CommentService commentService,
                          WotRemoteService wotRemoteService,
-                         UserService userService,
                          UserEventService userEventService) {
         super("gchange." + INDEX, client, settings, cryptoService);
+        this.commentService = commentService;
         this.wotRemoteService = wotRemoteService;
         this.userEventService = userEventService;
-        this.userService = userService;
     }
 
     /**
@@ -88,7 +88,6 @@ public class MarketService extends AbstractService {
         return this;
     }
 
-
     public boolean existsIndex() {
         return super.existsIndex(INDEX);
     }
@@ -128,7 +127,7 @@ public class MarketService extends AbstractService {
         createIndexRequestBuilder.setSettings(indexSettings);
         createIndexRequestBuilder.addMapping(RECORD_CATEGORY_TYPE, createRecordCategoryType());
         createIndexRequestBuilder.addMapping(RECORD_TYPE, createRecordType());
-        createIndexRequestBuilder.addMapping(RECORD_COMMENT_TYPE, createRecordCommentType(INDEX, RECORD_COMMENT_TYPE));
+        createIndexRequestBuilder.addMapping(RECORD_COMMENT_TYPE, commentService.createRecordCommentType(INDEX, RECORD_COMMENT_TYPE));
         createIndexRequestBuilder.execute().actionGet();
 
         return this;
@@ -165,33 +164,11 @@ public class MarketService extends AbstractService {
     }
 
     public String indexCommentFromJson(String json) {
-        JsonNode commentObj = readAndVerifyIssuerSignature(json);
-        String issuer = getMandatoryField(commentObj, RecordComment.PROPERTY_ISSUER).asText();
-
-        if (logger.isDebugEnabled()) {
-            logger.debug(String.format("Indexing a %s from issuer [%s]", RECORD_COMMENT_TYPE, issuer.substring(0, 8)));
-        }
-        String commentId = indexDocumentFromJson(INDEX, RECORD_COMMENT_TYPE, json);
-
-        // Notify record issuer
-        notifyRecordIssuerForComment(commentObj, true);
-
-        return commentId;
+        return commentService.indexCommentFromJson(INDEX, RECORD_TYPE, RECORD_COMMENT_TYPE, json);
     }
 
     public void updateCommentFromJson(String json, String id) {
-        JsonNode commentObj = readAndVerifyIssuerSignature(json);
-
-        if (logger.isDebugEnabled()) {
-            String issuer = getMandatoryField(commentObj, RecordComment.PROPERTY_ISSUER).asText();
-            logger.debug(String.format("Indexing a %s from issuer [%s]", RECORD_COMMENT_TYPE, issuer.substring(0, 8)));
-        }
-
-        updateDocumentFromJson(INDEX, RECORD_COMMENT_TYPE, id, json);
-
-        // Notify record issuer
-        notifyRecordIssuerForComment(commentObj, false);
-
+        commentService.updateCommentFromJson(INDEX, RECORD_TYPE, RECORD_COMMENT_TYPE, json, id);
     }
 
     public MarketService fillRecordCategories() {
@@ -413,34 +390,4 @@ public class MarketService extends AbstractService {
         }
     }
 
-    // Notification
-    private void notifyRecordIssuerForComment(JsonNode actualObj, boolean isNewComment) {
-        String issuer = getMandatoryField(actualObj, RecordComment.PROPERTY_ISSUER).asText();
-
-        // Notify issuer of record (is not same as comment writer)
-        String recordId = getMandatoryField(actualObj, RecordComment.PROPERTY_RECORD).asText();
-        Map<String, Object> recordFields = getFieldsById(INDEX, RECORD_TYPE, recordId,
-                MarketRecord.PROPERTY_TITLE, MarketRecord.PROPERTY_ISSUER);
-        if (MapUtils.isEmpty(recordFields)) { // record not found
-            throw new DocumentNotFoundException(I18n.t("duniter.market.error.comment.recordNotFound", recordId));
-        }
-        String recordIssuer = recordFields.get(MarketRecord.PROPERTY_ISSUER).toString();
-
-        // Get user title
-        String issuerTitle = userService.getProfileTitle(issuer);
-
-        String recordTitle = recordFields.get(MarketRecord.PROPERTY_TITLE).toString();
-        if (!issuer.equals(recordIssuer)) {
-            userEventService.notifyUser(
-                    UserEvent.newBuilder(UserEvent.EventType.INFO, GchangeEventCodes.NEW_COMMENT.name())
-                    .setMessage(
-                            isNewComment ? I18n.n("duniter.market.event.newComment") : I18n.n("duniter.market.event.updateComment"),
-                            issuerTitle != null ? issuerTitle : issuer.substring(0, 8),
-                            recordTitle)
-                    .setRecipient(recordIssuer)
-                    .setReference(INDEX, RECORD_TYPE, recordId)
-                    .build());
-        }
-    }
-
 }
diff --git a/duniter4j-es-gchange/src/main/java/org/duniter/elasticsearch/gchange/service/RegistryService.java b/duniter4j-es-gchange/src/main/java/org/duniter/elasticsearch/gchange/service/RegistryService.java
index 8792b19d..3a1e8a3b 100644
--- a/duniter4j-es-gchange/src/main/java/org/duniter/elasticsearch/gchange/service/RegistryService.java
+++ b/duniter4j-es-gchange/src/main/java/org/duniter/elasticsearch/gchange/service/RegistryService.java
@@ -24,8 +24,6 @@ package org.duniter.elasticsearch.gchange.service;
 
 
 import com.fasterxml.jackson.core.JsonProcessingException;
-import com.google.gson.Gson;
-import org.duniter.core.client.model.bma.gson.GsonUtils;
 import org.duniter.core.client.service.bma.BlockchainRemoteService;
 import org.duniter.core.exception.TechnicalException;
 import org.duniter.core.service.CryptoService;
@@ -53,19 +51,17 @@ public class RegistryService extends AbstractService {
     public static final String RECORD_COMMENT_TYPE = "comment";
     private static final String CATEGORIES_BULK_CLASSPATH_FILE = "registry-categories-bulk-insert.json";
 
-    private final Gson gson;
-    private BlockchainRemoteService blockchainRemoteService;
+    private CommentService commentService;
     private UserEventService userEventService;
 
     @Inject
     public RegistryService(Client client,
                            PluginSettings settings,
                            CryptoService cryptoService,
-                           BlockchainRemoteService blockchainRemoteService,
+                           CommentService commentService,
                             UserEventService userEventService) {
         super("gchange." + INDEX, client, settings, cryptoService);
-        this.gson = GsonUtils.newBuilder().create();
-        this.blockchainRemoteService = blockchainRemoteService;
+        this.commentService = commentService;
         this.userEventService = userEventService;
     }
 
@@ -102,7 +98,7 @@ public class RegistryService extends AbstractService {
         createIndexRequestBuilder.setSettings(indexSettings);
         createIndexRequestBuilder.addMapping(RECORD_CATEGORY_TYPE, createRecordCategoryType());
         createIndexRequestBuilder.addMapping(RECORD_TYPE, createRecordType());
-        createIndexRequestBuilder.addMapping(RECORD_COMMENT_TYPE, createRecordCommentType(INDEX, RECORD_COMMENT_TYPE));
+        createIndexRequestBuilder.addMapping(RECORD_COMMENT_TYPE, commentService.createRecordCommentType(INDEX, RECORD_COMMENT_TYPE));
         createIndexRequestBuilder.execute().actionGet();
 
         return this;
@@ -139,12 +135,11 @@ public class RegistryService extends AbstractService {
     }
 
     public String indexCommentFromJson(String json) {
-        return checkIssuerAndIndexDocumentFromJson(INDEX, RECORD_COMMENT_TYPE, json);
-
+        return commentService.indexCommentFromJson(INDEX, RECORD_TYPE, RECORD_COMMENT_TYPE, json);
     }
 
     public void updateCommentFromJson(String json, String id) {
-        checkIssuerAndUpdateDocumentFromJson(INDEX, RECORD_COMMENT_TYPE, json, id);
+        commentService.updateCommentFromJson(INDEX, RECORD_TYPE, RECORD_COMMENT_TYPE, json, id);
     }
 
     /* -- Internal methods -- */
diff --git a/duniter4j-es-gchange/src/main/java/org/duniter/elasticsearch/gchange/service/ServiceModule.java b/duniter4j-es-gchange/src/main/java/org/duniter/elasticsearch/gchange/service/ServiceModule.java
index 282c53be..72ca1faf 100644
--- a/duniter4j-es-gchange/src/main/java/org/duniter/elasticsearch/gchange/service/ServiceModule.java
+++ b/duniter4j-es-gchange/src/main/java/org/duniter/elasticsearch/gchange/service/ServiceModule.java
@@ -30,6 +30,7 @@ public class ServiceModule extends AbstractModule implements Module {
     @Override protected void configure() {
         bind(RegistryService.class).asEagerSingleton();
         bind(CitiesRegistryService.class).asEagerSingleton();
+        bind(CommentService.class).asEagerSingleton();
         bind(MarketService.class).asEagerSingleton();
         bind(SynchroService.class).asEagerSingleton();
     }
diff --git a/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/model/Message.java b/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/model/Message.java
new file mode 100644
index 00000000..8fc76320
--- /dev/null
+++ b/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/model/Message.java
@@ -0,0 +1,104 @@
+package org.duniter.elasticsearch.user.model;
+
+/*
+ * #%L
+ * Duniter4j :: Core Client API
+ * %%
+ * Copyright (C) 2014 - 2016 EIS
+ * %%
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation, either version 3 of the 
+ * License, or (at your option) any later version.
+ * 
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * 
+ * You should have received a copy of the GNU General Public 
+ * License along with this program.  If not, see
+ * <http://www.gnu.org/licenses/gpl-3.0.html>.
+ * #L%
+ */
+
+import com.fasterxml.jackson.annotation.JsonGetter;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonSetter;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.base.Preconditions;
+import org.duniter.core.client.model.elasticsearch.Record;
+import org.duniter.core.exception.TechnicalException;
+import org.nuiton.i18n.I18n;
+
+import java.util.Locale;
+
+/**
+ * Created by blavenie on 29/11/16.
+ */
+public class Message extends Record {
+
+
+    public static final String PROPERTY_NONCE="nonce";
+    public static final String PROPERTY_CONTENT="content";
+    public static final String PROPERTY_RECIPIENT="recipient";
+    public static final String PROPERTY_READ_SIGNATURE="readSignature";
+
+    private String nonce;
+
+    private String recipient;
+
+    private String content;
+
+    private String readSignature;
+
+    public Message() {
+        super();
+    }
+
+    public String getContent() {
+        return content;
+    }
+    public void setContent(String content) {
+        this.content = content;
+    }
+
+    public String getRecipient() {
+        return recipient;
+    }
+
+    public void setRecipient(String recipient) {
+        this.recipient = recipient;
+    }
+
+    @JsonGetter("read_signature")
+    public String getReadSignature() {
+        return readSignature;
+    }
+
+    @JsonSetter("read_signature")
+    public void setReadSignature(String readSignature) {
+        this.readSignature = readSignature;
+    }
+
+    public String getNonce() {
+        return nonce;
+    }
+
+    public void setNonce(String nonce) {
+        this.nonce = nonce;
+    }
+
+    @JsonIgnore
+    public String toJson() {
+        try {
+            ObjectMapper mapper = new ObjectMapper();
+            mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
+            return mapper.writeValueAsString(this);
+        } catch(Exception e) {
+            throw new TechnicalException(e);
+        }
+    }
+
+}
diff --git a/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/model/UserEvent.java b/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/model/UserEvent.java
index f69e6fdd..66c4e764 100644
--- a/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/model/UserEvent.java
+++ b/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/model/UserEvent.java
@@ -22,8 +22,10 @@ package org.duniter.elasticsearch.user.model;
  * #L%
  */
 
+import com.fasterxml.jackson.annotation.JsonGetter;
 import com.fasterxml.jackson.annotation.JsonIgnore;
 import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonSetter;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.google.common.base.Preconditions;
 import org.duniter.core.client.model.elasticsearch.Record;
@@ -62,6 +64,8 @@ public class UserEvent extends Record {
     public static final String PROPERTY_REFERENCE="reference";
     public static final String PROPERTY_RECIPIENT="recipient";
 
+    public static final String PROPERTY_READ_SIGNATURE="readSignature";
+
 
     private EventType type;
 
@@ -75,6 +79,8 @@ public class UserEvent extends Record {
 
     private Reference reference;
 
+    private String readSignature;
+
     public UserEvent() {
         super();
     }
@@ -96,6 +102,7 @@ public class UserEvent extends Record {
         this.reference = (another.getReference() != null) ? new Reference(another.getReference()) : null;
         this.message = another.getMessage();
         this.recipient = another.getRecipient();
+        this.readSignature = another.getReadSignature();
     }
 
     public EventType getType() {
@@ -150,6 +157,16 @@ public class UserEvent extends Record {
         this.recipient = recipient;
     }
 
+    @JsonGetter("read_signature")
+    public String getReadSignature() {
+        return readSignature;
+    }
+
+    @JsonSetter("read_signature")
+    public void setReadSignature(String readSignature) {
+        this.readSignature = readSignature;
+    }
+
     @JsonIgnore
     public String toJson() {
         try {
@@ -216,6 +233,12 @@ public class UserEvent extends Record {
             return this;
         }
 
+        public Builder setReferenceAnchor(String anchor) {
+            Preconditions.checkNotNull(result.getReference(), "No reference set. Please call setReference() first");
+            result.getReference().setAnchor(anchor);
+            return this;
+        }
+
         public Builder setTime(long time) {
             result.setTime(time);
             return this;
diff --git a/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/rest/RestModule.java b/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/rest/RestModule.java
index bb044f17..a1945c0f 100644
--- a/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/rest/RestModule.java
+++ b/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/rest/RestModule.java
@@ -24,11 +24,9 @@ package org.duniter.elasticsearch.user.rest;
 
 import org.duniter.elasticsearch.user.rest.history.RestHistoryDeleteIndexAction;
 import org.duniter.elasticsearch.user.rest.message.RestMessageInboxIndexAction;
+import org.duniter.elasticsearch.user.rest.message.RestMessageMarkAsReadAction;
 import org.duniter.elasticsearch.user.rest.message.RestMessageOutboxIndexAction;
-import org.duniter.elasticsearch.user.rest.user.RestUserProfileIndexAction;
-import org.duniter.elasticsearch.user.rest.user.RestUserProfileUpdateAction;
-import org.duniter.elasticsearch.user.rest.user.RestUserSettingsIndexAction;
-import org.duniter.elasticsearch.user.rest.user.RestUserSettingsUpdateAction;
+import org.duniter.elasticsearch.user.rest.user.*;
 import org.elasticsearch.common.inject.AbstractModule;
 import org.elasticsearch.common.inject.Module;
 
@@ -41,6 +39,7 @@ public class RestModule extends AbstractModule implements Module {
         bind(RestUserProfileUpdateAction.class).asEagerSingleton();
         bind(RestUserSettingsIndexAction.class).asEagerSingleton();
         bind(RestUserSettingsUpdateAction.class).asEagerSingleton();
+        bind(RestUserEventMarkAsReadAction.class).asEagerSingleton();
 
         // History
         bind(RestHistoryDeleteIndexAction.class).asEagerSingleton();
@@ -48,5 +47,6 @@ public class RestModule extends AbstractModule implements Module {
         // Message
         bind(RestMessageInboxIndexAction.class).asEagerSingleton();
         bind(RestMessageOutboxIndexAction.class).asEagerSingleton();
+        bind(RestMessageMarkAsReadAction.class).asEagerSingleton();
     }
 }
\ No newline at end of file
diff --git a/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/rest/message/RestMessageMarkAsReadAction.java b/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/rest/message/RestMessageMarkAsReadAction.java
new file mode 100644
index 00000000..9f38f96f
--- /dev/null
+++ b/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/rest/message/RestMessageMarkAsReadAction.java
@@ -0,0 +1,44 @@
+package org.duniter.elasticsearch.user.rest.message;
+
+/*
+ * #%L
+ * duniter4j-elasticsearch-plugin
+ * %%
+ * Copyright (C) 2014 - 2016 EIS
+ * %%
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation, either version 3 of the 
+ * License, or (at your option) any later version.
+ * 
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * 
+ * You should have received a copy of the GNU General Public 
+ * License along with this program.  If not, see
+ * <http://www.gnu.org/licenses/gpl-3.0.html>.
+ * #L%
+ */
+
+import org.duniter.elasticsearch.rest.AbstractRestPostMarkAsReadAction;
+import org.duniter.elasticsearch.rest.security.RestSecurityController;
+import org.duniter.elasticsearch.user.service.MessageService;
+import org.elasticsearch.client.Client;
+import org.elasticsearch.common.inject.Inject;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.rest.RestController;
+
+public class RestMessageMarkAsReadAction extends AbstractRestPostMarkAsReadAction {
+
+    @Inject
+    public RestMessageMarkAsReadAction(Settings settings, RestController controller, Client client,
+                                       RestSecurityController securityController,
+                                       MessageService messageService) {
+        super(settings, controller, client, securityController, MessageService.INDEX, MessageService.RECORD_TYPE,
+                (signature, id) -> {
+                    messageService.markMessageAsRead(signature, id);
+                });
+    }
+}
\ No newline at end of file
diff --git a/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/rest/user/RestUserEventMarkAsReadAction.java b/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/rest/user/RestUserEventMarkAsReadAction.java
new file mode 100644
index 00000000..643fa085
--- /dev/null
+++ b/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/rest/user/RestUserEventMarkAsReadAction.java
@@ -0,0 +1,44 @@
+package org.duniter.elasticsearch.user.rest.user;
+
+/*
+ * #%L
+ * duniter4j-elasticsearch-plugin
+ * %%
+ * Copyright (C) 2014 - 2016 EIS
+ * %%
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation, either version 3 of the 
+ * License, or (at your option) any later version.
+ * 
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * 
+ * You should have received a copy of the GNU General Public 
+ * License along with this program.  If not, see
+ * <http://www.gnu.org/licenses/gpl-3.0.html>.
+ * #L%
+ */
+
+import org.duniter.elasticsearch.rest.AbstractRestPostMarkAsReadAction;
+import org.duniter.elasticsearch.rest.security.RestSecurityController;
+import org.duniter.elasticsearch.user.service.UserEventService;
+import org.elasticsearch.client.Client;
+import org.elasticsearch.common.inject.Inject;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.rest.RestController;
+
+public class RestUserEventMarkAsReadAction extends AbstractRestPostMarkAsReadAction {
+
+    @Inject
+    public RestUserEventMarkAsReadAction(Settings settings, RestController controller, Client client,
+                                         RestSecurityController securityController,
+                                         UserEventService userEventService) {
+        super(settings, controller, client, securityController, UserEventService.INDEX, UserEventService.EVENT_TYPE,
+                (signature, id) -> {
+                    userEventService.markEventAsRead(signature, id);
+                });
+    }
+}
\ No newline at end of file
diff --git a/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/service/BlockchainUserEventService.java b/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/service/BlockchainUserEventService.java
index 96c1078c..30a9d2ba 100644
--- a/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/service/BlockchainUserEventService.java
+++ b/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/service/BlockchainUserEventService.java
@@ -24,8 +24,10 @@ package org.duniter.elasticsearch.user.service;
 
 
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import org.apache.commons.collections4.MapUtils;
 import org.duniter.core.client.model.ModelUtils;
 import org.duniter.core.client.model.bma.BlockchainBlock;
 import org.duniter.core.client.model.bma.jackson.JacksonUtils;
@@ -55,6 +57,8 @@ public class BlockchainUserEventService extends AbstractService implements Chang
 
     public static final String DEFAULT_PUBKEYS_SEPARATOR = ", ";
 
+    public final UserService userService;
+
     public final UserEventService userEventService;
 
     public final ObjectMapper objectMapper;
@@ -66,8 +70,10 @@ public class BlockchainUserEventService extends AbstractService implements Chang
     @Inject
     public BlockchainUserEventService(Client client, PluginSettings settings, CryptoService cryptoService,
                                       BlockchainService blockchainService,
+                                      UserService userService,
                                       UserEventService userEventService) {
         super("duniter.user.event.blockchain", client, settings, cryptoService);
+        this.userService = userService;
         this.userEventService = userEventService;
         this.objectMapper = JacksonUtils.newObjectMapper();
         this.changeListenSources = ImmutableList.of(new ChangeSource("*", BlockchainService.BLOCK_TYPE));
@@ -197,10 +203,8 @@ public class BlockchainUserEventService extends AbstractService implements Chang
     private void processTx(BlockchainBlock block, BlockchainBlock.Transaction tx) {
         Set<String> senders = ImmutableSet.copyOf(tx.getIssuers());
 
-
         // Received
-        // TODO get profile name
-        String sendersString = ModelUtils.joinPubkeys(senders, true, DEFAULT_PUBKEYS_SEPARATOR);
+        String sendersString = joinPubkeys(senders, true);
         Set<String> receivers = new HashSet<>();
         for (String output : tx.getOutputs()) {
             String[] parts = output.split(":");
@@ -215,8 +219,7 @@ public class BlockchainUserEventService extends AbstractService implements Chang
 
         // Sent
         if (CollectionUtils.isNotEmpty(receivers)) {
-            // TODO get profile name
-            String receiverStr = ModelUtils.joinPubkeys(receivers, true, DEFAULT_PUBKEYS_SEPARATOR);
+            String receiverStr = joinPubkeys(receivers, true);
             for (String sender : senders) {
                 notifyUserEvent(block, sender, UserEventCodes.TX_SENT, I18n.n("duniter.user.event.tx.sent"), receiverStr);
             }
@@ -242,8 +245,27 @@ public class BlockchainUserEventService extends AbstractService implements Chang
 
         // Delete events that reference this block
         userEventService.deleteEventsByReference(new UserEvent.Reference(change.getIndex(), change.getType(), change.getId()));
+    }
 
+    private String joinPubkeys(Set<String> pubkeys, boolean minify) {
+        Preconditions.checkNotNull(pubkeys);
+        Preconditions.checkArgument(pubkeys.size()>0);
+        if (pubkeys.size() == 1) {
+            String pubkey = pubkeys.iterator().next();
+            String title = userService.getProfileTitle(pubkey);
+            return title != null ? title :
+                    (minify ? ModelUtils.minifyPubkey(pubkey) : pubkey);
+        }
 
-    }
+        Map<String, String> profileTitles = userService.getProfileTitles(pubkeys);
+        StringBuilder sb = new StringBuilder();
+        pubkeys.stream().forEach((pubkey)-> {
+            String title = profileTitles != null ? profileTitles.get(pubkey) : null;
+            sb.append(DEFAULT_PUBKEYS_SEPARATOR);
+            sb.append(title != null ? title :
+                    (minify ? ModelUtils.minifyPubkey(pubkey) : pubkey));
+        });
 
+        return sb.substring(DEFAULT_PUBKEYS_SEPARATOR.length());
+    }
 }
diff --git a/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/service/MessageService.java b/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/service/MessageService.java
index 3f501b75..78053edf 100644
--- a/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/service/MessageService.java
+++ b/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/service/MessageService.java
@@ -25,12 +25,17 @@ package org.duniter.elasticsearch.user.service;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.JsonNode;
+import org.duniter.core.client.model.elasticsearch.Record;
 import org.duniter.core.exception.TechnicalException;
 import org.duniter.core.service.CryptoService;
 import org.duniter.elasticsearch.PluginSettings;
+import org.duniter.elasticsearch.exception.InvalidSignatureException;
 import org.duniter.elasticsearch.service.AbstractService;
+import org.duniter.elasticsearch.user.model.Message;
+import org.duniter.elasticsearch.user.model.UserEvent;
 import org.elasticsearch.action.admin.indices.create.CreateIndexRequestBuilder;
 import org.elasticsearch.action.index.IndexResponse;
+import org.elasticsearch.action.update.UpdateRequestBuilder;
 import org.elasticsearch.client.Client;
 import org.elasticsearch.common.inject.Inject;
 import org.elasticsearch.common.settings.Settings;
@@ -38,6 +43,7 @@ import org.elasticsearch.common.xcontent.XContentBuilder;
 import org.elasticsearch.common.xcontent.XContentFactory;
 
 import java.io.IOException;
+import java.util.Map;
 
 /**
  * Created by Benoit on 30/03/2015.
@@ -138,6 +144,22 @@ public class MessageService extends AbstractService {
         return response.getId();
     }
 
+    public void markMessageAsRead(String signature, String id) {
+        Map<String, Object> fields = getMandatoryFieldsById(INDEX, RECORD_TYPE, id, Message.PROPERTY_HASH, Message.PROPERTY_RECIPIENT);
+        String recipient = fields.get(UserEvent.PROPERTY_RECIPIENT).toString();
+        String hash = fields.get(UserEvent.PROPERTY_HASH).toString();
+
+        // Check signature
+        boolean valid = cryptoService.verify(hash, signature, recipient);
+        if (!valid) {
+            throw new InvalidSignatureException("Invalid signature: only the recipient can mark an message as read.");
+        }
+
+        UpdateRequestBuilder request = client.prepareUpdate(INDEX, RECORD_TYPE, id)
+                .setDoc("read_signature", signature);
+        request.execute();
+    }
+
     /* -- Internal methods -- */
 
     public XContentBuilder createRecordType() {
@@ -182,6 +204,12 @@ public class MessageService extends AbstractService {
                     .field("index", "not_analyzed")
                     .endObject()
 
+                    // read_signature
+                    .startObject("read_signature")
+                    .field("type", "string")
+                    .field("index", "not_analyzed")
+                    .endObject()
+
                     .endObject()
                     .endObject().endObject();
 
diff --git a/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/service/UserEventService.java b/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/service/UserEventService.java
index 8104eaf7..d43d4893 100644
--- a/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/service/UserEventService.java
+++ b/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/service/UserEventService.java
@@ -27,6 +27,7 @@ import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.google.common.base.Preconditions;
 import com.google.gson.JsonSyntaxException;
+import org.apache.commons.collections4.MapUtils;
 import org.duniter.core.exception.TechnicalException;
 import org.duniter.core.service.CryptoService;
 import org.duniter.core.service.MailService;
@@ -35,6 +36,9 @@ import org.duniter.core.util.crypto.CryptoUtils;
 import org.duniter.core.util.crypto.KeyPair;
 import org.duniter.core.util.websocket.WebsocketClientEndpoint;
 import org.duniter.elasticsearch.PluginSettings;
+import org.duniter.elasticsearch.exception.InvalidFormatException;
+import org.duniter.elasticsearch.exception.InvalidSignatureException;
+import org.duniter.elasticsearch.exception.NotFoundException;
 import org.duniter.elasticsearch.service.AbstractService;
 import org.duniter.elasticsearch.service.BlockchainService;
 import org.duniter.elasticsearch.threadpool.ThreadPool;
@@ -47,6 +51,7 @@ import org.elasticsearch.action.search.SearchPhaseExecutionException;
 import org.elasticsearch.action.search.SearchRequestBuilder;
 import org.elasticsearch.action.search.SearchResponse;
 import org.elasticsearch.action.search.SearchType;
+import org.elasticsearch.action.update.UpdateRequestBuilder;
 import org.elasticsearch.client.Client;
 import org.elasticsearch.common.inject.Inject;
 import org.elasticsearch.common.unit.TimeValue;
@@ -201,6 +206,23 @@ public class UserEventService extends AbstractService {
         });
     }
 
+    public void markEventAsRead(String signature, String id) {
+
+        Map<String, Object> fields = getMandatoryFieldsById(INDEX, EVENT_TYPE, id, UserEvent.PROPERTY_HASH, UserEvent.PROPERTY_RECIPIENT);
+        String recipient = fields.get(UserEvent.PROPERTY_RECIPIENT).toString();
+        String hash = fields.get(UserEvent.PROPERTY_HASH).toString();
+
+        // Check signature
+        boolean valid = cryptoService.verify(hash, signature, recipient);
+        if (!valid) {
+            throw new InvalidSignatureException("Invalid signature: only the recipient can mark an event as read.");
+        }
+
+        UpdateRequestBuilder request = client.prepareUpdate(INDEX, EVENT_TYPE, id)
+                .setDoc("read_signature", signature);
+        request.execute();
+    }
+
     /* -- Internal methods -- */
 
     public static XContentBuilder createEventType() {
@@ -287,6 +309,12 @@ public class UserEventService extends AbstractService {
                     .field("index", "not_analyzed")
                     .endObject()
 
+                    // read_signature
+                    .startObject("read_signature")
+                    .field("type", "string")
+                    .field("index", "not_analyzed")
+                    .endObject()
+
                     .endObject()
                     .endObject().endObject();
 
diff --git a/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/service/UserService.java b/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/service/UserService.java
index 5333f4e4..003d7089 100644
--- a/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/service/UserService.java
+++ b/duniter4j-es-user/src/main/java/org/duniter/elasticsearch/user/service/UserService.java
@@ -25,6 +25,7 @@ package org.duniter.elasticsearch.user.service;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.JsonNode;
+import org.apache.commons.collections4.MapUtils;
 import org.duniter.core.client.model.elasticsearch.UserProfile;
 import org.duniter.core.exception.TechnicalException;
 import org.duniter.core.service.CryptoService;
@@ -41,7 +42,10 @@ import org.elasticsearch.common.xcontent.XContentBuilder;
 import org.elasticsearch.common.xcontent.XContentFactory;
 
 import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
 import java.util.Objects;
+import java.util.Set;
 
 /**
  * Created by Benoit on 30/03/2015.
@@ -201,6 +205,15 @@ public class UserService extends AbstractService {
         return title.toString();
     }
 
+    public Map<String, String> getProfileTitles(Set<String> issuers) {
+
+        Map<String, Object> titles = getFieldByIds(INDEX, PROFILE_TYPE, issuers, UserProfile.PROPERTY_TITLE);
+        if (MapUtils.isEmpty(titles)) return null;
+        Map<String, String> result = new HashMap<>();
+        titles.entrySet().stream().forEach((entry) -> result.put(entry.getKey(), entry.getValue().toString()));
+        return result;
+    }
+
     /* -- Internal methods -- */
 
 
diff --git a/duniter4j-es-user/src/main/misc/curl_test.sh b/duniter4j-es-user/src/main/misc/curl_test.sh
index 01b7056e..b7acc655 100755
--- a/duniter4j-es-user/src/main/misc/curl_test.sh
+++ b/duniter4j-es-user/src/main/misc/curl_test.sh
@@ -1,25 +1,20 @@
 #!/bin/sh
 
-curl -XPOST "http://127.0.0.1:9200/user/event/_search?pretty" -d'
+curl -XPOST "http://127.0.0.1:9200/user/event/_count?pretty" -d'
 {
   query: {
-    nested: {
-        path: "reference",
-        query: {
-            constant_score: {
-              filter:
-                  [
-                      {term: { "reference.index": "test_net"}},
-                      {term: { "reference.type": "block"}},
-                      {term: { "reference.id": "10862"}}
-                  ]
-
-          }
-        }
+    bool: {
+        filter: [
+          {term: {recipient: "5ocqzyDMMWf1V8bsoNhWb1iNwax1e9M7VTUN6navs8of"}}
+        ],
+        must_not: {terms: { "code": ["TX_SENT"]}}
     }
-
   },
+  sort : [
+    { "time" : {"order" : "desc"}}
+  ],
   from: 0,
-  size: 100
+  size: 100,
+  _source: false
 }'
 
-- 
GitLab