Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ISSUE-1163] Inject OpenPaaS user contacts into Tmail's auto-complete database #1215

Merged
merged 33 commits into from
Oct 25, 2024
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
0603398
[NO_REVIEW] Do work in jmap-extensions
HoussemNasri Oct 3, 2024
b793e5b
Rebase
HoussemNasri Oct 17, 2024
6e678d5
Remove unneeded change in jmap-extensions:pom.xml
HoussemNasri Oct 17, 2024
64caa79
Make the fn property in jCardObject optional
HoussemNasri Oct 17, 2024
7920a68
Refactor OpenPaasContactsConsumer
HoussemNasri Oct 18, 2024
3034856
Configure the dead letter queue
HoussemNasri Oct 20, 2024
422dc57
Strip extra fields from OpenPaasUserResponse
HoussemNasri Oct 21, 2024
0cb1daf
Fix typo when rebasing
HoussemNasri Oct 21, 2024
b3f4e45
Take the contactField computation out
HoussemNasri Oct 21, 2024
4044083
Fix rabbitmq queue poisoning
HoussemNasri Oct 21, 2024
921d4ed
Define expected behavior in tests
HoussemNasri Oct 21, 2024
246835c
Handle review comments
HoussemNasri Oct 22, 2024
2d6213d
Update tmail-backend/tmail-third-party/openpaas/src/main/java/com/lin…
HoussemNasri Oct 22, 2024
0a941ae
Remove deadletter exchange boilerplate
HoussemNasri Oct 22, 2024
0934508
Fix compilation error
HoussemNasri Oct 22, 2024
9522451
Test edge cases
HoussemNasri Oct 22, 2024
b2087dd
Use better user ids in tests
HoussemNasri Oct 22, 2024
1fbf532
Add note to test
HoussemNasri Oct 22, 2024
f188b1f
Refactor test
HoussemNasri Oct 22, 2024
dcdc15f
Test JCardObject
HoussemNasri Oct 22, 2024
7bd2df7
Handle some "definition of done" requirements
HoussemNasri Oct 22, 2024
d95de64
Refactor
HoussemNasri Oct 22, 2024
aa008f4
Refactor OpenPaasContactsConsumer
HoussemNasri Oct 22, 2024
98e68c7
Update tmail-backend/tmail-third-party/openpaas/src/main/java/com/lin…
HoussemNasri Oct 22, 2024
a8bec38
Add import
HoussemNasri Oct 22, 2024
98ef683
Update tmail-backend/tmail-third-party/openpaas/src/test/java/com/lin…
HoussemNasri Oct 22, 2024
82c6afa
Update tmail-backend/tmail-third-party/openpaas/src/main/java/com/lin…
HoussemNasri Oct 22, 2024
199aecd
Update tmail-backend/tmail-third-party/openpaas/src/test/java/com/lin…
HoussemNasri Oct 22, 2024
4a1c7ae
Update tmail-backend/tmail-third-party/openpaas/src/main/java/com/lin…
HoussemNasri Oct 22, 2024
564082b
Refactor
HoussemNasri Oct 22, 2024
890f7b5
Fix compilation
HoussemNasri Oct 22, 2024
543992d
Update tmail-backend/tmail-third-party/openpaas/src/test/java/com/lin…
HoussemNasri Oct 22, 2024
ba36867
Use stronger type to represent an email in a JCard object
HoussemNasri Oct 23, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>
HoussemNasri marked this conversation as resolved.
Show resolved Hide resolved
</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,67 @@
package com.linagora.tmail.contact;

import java.util.Optional;

import jakarta.mail.internet.AddressException;

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<String> emailOpt) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we use MailAddress here or it's fine as is? And throw an exception at the deserializer.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of course using stronger types in the data model indeed is always better. If we think this can easily be achieved then we shall go for it.

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<String> emailOpt() {
return emailOpt;
}

public Optional<ContactFields> asContactFields() {
Optional<String> contactFullnameOpt = fnOpt();
Optional<MailAddress> contactMailAddressOpt = emailOpt()
.flatMap(contactEmail -> {
try {
return Optional.of(new MailAddress(contactEmail));
} catch (AddressException e) {
LOGGER.warn("Invalid contact email address: {}", contactEmail, e);
return Optional.empty();
}
});

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,80 @@
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 org.apache.commons.lang3.tuple.ImmutablePair;
import org.apache.commons.lang3.tuple.Pair;
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);
}

return new JCardObject(getOptionalFromMap(jCardProperties, FN), getOptionalFromMap(jCardProperties, EMAIL));
}

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,152 @@
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