diff --git a/tmail-backend/tmail-third-party/openpaas/pom.xml b/tmail-backend/tmail-third-party/openpaas/pom.xml index effa8f2995..f1805f3017 100644 --- a/tmail-backend/tmail-third-party/openpaas/pom.xml +++ b/tmail-backend/tmail-third-party/openpaas/pom.xml @@ -14,6 +14,20 @@ OpenPaaS integration for Twake Mail + + ${project.groupId} + jmap-extensions + + + ${james.groupId} + apache-james-backends-rabbitmq + + + ${james.groupId} + apache-james-backends-rabbitmq + test + test-jar + ${james.groupId} james-core diff --git a/tmail-backend/tmail-third-party/openpaas/src/main/java/com/linagora/tmail/api/OpenPaasUserResponse.java b/tmail-backend/tmail-third-party/openpaas/src/main/java/com/linagora/tmail/api/OpenPaasUserResponse.java index 4756406a19..777f9c6787 100644 --- a/tmail-backend/tmail-third-party/openpaas/src/main/java/com/linagora/tmail/api/OpenPaasUserResponse.java +++ b/tmail-backend/tmail-third-party/openpaas/src/main/java/com/linagora/tmail/api/OpenPaasUserResponse.java @@ -1,16 +1,8 @@ package com.linagora.tmail.api; -import java.util.List; - import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; @JsonIgnoreProperties(ignoreUnknown = true) -public record OpenPaasUserResponse(@JsonProperty("id") String id, - @JsonProperty("firstname") String firstname, - @JsonProperty("lastname") String lastname, - @JsonProperty("preferredEmail") String preferredEmail, - @JsonProperty("emails") List emails, - @JsonProperty("main_phone") String mainPhone, - @JsonProperty("displayName") String displayName) { +public record OpenPaasUserResponse(@JsonProperty("preferredEmail") String preferredEmail) { } diff --git a/tmail-backend/tmail-third-party/openpaas/src/main/java/com/linagora/tmail/contact/ContactAddedRabbitMqMessage.java b/tmail-backend/tmail-third-party/openpaas/src/main/java/com/linagora/tmail/contact/ContactAddedRabbitMqMessage.java new file mode 100644 index 0000000000..a0081b7d35 --- /dev/null +++ b/tmail-backend/tmail-third-party/openpaas/src/main/java/com/linagora/tmail/contact/ContactAddedRabbitMqMessage.java @@ -0,0 +1,19 @@ +package com.linagora.tmail.contact; + +import java.io.IOException; + +import com.fasterxml.jackson.databind.ObjectMapper; + +public record ContactAddedRabbitMqMessage(String bookId, String bookName, String contactId, + String userId, JCardObject vcard) { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + static ContactAddedRabbitMqMessage fromJSON(byte[] jsonBytes) { + try { + return objectMapper.readValue(jsonBytes, ContactAddedRabbitMqMessage.class); + } catch (IOException e) { + throw new RuntimeException("Failed to parse ContactAddedRabbitMqMessage", e); + } + } +} diff --git a/tmail-backend/tmail-third-party/openpaas/src/main/java/com/linagora/tmail/contact/JCardObject.java b/tmail-backend/tmail-third-party/openpaas/src/main/java/com/linagora/tmail/contact/JCardObject.java new file mode 100644 index 0000000000..4e9854f4ac --- /dev/null +++ b/tmail-backend/tmail-third-party/openpaas/src/main/java/com/linagora/tmail/contact/JCardObject.java @@ -0,0 +1,57 @@ +package com.linagora.tmail.contact; + +import java.util.Optional; + +import org.apache.james.core.MailAddress; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.google.common.base.Preconditions; +import com.linagora.tmail.james.jmap.contact.ContactFields; + +@JsonDeserialize(using = JCardObjectDeserializer.class) +public record JCardObject(Optional fnOpt, Optional 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 = {