Skip to content

Commit

Permalink
[ISSUE-1163] Inject OpenPaaS user contacts into Tmail's auto-complete…
Browse files Browse the repository at this point in the history
… database (#1215)
  • Loading branch information
HoussemNasri authored Oct 25, 2024
1 parent 8b403da commit 40331b2
Show file tree
Hide file tree
Showing 10 changed files with 899 additions and 11 deletions.
14 changes: 14 additions & 0 deletions tmail-backend/tmail-third-party/openpaas/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@
<description>OpenPaaS integration for Twake Mail</description>

<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>jmap-extensions</artifactId>
</dependency>
<dependency>
<groupId>${james.groupId}</groupId>
<artifactId>apache-james-backends-rabbitmq</artifactId>
</dependency>
<dependency>
<groupId>${james.groupId}</groupId>
<artifactId>apache-james-backends-rabbitmq</artifactId>
<scope>test</scope>
<type>test-jar</type>
</dependency>
<dependency>
<groupId>${james.groupId}</groupId>
<artifactId>james-core</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> emails,
@JsonProperty("main_phone") String mainPhone,
@JsonProperty("displayName") String displayName) {
public record OpenPaasUserResponse(@JsonProperty("preferredEmail") String preferredEmail) {
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String> fnOpt, Optional<MailAddress> 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.
* <p>
* Example: Mr. John Q. Public\, Esq.
*/
@Override
public Optional<String> fnOpt() {
return fnOpt;
}

/**
* Purpose: To specify the electronic mail address for communication
* with the object the vCard represents.
* <p>
* Example: [email protected]
*/
@Override
public Optional<MailAddress> emailOpt() {
return emailOpt;
}

public Optional<ContactFields> asContactFields() {
Optional<String> contactFullnameOpt = fnOpt();
Optional<MailAddress> contactMailAddressOpt = emailOpt();

if (contactMailAddressOpt.isEmpty()) {
return Optional.empty();
}

MailAddress contactMailAddress = contactMailAddressOpt.get();
String contactFullname = contactFullnameOpt.orElse("");

return Optional.of(new ContactFields(contactMailAddress, contactFullname, ""));
}
}
Original file line number Diff line number Diff line change
@@ -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<JCardObject> {
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<String> 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<String, String> 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<MailAddress> 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<String, String> collectJCardProperties(Iterator<JsonNode> propertiesIterator) {
return Iterators.toStream(propertiesIterator)
.map(JCardObjectDeserializer::getPropertyKeyValuePair)
.flatMap(Optional::stream)
.collect(Collectors.toMap(Pair::getKey, Pair::getValue));
}

private static Optional<ImmutablePair<String, String>> 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<String> getOptionalFromMap(Map<String, String> map, String key) {
return Optional.ofNullable(map.getOrDefault(key, null));
}
}
Original file line number Diff line number Diff line change
@@ -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<AcknowledgableDelivery> delivery() {
return Flux.using(receiverProvider::createReceiver,
receiver -> receiver.consumeManualAck(QUEUE_NAME),
Receiver::close);
}

private Mono<EmailAddressContact> 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<EmailAddressContact> handleMessage(ContactAddedRabbitMqMessage contactAddedMessage) {
LOGGER.trace("Consumed jCard object message: {}", contactAddedMessage);
Optional<ContactFields> 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<EmailAddressContact> 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);
}
}
Loading

0 comments on commit 40331b2

Please sign in to comment.