emailOpt) {
+ public static final Logger LOGGER = LoggerFactory.getLogger(JCardObject.class);
+
+ public JCardObject {
+ Preconditions.checkNotNull(fnOpt);
+ Preconditions.checkNotNull(emailOpt);
+ }
+
+ /**
+ * Purpose: To specify the formatted text corresponding to the name of
+ * the object the vCard represents.
+ *
+ * Example: Mr. John Q. Public\, Esq.
+ */
+ @Override
+ public Optional fnOpt() {
+ return fnOpt;
+ }
+
+ /**
+ * Purpose: To specify the electronic mail address for communication
+ * with the object the vCard represents.
+ *
+ * Example: jane_doe@example.com
+ */
+ @Override
+ public Optional emailOpt() {
+ return emailOpt;
+ }
+
+ public Optional asContactFields() {
+ Optional contactFullnameOpt = fnOpt();
+ Optional contactMailAddressOpt = emailOpt();
+
+ if (contactMailAddressOpt.isEmpty()) {
+ return Optional.empty();
+ }
+
+ MailAddress contactMailAddress = contactMailAddressOpt.get();
+ String contactFullname = contactFullnameOpt.orElse("");
+
+ return Optional.of(new ContactFields(contactMailAddress, contactFullname, ""));
+ }
+}
diff --git a/tmail-backend/tmail-third-party/openpaas/src/main/java/com/linagora/tmail/contact/JCardObjectDeserializer.java b/tmail-backend/tmail-third-party/openpaas/src/main/java/com/linagora/tmail/contact/JCardObjectDeserializer.java
new file mode 100644
index 0000000000..b0983566c8
--- /dev/null
+++ b/tmail-backend/tmail-third-party/openpaas/src/main/java/com/linagora/tmail/contact/JCardObjectDeserializer.java
@@ -0,0 +1,93 @@
+package com.linagora.tmail.contact;
+
+import java.io.IOException;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import jakarta.mail.internet.AddressException;
+
+import org.apache.commons.lang3.tuple.ImmutablePair;
+import org.apache.commons.lang3.tuple.Pair;
+import org.apache.james.core.MailAddress;
+import org.apache.james.util.streams.Iterators;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
+
+
+public class JCardObjectDeserializer extends StdDeserializer {
+ private static final Logger LOGGER = LoggerFactory.getLogger(JCardObjectDeserializer.class);
+
+ private static final String FN = "fn";
+ private static final String EMAIL = "email";
+ private static final Set SUPPORTED_PROPERTY_NAMES = Set.of(FN, EMAIL);
+ private static final int PROPERTY_NAME_INDEX = 0;
+ private static final int PROPERTIES_ARRAY_INDEX = 1;
+ private static final int TEXT_PROPERTY_VALUE_INDEX = 3;
+
+ public JCardObjectDeserializer() {
+ this(null);
+ }
+
+ protected JCardObjectDeserializer(Class> vc) {
+ super(vc);
+ }
+
+ @Override
+ public JCardObject deserialize(JsonParser p, DeserializationContext ctxt)
+ throws IOException {
+ JsonNode node = p.getCodec().readTree(p);
+
+ JsonNode jCardPropertiesArray = node.get(PROPERTIES_ARRAY_INDEX);
+ Map jCardProperties =
+ collectJCardProperties(jCardPropertiesArray.iterator());
+
+ if (!jCardProperties.containsKey(FN)) {
+ String json = node.toString();
+ LOGGER.warn("""
+ Missing 'fn' property in the provided JCard object. 'fn' is required according to the specifications.
+ Received data: {}.
+ Ensure the 'fn' property is present and correctly formatted.""", json);
+ }
+
+ Optional maybeMailAddress = getOptionalFromMap(jCardProperties, EMAIL)
+ .flatMap(email -> {
+ try {
+ return Optional.of(new MailAddress(email));
+ } catch (AddressException e) {
+ LOGGER.error("Invalid contact mail address '{}' found in JCard Object", email, e);
+ return Optional.empty();
+ }
+ });
+
+ return new JCardObject(getOptionalFromMap(jCardProperties, FN), maybeMailAddress);
+ }
+
+ private static Map collectJCardProperties(Iterator propertiesIterator) {
+ return Iterators.toStream(propertiesIterator)
+ .map(JCardObjectDeserializer::getPropertyKeyValuePair)
+ .flatMap(Optional::stream)
+ .collect(Collectors.toMap(Pair::getKey, Pair::getValue));
+ }
+
+ private static Optional> getPropertyKeyValuePair(JsonNode propertyNode) {
+ String propertyName = propertyNode.get(PROPERTY_NAME_INDEX).asText();
+ if (SUPPORTED_PROPERTY_NAMES.contains(propertyName)) {
+ String propertyValue = propertyNode.get(TEXT_PROPERTY_VALUE_INDEX).asText();
+ return Optional.of(ImmutablePair.of(propertyName, propertyValue));
+ } else {
+ return Optional.empty();
+ }
+ }
+
+ private Optional getOptionalFromMap(Map map, String key) {
+ return Optional.ofNullable(map.getOrDefault(key, null));
+ }
+}
diff --git a/tmail-backend/tmail-third-party/openpaas/src/main/java/com/linagora/tmail/contact/OpenPaasContactsConsumer.java b/tmail-backend/tmail-third-party/openpaas/src/main/java/com/linagora/tmail/contact/OpenPaasContactsConsumer.java
new file mode 100644
index 0000000000..b569979703
--- /dev/null
+++ b/tmail-backend/tmail-third-party/openpaas/src/main/java/com/linagora/tmail/contact/OpenPaasContactsConsumer.java
@@ -0,0 +1,151 @@
+package com.linagora.tmail.contact;
+
+import static org.apache.james.backends.rabbitmq.Constants.DURABLE;
+import static org.apache.james.backends.rabbitmq.Constants.EMPTY_ROUTING_KEY;
+
+import java.io.Closeable;
+import java.util.Optional;
+
+import jakarta.inject.Inject;
+
+import org.apache.james.backends.rabbitmq.RabbitMQConfiguration;
+import org.apache.james.backends.rabbitmq.ReactorRabbitMQChannelPool;
+import org.apache.james.backends.rabbitmq.ReceiverProvider;
+import org.apache.james.core.MailAddress;
+import org.apache.james.core.Username;
+import org.apache.james.jmap.api.model.AccountId;
+import org.apache.james.lifecycle.api.Startable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.linagora.tmail.api.OpenPaasRestClient;
+import com.linagora.tmail.james.jmap.contact.ContactFields;
+import com.linagora.tmail.james.jmap.contact.ContactNotFoundException;
+import com.linagora.tmail.james.jmap.contact.EmailAddressContact;
+import com.linagora.tmail.james.jmap.contact.EmailAddressContactSearchEngine;
+import com.rabbitmq.client.BuiltinExchangeType;
+
+import reactor.core.Disposable;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.rabbitmq.AcknowledgableDelivery;
+import reactor.rabbitmq.BindingSpecification;
+import reactor.rabbitmq.ExchangeSpecification;
+import reactor.rabbitmq.QueueSpecification;
+import reactor.rabbitmq.Receiver;
+import reactor.rabbitmq.Sender;
+
+
+public class OpenPaasContactsConsumer implements Startable, Closeable {
+ private static final Logger LOGGER = LoggerFactory.getLogger(OpenPaasContactsConsumer.class);
+ private static final boolean REQUEUE_ON_NACK = true;
+ public static final String EXCHANGE_NAME = "contacts:contact:add";
+ public static final String QUEUE_NAME = "openpaas-contacts-queue";
+ public static final String DEAD_LETTER = QUEUE_NAME + "-dead-letter";
+
+ private final ReceiverProvider receiverProvider;
+ private final Sender sender;
+ private final RabbitMQConfiguration commonRabbitMQConfiguration;
+ private final EmailAddressContactSearchEngine contactSearchEngine;
+ private final ObjectMapper objectMapper = new ObjectMapper();
+ private final OpenPaasRestClient openPaasRestClient;
+ private Disposable consumeContactsDisposable;
+
+ @Inject
+ public OpenPaasContactsConsumer(ReactorRabbitMQChannelPool channelPool,
+ RabbitMQConfiguration commonRabbitMQConfiguration,
+ EmailAddressContactSearchEngine contactSearchEngine,
+ OpenPaasRestClient openPaasRestClient) {
+ this.receiverProvider = channelPool::createReceiver;
+ this.sender = channelPool.getSender();
+ this.commonRabbitMQConfiguration = commonRabbitMQConfiguration;
+ this.contactSearchEngine = contactSearchEngine;
+ this.openPaasRestClient = openPaasRestClient;
+ }
+
+ public void start() {
+ Flux.concat(
+ sender.declareExchange(ExchangeSpecification.exchange(EXCHANGE_NAME)
+ .durable(DURABLE).type(BuiltinExchangeType.FANOUT.getType())),
+ sender.declareQueue(QueueSpecification
+ .queue(DEAD_LETTER)
+ .durable(DURABLE)
+ .arguments(commonRabbitMQConfiguration.workQueueArgumentsBuilder().build())),
+ sender.declareQueue(QueueSpecification
+ .queue(QUEUE_NAME)
+ .durable(DURABLE)
+ .arguments(commonRabbitMQConfiguration.workQueueArgumentsBuilder()
+ .deadLetter(DEAD_LETTER)
+ .build())),
+ sender.bind(BindingSpecification.binding()
+ .exchange(EXCHANGE_NAME)
+ .queue(QUEUE_NAME)
+ .routingKey(EMPTY_ROUTING_KEY)))
+ .then()
+ .block();
+
+ consumeContactsDisposable = doConsumeContactMessages();
+ }
+
+ private Disposable doConsumeContactMessages() {
+ return delivery()
+ .flatMap(delivery -> messageConsume(delivery, delivery.getBody()))
+ .subscribe();
+ }
+
+ public Flux delivery() {
+ return Flux.using(receiverProvider::createReceiver,
+ receiver -> receiver.consumeManualAck(QUEUE_NAME),
+ Receiver::close);
+ }
+
+ private Mono messageConsume(AcknowledgableDelivery ackDelivery, byte[] messagePayload) {
+ return Mono.just(messagePayload)
+ .map(ContactAddedRabbitMqMessage::fromJSON)
+ .flatMap(this::handleMessage)
+ .doOnSuccess(result -> {
+ LOGGER.debug("Consumed contact successfully '{}'", result);
+ ackDelivery.ack();
+ })
+ .onErrorResume(error -> {
+ LOGGER.error("Error when consume message", error);
+ ackDelivery.nack(!REQUEUE_ON_NACK);
+ return Mono.empty();
+ });
+ }
+
+ private Mono handleMessage(ContactAddedRabbitMqMessage contactAddedMessage) {
+ LOGGER.trace("Consumed jCard object message: {}", contactAddedMessage);
+ Optional maybeContactFields = contactAddedMessage.vcard().asContactFields();
+ return maybeContactFields.map(
+ openPaasContact -> openPaasRestClient.retrieveMailAddress(contactAddedMessage.userId())
+ .map(this::getAccountIdFromMailAddress)
+ .flatMap(ownerAccountId -> indexContactIfNeeded(ownerAccountId, openPaasContact)))
+ .orElse(Mono.empty());
+ }
+
+ private Mono indexContactIfNeeded(AccountId ownerAccountId, ContactFields openPaasContact) {
+ return Mono.from(contactSearchEngine.get(ownerAccountId, openPaasContact.address()))
+ .onErrorResume(ContactNotFoundException.class, e -> Mono.empty())
+ .switchIfEmpty(
+ Mono.from(contactSearchEngine.index(ownerAccountId, openPaasContact)))
+ .flatMap(existingContact -> {
+ if (!openPaasContact.firstname().isBlank()) {
+ return Mono.from(contactSearchEngine.index(ownerAccountId, openPaasContact));
+ } else {
+ return Mono.empty();
+ }
+ });
+ }
+
+ private AccountId getAccountIdFromMailAddress(MailAddress mailAddress) {
+ return AccountId.fromUsername(Username.fromMailAddress(mailAddress));
+ }
+
+ @Override
+ public void close() {
+ Optional.ofNullable(consumeContactsDisposable)
+ .ifPresent(Disposable::dispose);
+ }
+}
diff --git a/tmail-backend/tmail-third-party/openpaas/src/test/java/com/linagora/tmail/contact/JCardObjectDeserializerTest.java b/tmail-backend/tmail-third-party/openpaas/src/test/java/com/linagora/tmail/contact/JCardObjectDeserializerTest.java
new file mode 100644
index 0000000000..e1d4f01415
--- /dev/null
+++ b/tmail-backend/tmail-third-party/openpaas/src/test/java/com/linagora/tmail/contact/JCardObjectDeserializerTest.java
@@ -0,0 +1,97 @@
+package com.linagora.tmail.contact;
+
+import java.util.Optional;
+import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
+import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+public class JCardObjectDeserializerTest {
+ static ObjectMapper objectMapper;
+
+ @BeforeAll
+ static void setUp() {
+ objectMapper = new ObjectMapper();
+ }
+
+ @Test
+ void shouldNotThrowWhenMailAddressIsInvalid() {
+ JCardObject cardObject = deserialize("""
+ [
+ "vcard",
+ [
+ [ "fn", {}, "text", "Jhon Doe" ],
+ [ "email", {}, "text", "BAD_MAIL_ADDRESS" ]
+ ]
+ ]
+ """);
+
+ assertThat(cardObject.emailOpt()).isEmpty();
+ assertThat(cardObject.fnOpt()).isEqualTo(Optional.of("Jhon Doe"));
+ }
+
+ @Test
+ void shouldNotThrowWhenJCardContainsUnknownProperties() {
+ JCardObject cardObject = deserialize("""
+ [
+ "vcard",
+ [
+ [ "version", {}, "text", "4.0" ],
+ [ "kind", {}, "text", "individual" ],
+ [ "fn", {}, "text", "Jhon Doe" ],
+ [ "email", {}, "text", "jhon@doe.com" ],
+ [ "org", {}, "text", [ "ABC, Inc.", "North American Division", "Marketing" ] ]
+ ]
+ ]""");
+
+ assertThat(cardObject.emailOpt()).isEqualTo(Optional.of("jhon@doe.com"));
+ assertThat(cardObject.fnOpt()).isEqualTo(Optional.of("Jhon Doe"));
+ }
+
+ @Test
+ void shouldNotThrowWhenEmailPropertyNotPresent() {
+ JCardObject cardObject = deserialize("""
+ [
+ "vcard",
+ [
+ [ "fn", {}, "text", "Jhon Doe" ]
+ ]
+ ]
+ """);
+
+ assertThat(cardObject.emailOpt()).isEmpty();
+ assertThat(cardObject.fnOpt()).isEqualTo(Optional.of("Jhon Doe"));
+ }
+
+ @Test
+ void shouldNotThrowWhenFnPropertyNotPresent() {
+ JCardObject cardObject = deserialize("""
+ [
+ "vcard",
+ [
+ [ "email", {}, "text", "jhon@doe.com" ]
+ ]
+ ]
+ """);
+
+ assertThat(cardObject.emailOpt()).isEqualTo(Optional.of("jhon@doe.com"));
+ assertThat(cardObject.fnOpt()).isEmpty();
+ }
+
+ @Test
+ void shouldThrowOnBadPayload() {
+ assertThatThrownBy(() -> deserialize("BAD_PAYLOAD"));
+ }
+
+ private JCardObject deserialize(String json) {
+ try {
+ return objectMapper.readValue(json, JCardObject.class);
+ } catch (JsonProcessingException e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/tmail-backend/tmail-third-party/openpaas/src/test/java/com/linagora/tmail/contact/JCardObjectTest.java b/tmail-backend/tmail-third-party/openpaas/src/test/java/com/linagora/tmail/contact/JCardObjectTest.java
new file mode 100644
index 0000000000..d2f19c3f20
--- /dev/null
+++ b/tmail-backend/tmail-third-party/openpaas/src/test/java/com/linagora/tmail/contact/JCardObjectTest.java
@@ -0,0 +1,46 @@
+package com.linagora.tmail.contact;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import org.apache.james.core.MailAddress;
+import org.junit.jupiter.api.Test;
+
+import java.util.Optional;
+
+import jakarta.mail.internet.AddressException;
+
+public class JCardObjectTest {
+
+ @Test
+ void asContactFieldsShouldNotThrowIfFullnameNotProvided() {
+ JCardObject cardObject = new JCardObject(Optional.empty(), maybeMailAddress("Jhon@doe.com"));
+ assertThat(cardObject.asContactFields()).isPresent();
+ }
+
+ @Test
+ void asContactFieldsShouldConvertFieldsCorrectly() {
+ JCardObject cardObject = new JCardObject(Optional.of("Jhon Doe"), maybeMailAddress("Jhon@doe.com"));
+ assertThat(cardObject.asContactFields()).isPresent();
+ assertThat(cardObject.asContactFields().get().firstname()).isEqualTo("Jhon Doe");
+ assertThat(cardObject.asContactFields().get().surname()).isEmpty();
+ assertThat(cardObject.asContactFields().get().address()).isEqualTo("Jhon@doe.com");
+ }
+
+ @Test
+ void shouldThrowIfFnOrEmailOptionalIsNull() {
+ assertThatThrownBy(() -> new JCardObject(null, maybeMailAddress("jhon@doe.com")))
+ .isInstanceOf(NullPointerException.class);
+
+ assertThatThrownBy(() -> new JCardObject(Optional.of("Jhon Doe"), null))
+ .isInstanceOf(NullPointerException.class);
+ }
+
+ private Optional maybeMailAddress(String email) {
+ try {
+ return Optional.of(new MailAddress(email));
+ } catch (AddressException e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/tmail-backend/tmail-third-party/openpaas/src/test/java/com/linagora/tmail/contact/OpenPaasContactsConsumerTest.java b/tmail-backend/tmail-third-party/openpaas/src/test/java/com/linagora/tmail/contact/OpenPaasContactsConsumerTest.java
new file mode 100644
index 0000000000..42e5971df6
--- /dev/null
+++ b/tmail-backend/tmail-third-party/openpaas/src/test/java/com/linagora/tmail/contact/OpenPaasContactsConsumerTest.java
@@ -0,0 +1,369 @@
+package com.linagora.tmail.contact;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.apache.james.backends.rabbitmq.Constants.EMPTY_ROUTING_KEY;
+import static org.apache.james.backends.rabbitmq.RabbitMQExtension.IsolationPolicy.WEAK;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+import static org.awaitility.Durations.TEN_SECONDS;
+
+import org.apache.james.backends.rabbitmq.RabbitMQExtension;
+import org.apache.james.core.MailAddress;
+import org.apache.james.core.Username;
+import org.apache.james.jmap.api.model.AccountId;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.IntStream;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.rabbitmq.OutboundMessage;
+
+import jakarta.mail.internet.AddressException;
+
+import com.github.fge.lambdas.Throwing;
+import com.linagora.tmail.OpenPaasConfiguration;
+import com.linagora.tmail.api.OpenPaasRestClient;
+import com.linagora.tmail.api.OpenPaasServerExtension;
+import com.linagora.tmail.james.jmap.contact.ContactFields;
+import com.linagora.tmail.james.jmap.contact.EmailAddressContact;
+import com.linagora.tmail.james.jmap.contact.EmailAddressContactSearchEngine;
+import com.linagora.tmail.james.jmap.contact.InMemoryEmailAddressContactSearchEngine;
+
+class OpenPaasContactsConsumerTest {
+
+ @RegisterExtension
+ static OpenPaasServerExtension openPaasServerExtension = new OpenPaasServerExtension();
+
+ @RegisterExtension
+ static RabbitMQExtension rabbitMQExtension = RabbitMQExtension.singletonRabbitMQ()
+ .isolationPolicy(WEAK);
+
+ private EmailAddressContactSearchEngine searchEngine;
+ private OpenPaasContactsConsumer consumer;
+
+ @BeforeEach
+ void setup() throws URISyntaxException {
+ OpenPaasRestClient restClient = new OpenPaasRestClient(
+ new OpenPaasConfiguration(
+ openPaasServerExtension.getBaseUrl(),
+ OpenPaasServerExtension.GOOD_USER(),
+ OpenPaasServerExtension.GOOD_PASSWORD()));
+ searchEngine = new InMemoryEmailAddressContactSearchEngine();
+ consumer = new OpenPaasContactsConsumer(rabbitMQExtension.getRabbitChannelPool(),
+ rabbitMQExtension.getRabbitMQ().withQuorumQueueConfiguration(),
+ searchEngine, restClient);
+
+ consumer.start();
+ }
+
+ @AfterEach
+ void afterEach() throws IOException {
+ consumer.close();
+ }
+
+ @Test
+ void consumeMessageShouldNotCrashOnInvalidMessages() throws InterruptedException {
+ IntStream.range(0, 10).forEach(i -> sendMessage("BAD_PAYLOAD" + i));
+
+ TimeUnit.MILLISECONDS.sleep(100);
+
+ sendMessage("""
+ {
+ "bookId": "ALICE_USER_ID",
+ "bookName": "contacts",
+ "contactId": "fd9b3c98-fc77-4187-92ac-d9f58d400968",
+ "userId": "ALICE_USER_ID",
+ "vcard": [
+ "vcard",
+ [
+ [ "version", {}, "text", "4.0" ],
+ [ "kind", {}, "text", "individual" ],
+ [ "fn", {}, "text", "Jane Doe" ],
+ [ "email", {}, "text", "jhon@doe.com" ],
+ [ "org", {}, "text", [ "ABC, Inc.", "North American Division", "Marketing" ] ]
+ ]
+ ]
+ }
+ """);
+
+ await().timeout(TEN_SECONDS).untilAsserted(() ->
+ assertThat(
+ Flux.from(searchEngine.autoComplete(AccountId.fromString(OpenPaasServerExtension.ALICE_EMAIL()), "jhon", 10))
+ .collectList().block())
+ .hasSize(1));
+ }
+
+ @Test
+ void contactShouldBeIndexedWhenContactUserAddedMessage() {
+ sendMessage("""
+ {
+ "bookId": "ALICE_USER_ID",
+ "bookName": "contacts",
+ "contactId": "fd9b3c98-fc77-4187-92ac-d9f58d400968",
+ "userId": "ALICE_USER_ID",
+ "vcard": [
+ "vcard",
+ [
+ [ "version", {}, "text", "4.0" ],
+ [ "kind", {}, "text", "individual" ],
+ [ "fn", {}, "text", "Jane Doe" ],
+ [ "email", {}, "text", "jhon@doe.com" ],
+ [ "org", {}, "text", [ "ABC, Inc.", "North American Division", "Marketing" ] ]
+ ]
+ ]
+ }
+ """);
+
+ await().timeout(TEN_SECONDS).untilAsserted(() ->
+ assertThat(
+ Flux.from(searchEngine.autoComplete(AccountId.fromString(OpenPaasServerExtension.ALICE_EMAIL()), "jhon", 10))
+ .collectList().block())
+ .hasSize(1)
+ .map(EmailAddressContact::fields)
+ .allSatisfy(Throwing.consumer(contact -> {
+ assertThat(contact.address()).isEqualTo(new MailAddress("jhon@doe.com"));
+ assertThat(contact.firstname()).isEqualTo("Jane Doe");
+ assertThat(contact.surname()).isEqualTo("");
+ })));
+ }
+
+ @Test
+ void consumeMessageShouldNotCrashOnInvalidContactMailAddress() {
+ sendMessage("""
+ {
+ "bookId": "ALICE_USER_ID",
+ "bookName": "contacts",
+ "contactId": "fd9b3c98-fc77-4187-92ac-d9f58d400968",
+ "userId": "ALICE_USER_ID",
+ "vcard": [
+ "vcard",
+ [
+ [ "version", {}, "text", "4.0" ],
+ [ "kind", {}, "text", "individual" ],
+ [ "fn", {}, "text", "Jane Doe" ],
+ [ "email", {}, "text", "BAD_MAIL_ADDRESS" ],
+ [ "org", {}, "text", [ "ABC, Inc.", "North American Division", "Marketing" ] ]
+ ]
+ ]
+ }
+ """);
+
+ await().timeout(TEN_SECONDS).untilAsserted(() ->
+ assertThat(
+ Flux.from(searchEngine.autoComplete(AccountId.fromString(OpenPaasServerExtension.ALICE_EMAIL()), "jhon", 10))
+ .collectList().block()).isEmpty());
+
+ sendMessage("""
+ {
+ "bookId": "ALICE_USER_ID",
+ "bookName": "contacts",
+ "contactId": "fd9b3c98-fc77-4187-92ac-d9f58d400968",
+ "userId": "ALICE_USER_ID",
+ "vcard": [
+ "vcard",
+ [
+ [ "version", {}, "text", "4.0" ],
+ [ "kind", {}, "text", "individual" ],
+ [ "fn", {}, "text", "Jane Doe" ],
+ [ "email", {}, "text", "jhon@doe.com" ],
+ [ "org", {}, "text", [ "ABC, Inc.", "North American Division", "Marketing" ] ]
+ ]
+ ]
+ }
+ """);
+
+ await().timeout(TEN_SECONDS).untilAsserted(() ->
+ assertThat(
+ Flux.from(searchEngine.autoComplete(AccountId.fromString(OpenPaasServerExtension.ALICE_EMAIL()), "jhon", 10))
+ .collectList().block())
+ .hasSize(1));
+ }
+
+ @Test
+ void consumeMessageShouldNotCrashWhenFnPropertyIsNotProvided() {
+ sendMessage("""
+ {
+ "bookId": "ALICE_USER_ID",
+ "bookName": "contacts",
+ "contactId": "fd9b3c98-fc77-4187-92ac-d9f58d400968",
+ "userId": "ALICE_USER_ID",
+ "vcard": [
+ "vcard",
+ [
+ [ "version", {}, "text", "4.0" ],
+ [ "kind", {}, "text", "individual" ],
+ [ "email", {}, "text", "jhon@doe.com" ],
+ [ "org", {}, "text", [ "ABC, Inc.", "North American Division", "Marketing" ] ]
+ ]
+ ]
+ }
+ """);
+
+ await().timeout(TEN_SECONDS).untilAsserted(() ->
+ assertThat(
+ Flux.from(searchEngine.autoComplete(AccountId.fromString(OpenPaasServerExtension.ALICE_EMAIL()), "jhon", 10))
+ .collectList().block())
+ .hasSize(1)
+ .map(EmailAddressContact::fields)
+ .allSatisfy(Throwing.consumer(contact -> {
+ assertThat(contact.firstname()).isEmpty();
+ assertThat(contact.surname()).isEmpty();
+ assertThat(contact.address()).isEqualTo(new MailAddress("jhon@doe.com"));
+ })));
+ }
+
+ @Test
+ void consumeMessageShouldNotCrashOnInvalidOwnerMailAddress() {
+ // Note: Bob has an invalid mail address.
+ sendMessage("""
+ {
+ "bookId": "BOB_USER_ID",
+ "bookName": "contacts",
+ "contactId": "fd9b3c98-fc77-4187-92ac-d9f58d400968",
+ "userId": "BOB_USER_ID",
+ "vcard": [
+ "vcard",
+ [
+ [ "version", {}, "text", "4.0" ],
+ [ "kind", {}, "text", "individual" ],
+ [ "fn", {}, "text", "Jane Doe" ],
+ [ "email", {}, "text", "jhon@doe.com" ],
+ [ "org", {}, "text", [ "ABC, Inc.", "North American Division", "Marketing" ] ]
+ ]
+ ]
+ }
+ """);
+
+ await().timeout(TEN_SECONDS).untilAsserted(() ->
+ assertThat(
+ Flux.from(searchEngine.autoComplete(AccountId.fromString(OpenPaasServerExtension.ALICE_EMAIL()), "jhon", 10))
+ .collectList().block())
+ .isEmpty());
+
+ sendMessage("""
+ {
+ "bookId": "ALICE_USER_ID",
+ "bookName": "contacts",
+ "contactId": "fd9b3c98-fc77-4187-92ac-d9f58d400968",
+ "userId": "ALICE_USER_ID",
+ "vcard": [
+ "vcard",
+ [
+ [ "version", {}, "text", "4.0" ],
+ [ "kind", {}, "text", "individual" ],
+ [ "fn", {}, "text", "Jane Doe" ],
+ [ "email", {}, "text", "jhon@doe.com" ],
+ [ "org", {}, "text", [ "ABC, Inc.", "North American Division", "Marketing" ] ]
+ ]
+ ]
+ }
+ """);
+
+ await().timeout(TEN_SECONDS).untilAsserted(() ->
+ assertThat(
+ Flux.from(searchEngine.autoComplete(AccountId.fromString(OpenPaasServerExtension.ALICE_EMAIL()), "jhon", 10))
+ .collectList().block())
+ .hasSize(1));
+ }
+
+ @Test
+ void givenDisplayNameFromOpenPaasNotEmptyThenStoredDisplayNameShouldBeOverridden()
+ throws AddressException {
+ indexJhonDoe(OpenPaasServerExtension.ALICE_EMAIL());
+
+ sendMessage("""
+ {
+ "bookId": "ALICE_USER_ID",
+ "bookName": "contacts",
+ "contactId": "fd9b3c98-fc77-4187-92ac-d9f58d400968",
+ "userId": "ALICE_USER_ID",
+ "vcard": [
+ "vcard",
+ [
+ [ "version", {}, "text", "4.0" ],
+ [ "kind", {}, "text", "individual" ],
+ [ "fn", {}, "text", "Jhon Dont" ],
+ [ "email", {}, "text", "jhon@doe.com" ],
+ [ "org", {}, "text", [ "ABC, Inc.", "North American Division", "Marketing" ] ]
+ ]
+ ]
+ }
+ """);
+
+ ContactFields expectedContact =
+ new ContactFields(new MailAddress("jhon@doe.com"), "Jhon Dont", "");
+
+ await().timeout(TEN_SECONDS).untilAsserted(() ->
+ assertThat(
+ Flux.from(searchEngine.autoComplete(AccountId.fromString(OpenPaasServerExtension.ALICE_EMAIL()), "jhon", 10))
+ .collectList().block())
+ .hasSize(1)
+ .map(EmailAddressContact::fields)
+ .first()
+ .isEqualTo(expectedContact));
+ }
+
+ @Test
+ void givenDisplayNameFromOpenPaasIsEmptyThenStoredDisplayNameShouldPersist() {
+ ContactFields indexedContact = indexJhonDoe(OpenPaasServerExtension.ALICE_EMAIL());
+
+ sendMessage("""
+ {
+ "bookId": "ALICE_USER_ID",
+ "bookName": "contacts",
+ "contactId": "fd9b3c98-fc77-4187-92ac-d9f58d400968",
+ "userId": "ALICE_USER_ID",
+ "vcard": [
+ "vcard",
+ [
+ [ "version", {}, "text", "4.0" ],
+ [ "kind", {}, "text", "individual" ],
+ [ "fn", {}, "text", " " ],
+ [ "email", {}, "text", "jhon@doe.com" ],
+ [ "org", {}, "text", [ "ABC, Inc.", "North American Division", "Marketing" ] ]
+ ]
+ ]
+ }
+ """);
+
+ await().timeout(TEN_SECONDS).untilAsserted(() ->
+ assertThat(
+ Flux.from(searchEngine.autoComplete(AccountId.fromString(OpenPaasServerExtension.ALICE_EMAIL()), "jhon", 10))
+ .collectList().block())
+ .hasSize(1)
+ .map(EmailAddressContact::fields)
+ .first()
+ .isEqualTo(indexedContact));
+ }
+
+
+ private void sendMessage(String message) {
+ rabbitMQExtension.getSender()
+ .send(Mono.just(new OutboundMessage(
+ OpenPaasContactsConsumer.EXCHANGE_NAME,
+ EMPTY_ROUTING_KEY,
+ message.getBytes(UTF_8))))
+ .block();
+ }
+
+ private ContactFields indexJhonDoe(String ownerMailAddressString) {
+ try {
+ MailAddress ownerMailAddress = new MailAddress(ownerMailAddressString);
+ MailAddress jhonDoeMailAddress = new MailAddress("jhon@doe.com");
+ AccountId aliceAccountId =
+ AccountId.fromUsername(Username.fromMailAddress(ownerMailAddress));
+
+ return Mono.from(searchEngine.index(aliceAccountId,
+ new ContactFields(jhonDoeMailAddress, "Jhon", "Doe"))).block().fields();
+ }
+ catch (AddressException e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/tmail-backend/tmail-third-party/openpaas/src/test/scala/com/linagora/tmail/api/OpenPaasServerExtension.scala b/tmail-backend/tmail-third-party/openpaas/src/test/scala/com/linagora/tmail/api/OpenPaasServerExtension.scala
index e6157e3015..0941176f93 100644
--- a/tmail-backend/tmail-third-party/openpaas/src/test/scala/com/linagora/tmail/api/OpenPaasServerExtension.scala
+++ b/tmail-backend/tmail-third-party/openpaas/src/test/scala/com/linagora/tmail/api/OpenPaasServerExtension.scala
@@ -3,7 +3,7 @@ package com.linagora.tmail.api
import java.net.{URI, URL}
import com.linagora.tmail.HttpUtils
-import com.linagora.tmail.api.OpenPaasServerExtension.{ALICE_EMAIL, ALICE_USER_ID, BAD_AUTHENTICATION_TOKEN, GOOD_AUTHENTICATION_TOKEN, LOGGER}
+import com.linagora.tmail.api.OpenPaasServerExtension.{ALICE_EMAIL, ALICE_USER_ID, BAD_AUTHENTICATION_TOKEN, BOB_INVALID_EMAIL, BOB_USER_ID, GOOD_AUTHENTICATION_TOKEN, LOGGER}
import org.junit.jupiter.api.extension._
import org.mockserver.configuration.ConfigurationProperties
import org.mockserver.integration.ClientAndServer
@@ -15,8 +15,10 @@ import org.slf4j.{Logger, LoggerFactory}
object OpenPaasServerExtension {
- val ALICE_USER_ID: String = "abc0a663bdaffe0026290xyz"
+ val ALICE_USER_ID: String = "ALICE_USER_ID"
val ALICE_EMAIL: String = "adoe@linagora.com"
+ val BOB_USER_ID: String = "BOB_USER_ID"
+ val BOB_INVALID_EMAIL: String = "BOB_EMAIL_IS_INVALID"
val GOOD_USER = "admin"
val GOOD_PASSWORD = "admin"
val BAD_USER = "BAD_USER"
@@ -86,6 +88,54 @@ class OpenPaasServerExtension extends BeforeEachCallback with AfterEachCallback
| "followers": 0,
| "followings": 0
|}""".stripMargin))
+
+ mockServer.when(
+ request.withPath(s"/users/$BOB_USER_ID")
+ .withMethod("GET")
+ .withHeader(string("Authorization"), string(GOOD_AUTHENTICATION_TOKEN)))
+ .respond(response.withStatusCode(200)
+ .withBody(s"""{
+ | "_id": "$BOB_USER_ID",
+ | "firstname": "Alice",
+ | "lastname": "DOE",
+ | "preferredEmail": "$BOB_INVALID_EMAIL",
+ | "emails": [
+ | "$BOB_INVALID_EMAIL"
+ | ],
+ | "domains": [
+ | {
+ | "joined_at": "2020-09-03T08:16:35.682Z",
+ | "domain_id": "$BOB_USER_ID"
+ | }
+ | ],
+ | "states": [],
+ | "avatars": [
+ | "$BOB_USER_ID"
+ | ],
+ | "main_phone": "01111111111",
+ | "accounts": [
+ | {
+ | "timestamps": {
+ | "creation": "2020-09-03T08:16:35.682Z"
+ | },
+ | "hosted": true,
+ | "emails": [
+ | "adoe@linagora.com"
+ | ],
+ | "preferredEmailIndex": 0,
+ | "type": "email"
+ | }
+ | ],
+ | "login": {
+ | "failures": [],
+ | "success": "2024-10-04T12:59:44.469Z"
+ | },
+ | "id": "$BOB_USER_ID",
+ | "displayName": "Alice DOE",
+ | "objectType": "user",
+ | "followers": 0,
+ | "followings": 0
+ |}""".stripMargin))
}
override def afterEach(context: ExtensionContext): Unit = {