From 402ca81088ee288470195b6c2f40c645086589f2 Mon Sep 17 00:00:00 2001 From: HoussemNasri Date: Wed, 8 Jan 2025 14:10:52 +0100 Subject: [PATCH 1/8] Introduce the CalendarEventAttendance/get JMAP method --- .../james/jmap/EventAttendanceRepository.java | 4 +- .../tmail/james/jmap/MessagePartBlobId.java | 11 ++ .../StandaloneEventAttendanceRepository.java | 39 +++-- .../CalendarEventAttendanceSerializer.scala | 26 ++++ .../CalendarEventAttendanceGetMethod.scala | 136 ++++++++++++++++++ .../method/CalendarEventParseMethod.scala | 3 + ...ndaloneEventAttendanceRepositoryTest.scala | 44 ++++-- 7 files changed, 238 insertions(+), 25 deletions(-) create mode 100644 tmail-backend/jmap/extensions/src/main/scala/com/linagora/tmail/james/jmap/json/CalendarEventAttendanceSerializer.scala create mode 100644 tmail-backend/jmap/extensions/src/main/scala/com/linagora/tmail/james/jmap/method/CalendarEventAttendanceGetMethod.scala diff --git a/tmail-backend/jmap/extensions/src/main/java/com/linagora/tmail/james/jmap/EventAttendanceRepository.java b/tmail-backend/jmap/extensions/src/main/java/com/linagora/tmail/james/jmap/EventAttendanceRepository.java index f21a4b4f78..d0c7b9e610 100644 --- a/tmail-backend/jmap/extensions/src/main/java/com/linagora/tmail/james/jmap/EventAttendanceRepository.java +++ b/tmail-backend/jmap/extensions/src/main/java/com/linagora/tmail/james/jmap/EventAttendanceRepository.java @@ -4,14 +4,14 @@ import org.apache.james.core.Username; import org.apache.james.jmap.mail.BlobIds; -import org.apache.james.mailbox.model.MessageId; import org.reactivestreams.Publisher; +import com.linagora.tmail.james.jmap.method.CalendarEventAttendanceResults; import com.linagora.tmail.james.jmap.model.CalendarEventReplyResults; import com.linagora.tmail.james.jmap.model.LanguageLocation; public interface EventAttendanceRepository { - Publisher getAttendanceStatus(Username username, MessageId messageId); + Publisher getAttendanceStatus(Username username, BlobIds calendarEventBlobIds); Publisher setAttendanceStatus(Username username, AttendanceStatus attendanceStatus, BlobIds eventBlobIds, diff --git a/tmail-backend/jmap/extensions/src/main/java/com/linagora/tmail/james/jmap/MessagePartBlobId.java b/tmail-backend/jmap/extensions/src/main/java/com/linagora/tmail/james/jmap/MessagePartBlobId.java index 02eec2be54..c2fd672c0a 100644 --- a/tmail-backend/jmap/extensions/src/main/java/com/linagora/tmail/james/jmap/MessagePartBlobId.java +++ b/tmail-backend/jmap/extensions/src/main/java/com/linagora/tmail/james/jmap/MessagePartBlobId.java @@ -13,6 +13,9 @@ import reactor.util.function.Tuple2; import reactor.util.function.Tuples; +import scala.util.Failure; +import scala.util.Success; +import scala.util.Try; public final class MessagePartBlobId { private static final Pattern MESSAGE_PART_BLOB_ID_PATTERN = @@ -40,6 +43,14 @@ public MessagePartBlobId(String value) { .toList(); } + public static Try tryParse(String blobId) { + try { + return new Success<>(new MessagePartBlobId(blobId)); + } catch (Exception e) { + return new Failure<>(e); + } + } + public String getMessageId() { return messageId; } diff --git a/tmail-backend/jmap/extensions/src/main/java/com/linagora/tmail/james/jmap/StandaloneEventAttendanceRepository.java b/tmail-backend/jmap/extensions/src/main/java/com/linagora/tmail/james/jmap/StandaloneEventAttendanceRepository.java index 61b79a0df6..05b50103d4 100644 --- a/tmail-backend/jmap/extensions/src/main/java/com/linagora/tmail/james/jmap/StandaloneEventAttendanceRepository.java +++ b/tmail-backend/jmap/extensions/src/main/java/com/linagora/tmail/james/jmap/StandaloneEventAttendanceRepository.java @@ -8,6 +8,7 @@ import org.apache.james.core.Username; import org.apache.james.jmap.core.AccountId; +import org.apache.james.jmap.mail.BlobId; import org.apache.james.jmap.mail.BlobIds; import org.apache.james.mailbox.MailboxSession; import org.apache.james.mailbox.MessageIdManager; @@ -20,7 +21,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.linagora.tmail.james.jmap.method.CalendarEventAttendanceResults; +import com.linagora.tmail.james.jmap.method.CalendarEventAttendanceResults$; import com.linagora.tmail.james.jmap.method.CalendarEventReplyPerformer; +import com.linagora.tmail.james.jmap.method.EventAttendanceStatusEntry; import com.linagora.tmail.james.jmap.model.CalendarEventReplyRequest; import com.linagora.tmail.james.jmap.model.CalendarEventReplyResults; import com.linagora.tmail.james.jmap.model.LanguageLocation; @@ -48,13 +52,28 @@ public StandaloneEventAttendanceRepository(MessageIdManager messageIdManager, Se } @Override - public Publisher getAttendanceStatus(Username username, MessageId messageId) { - LOGGER.trace("Getting attendance status for user '{}' and message '{}'", username, messageId); + public Publisher getAttendanceStatus(Username username, BlobIds calendarEventBlobIds) { + LOGGER.trace("Getting attendance status for user '{}' and message '{}'", username, + calendarEventBlobIds); MailboxSession systemMailboxSession = sessionProvider.createSystemSession(username); - return getFlags(messageId, systemMailboxSession) - .flatMap(userFlags -> Mono.justOrEmpty(AttendanceStatus.fromMessageFlags(userFlags))) - .switchIfEmpty(handleMissingEventAttendanceFlag(messageId)); + return Flux.fromIterable(JavaConverters.seqAsJavaList(calendarEventBlobIds.value())) + .flatMap(blobId -> getAttendanceStatusFromEventBlob(blobId.value(), systemMailboxSession)) + .reduce(CalendarEventAttendanceResults$.MODULE$.empty(), CalendarEventAttendanceResults$.MODULE$::merge); + } + + private Flux getAttendanceStatusFromEventBlob(String blobId, MailboxSession systemMailboxSession) { + return extractMessageId(blobId) + .flatMapMany(messageId -> + getFlags(messageId, systemMailboxSession) + .flatMap(userFlags -> Mono.justOrEmpty(AttendanceStatus.fromMessageFlags(userFlags))) + .switchIfEmpty(handleMissingEventAttendanceFlag(messageId))) + .map(attendanceStatus -> + CalendarEventAttendanceResults$.MODULE$.done( + new EventAttendanceStatusEntry(blobId, attendanceStatus))) + .onErrorResume(Exception.class, (error) -> + Mono.just(CalendarEventAttendanceResults$.MODULE$.notDone( + BlobId.of(blobId).get(), error, systemMailboxSession))); } private Mono handleMissingEventAttendanceFlag(MessageId messageId) { @@ -72,7 +91,7 @@ public Publisher setAttendanceStatus(Username usernam MailboxSession systemMailboxSession = sessionProvider.createSystemSession(username); return Flux.fromIterable(JavaConverters.seqAsJavaList(calendarEventBlobIds.value())) - .map(blobId -> extractMessageId(blobId.value())) + .flatMap(blobId -> extractMessageId(blobId.value())) .onErrorContinue((throwable, o) -> LOGGER.debug("Failed to extract message id from blob id: {}", o, throwable)) .collectList() .flatMap(messageIds -> @@ -85,8 +104,12 @@ public Publisher setAttendanceStatus(Username usernam .then(tryToSendReplyEmail(username, calendarEventBlobIds, maybePreferredLanguage, systemMailboxSession, attendanceStatus)); } - private MessageId extractMessageId(String blobId) { - return messageIdFactory.fromString(new MessagePartBlobId(blobId).getMessageId()); + private Mono extractMessageId(String blobId) { + return Mono.fromCallable(() -> + MessagePartBlobId.tryParse(blobId) + .map(MessagePartBlobId::getMessageId) + .map(messageIdFactory::fromString) + .get()); } private Mono tryToSendReplyEmail(Username username, diff --git a/tmail-backend/jmap/extensions/src/main/scala/com/linagora/tmail/james/jmap/json/CalendarEventAttendanceSerializer.scala b/tmail-backend/jmap/extensions/src/main/scala/com/linagora/tmail/james/jmap/json/CalendarEventAttendanceSerializer.scala new file mode 100644 index 0000000000..16e7450ee4 --- /dev/null +++ b/tmail-backend/jmap/extensions/src/main/scala/com/linagora/tmail/james/jmap/json/CalendarEventAttendanceSerializer.scala @@ -0,0 +1,26 @@ +package com.linagora.tmail.james.jmap.json + +import com.linagora.tmail.james.jmap.method.{CalendarEventAttendanceGetRequest, CalendarEventAttendanceGetResponse, CalendarEventAttendanceResults} +import com.linagora.tmail.james.jmap.model.{CalendarEventNotDone, CalendarEventNotFound} +import org.apache.james.jmap.mail.{BlobId, BlobIds} +import play.api.libs.json.{JsResult, JsValue, Json, OWrites, Reads, Writes} + +object CalendarEventAttendanceSerializer { + private implicit val blobIdReads: Reads[BlobId] = Json.valueReads[BlobId] + private implicit val blobIdsReads: Reads[BlobIds] = Json.valueReads[BlobIds] + private implicit val blobIdWrites: Writes[BlobId] = Json.valueWrites[BlobId] + + private implicit val calendarEventNotFoundWrites: Writes[CalendarEventNotFound] = Json.valueWrites[CalendarEventNotFound] + private implicit val calendarEventNotDoneWrites: Writes[CalendarEventNotDone] = Json.valueWrites[CalendarEventNotDone] + + private implicit val calendarEventAttendanceGetRequestReads: Reads[CalendarEventAttendanceGetRequest] = Json.reads[CalendarEventAttendanceGetRequest] + + private implicit val calendarEventAttendanceGetResponseWrites: OWrites[CalendarEventAttendanceGetResponse] = Json.writes[CalendarEventAttendanceGetResponse] + + def deserializeEventAttendanceGetRequest(input: JsValue): JsResult[CalendarEventAttendanceGetRequest] = + Json.fromJson[CalendarEventAttendanceGetRequest](input) + + def serializeEventAttendanceGetResponse(response: CalendarEventAttendanceGetResponse): JsValue = + Json.toJson(response) + +} diff --git a/tmail-backend/jmap/extensions/src/main/scala/com/linagora/tmail/james/jmap/method/CalendarEventAttendanceGetMethod.scala b/tmail-backend/jmap/extensions/src/main/scala/com/linagora/tmail/james/jmap/method/CalendarEventAttendanceGetMethod.scala new file mode 100644 index 0000000000..fdd6579b9d --- /dev/null +++ b/tmail-backend/jmap/extensions/src/main/scala/com/linagora/tmail/james/jmap/method/CalendarEventAttendanceGetMethod.scala @@ -0,0 +1,136 @@ +package com.linagora.tmail.james.jmap.method + +import com.linagora.tmail.james.jmap.json.CalendarEventAttendanceSerializer +import com.linagora.tmail.james.jmap.method.CalendarEventAttendanceGetRequest.MAXIMUM_NUMBER_OF_BLOB_IDS +import com.linagora.tmail.james.jmap.method.CapabilityIdentifier.LINAGORA_CALENDAR +import com.linagora.tmail.james.jmap.model.{CalendarEventNotDone, CalendarEventNotFound, CalendarEventNotParsable, InvalidCalendarFileException} +import com.linagora.tmail.james.jmap.{AttendanceStatus, EventAttendanceRepository} +import eu.timepit.refined.auto._ +import eu.timepit.refined.refineV +import jakarta.inject.Inject +import org.apache.james.jmap.core.CapabilityIdentifier.{CapabilityIdentifier, JMAP_CORE} +import org.apache.james.jmap.core.Id.IdConstraint +import org.apache.james.jmap.core.Invocation.{Arguments, MethodName} +import org.apache.james.jmap.core.SetError.SetErrorDescription +import org.apache.james.jmap.core.{AccountId, Invocation, SessionTranslator, SetError} +import org.apache.james.jmap.json.ResponseSerializer +import org.apache.james.jmap.mail.{BlobId, BlobIds, RequestTooLargeException} +import org.apache.james.jmap.method.{InvocationWithContext, MethodRequiringAccountId, WithAccountId} +import org.apache.james.jmap.routes.SessionSupplier +import org.apache.james.mailbox.MailboxSession +import org.apache.james.metrics.api.MetricFactory +import org.reactivestreams.Publisher +import play.api.libs.json.JsObject +import reactor.core.scala.publisher.SMono + +class CalendarEventAttendanceGetMethod @Inject()(val eventAttendanceRepository: EventAttendanceRepository, + val metricFactory: MetricFactory, + val sessionSupplier: SessionSupplier, + val sessionTranslator: SessionTranslator) extends MethodRequiringAccountId[CalendarEventAttendanceGetRequest] { + override val methodName: Invocation.MethodName = MethodName("CalendarEventAttendance/get") + + override val requiredCapabilities: Set[CapabilityIdentifier] = Set(JMAP_CORE, LINAGORA_CALENDAR) + + override def doProcess(capabilities: Set[CapabilityIdentifier], invocation: InvocationWithContext, mailboxSession: MailboxSession, request: CalendarEventAttendanceGetRequest): Publisher[InvocationWithContext] = + SMono.fromDirect(eventAttendanceRepository.getAttendanceStatus(mailboxSession.getUser, request.blobIds)) + .map(result => CalendarEventAttendanceGetResponse.from(request.accountId, result)) + .map(response => Invocation( + methodName, + Arguments(CalendarEventAttendanceSerializer.serializeEventAttendanceGetResponse(response).as[JsObject]), + invocation.invocation.methodCallId)) + .map(InvocationWithContext(_, invocation.processingContext)) + + override def getRequest(mailboxSession: MailboxSession, invocation: Invocation): Either[Exception, CalendarEventAttendanceGetRequest] = + CalendarEventAttendanceSerializer.deserializeEventAttendanceGetRequest(invocation.arguments.value) + .asEither.left.map(ResponseSerializer.asException) + .flatMap(_.validate()) +} + +object CalendarEventAttendanceGetRequest { + val MAXIMUM_NUMBER_OF_BLOB_IDS: Int = 16 +} + +case class CalendarEventAttendanceGetRequest(accountId: AccountId, + blobIds: BlobIds) extends WithAccountId { + def validate(): Either[Exception, CalendarEventAttendanceGetRequest] = + validateBlobIdsSize + + private def validateBlobIdsSize: Either[RequestTooLargeException, CalendarEventAttendanceGetRequest] = + if (blobIds.value.length > MAXIMUM_NUMBER_OF_BLOB_IDS) { + Left(RequestTooLargeException("The number of ids requested by the client exceeds the maximum number the server is willing to process in a single method call")) + } else { + scala.Right(this) + } +} + +object CalendarEventAttendanceGetResponse { + def from(accountId: AccountId, results: CalendarEventAttendanceResults): CalendarEventAttendanceGetResponse = { + val (accepted, rejected, tentativelyAccepted, needsAction) = + results.done.foldLeft((List.empty[BlobId], List.empty[BlobId], List.empty[BlobId], List.empty[BlobId])) { + case ((accepted, rejected, tentativelyAccepted, needsAction), entry) => + val refinedBlobId = refineV[IdConstraint](entry.blobId).fold( + _ => BlobId("invalid"), // Fallback for invalid values + validBlobId => BlobId(validBlobId) + ) + entry.eventAttendanceStatus match { + case AttendanceStatus.Accepted => (refinedBlobId :: accepted, rejected, tentativelyAccepted, needsAction) + case AttendanceStatus.Declined => (accepted, refinedBlobId :: rejected, tentativelyAccepted, needsAction) + case AttendanceStatus.Tentative => (accepted, rejected, refinedBlobId :: tentativelyAccepted, needsAction) + case AttendanceStatus.NeedsAction => (accepted, rejected, tentativelyAccepted, refinedBlobId :: needsAction) + } + } + + CalendarEventAttendanceGetResponse( + accountId, + accepted, + rejected, + tentativelyAccepted, + needsAction, + results.notFound, + results.notDone) + } +} + +case class CalendarEventAttendanceGetResponse(accountId: AccountId, + accepted: List[BlobId] = List(), + rejected: List[BlobId] = List(), + tentativelyAccepted: List[BlobId] = List(), + needsAction: List[BlobId] = List(), + notFound: Option[CalendarEventNotFound] = Option.empty, + notDone: Option[CalendarEventNotDone] = Option.empty) extends WithAccountId + +object CalendarEventAttendanceResults { + def merge(r1: CalendarEventAttendanceResults, r2: CalendarEventAttendanceResults): CalendarEventAttendanceResults = + CalendarEventAttendanceResults( + done = r1.done ++ r2.done, + notFound = r1.notFound.orElse(r2.notFound), + notDone = r1.notDone.orElse(r2.notDone)) + + def notFound(blobId: BlobId): CalendarEventAttendanceResults = CalendarEventAttendanceResults(notFound = Some(CalendarEventNotFound(Set(blobId.value)))) + + def empty: CalendarEventAttendanceResults = CalendarEventAttendanceResults() + + def done(eventAttendanceEntry: EventAttendanceStatusEntry): CalendarEventAttendanceResults = + CalendarEventAttendanceResults(List(eventAttendanceEntry)) + + def notDone(notParsable: CalendarEventNotParsable): CalendarEventAttendanceResults = + CalendarEventAttendanceResults(notDone = Some(CalendarEventNotDone(notParsable.asSetErrorMap))) + + def notDone(blobId: BlobId, throwable: Throwable, mailboxSession: MailboxSession): CalendarEventAttendanceResults = + CalendarEventAttendanceResults(notDone = Some(CalendarEventNotDone(Map(blobId.value -> asSetError(throwable, mailboxSession)))), done = List(), notFound = None) + + private def asSetError(throwable: Throwable, mailboxSession: MailboxSession): SetError = throwable match { + case _: InvalidCalendarFileException | _: IllegalArgumentException => + // LOGGER.info("Error when generate reply mail for {}: {}", mailboxSession.getUser.asString(), throwable.getMessage) + SetError.invalidPatch(SetErrorDescription(throwable.getMessage)) + case _ => + // LOGGER.error("serverFail to generate reply mail for {}", mailboxSession.getUser.asString(), throwable) + SetError.serverFail(SetErrorDescription(throwable.getMessage)) + } +} + +case class CalendarEventAttendanceResults(done: List[EventAttendanceStatusEntry] = List(), + notFound: Option[CalendarEventNotFound] = Option.empty, + notDone: Option[CalendarEventNotDone] = Option.empty) + +case class EventAttendanceStatusEntry(blobId: String, eventAttendanceStatus: AttendanceStatus) diff --git a/tmail-backend/jmap/extensions/src/main/scala/com/linagora/tmail/james/jmap/method/CalendarEventParseMethod.scala b/tmail-backend/jmap/extensions/src/main/scala/com/linagora/tmail/james/jmap/method/CalendarEventParseMethod.scala index 6e8ffdb89b..fda686d3a4 100644 --- a/tmail-backend/jmap/extensions/src/main/scala/com/linagora/tmail/james/jmap/method/CalendarEventParseMethod.scala +++ b/tmail-backend/jmap/extensions/src/main/scala/com/linagora/tmail/james/jmap/method/CalendarEventParseMethod.scala @@ -58,6 +58,9 @@ class CalendarEventMethodModule extends AbstractModule { Multibinder.newSetBinder(binder(), classOf[Method]) .addBinding() .to(classOf[CalendarEventMaybeMethod]) + Multibinder.newSetBinder(binder(), classOf[Method]) + .addBinding() + .to(classOf[CalendarEventAttendanceGetMethod]) bind(classOf[CalendarEventReplyPerformer]).in(Scopes.SINGLETON) bind(classOf[CalendarEventReplySupportedLanguage]).in(Scopes.SINGLETON) diff --git a/tmail-backend/jmap/extensions/src/test/scala/com/linagora/tmail/james/jmap/StandaloneEventAttendanceRepositoryTest.scala b/tmail-backend/jmap/extensions/src/test/scala/com/linagora/tmail/james/jmap/StandaloneEventAttendanceRepositoryTest.scala index 7cfbc0c704..9e3f02c61c 100644 --- a/tmail-backend/jmap/extensions/src/test/scala/com/linagora/tmail/james/jmap/StandaloneEventAttendanceRepositoryTest.scala +++ b/tmail-backend/jmap/extensions/src/test/scala/com/linagora/tmail/james/jmap/StandaloneEventAttendanceRepositoryTest.scala @@ -3,7 +3,7 @@ package com.linagora.tmail.james.jmap import java.util import java.util.Optional -import com.linagora.tmail.james.jmap.method.CalendarEventReplyPerformer +import com.linagora.tmail.james.jmap.method.{CalendarEventAttendanceResults, CalendarEventReplyPerformer, EventAttendanceStatusEntry} import com.linagora.tmail.james.jmap.model.CalendarEventReplyRequest import jakarta.mail.Flags import net.fortuna.ical4j.model.parameter.PartStat @@ -51,22 +51,30 @@ class StandaloneEventAttendanceRepositoryTest { def givenAcceptedFlagIsLinkedToMailGetAttendanceStatusShouldReturnAccepted(): Unit = { val flags: Flags = new Flags("$accepted") val messageId: MessageId = createMessage(flags) - assertThat(Mono.from(testee.getAttendanceStatus(mailbox.getUser, messageId)).block) - .isEqualTo(AttendanceStatus.Accepted) + val blobId = createFakeCalendaerEventBlobId(messageId) + val blobIds = BlobIds(Seq(blobId.value)) + assertThat(Mono.from(testee.getAttendanceStatus(mailbox.getUser, blobIds)).block()) + .isEqualTo(done(blobId, AttendanceStatus.Accepted)) } @Test def givenRejectedFlagIsLinkedToMailGetAttendanceStatusShouldReturnDeclined(): Unit = { val flags: Flags = new Flags("$rejected") val messageId: MessageId = createMessage(flags) - assertThat(Mono.from(testee.getAttendanceStatus(mailbox.getUser, messageId)).block).isEqualTo(AttendanceStatus.Declined) + val blobId = createFakeCalendaerEventBlobId(messageId) + val blobIds = BlobIds(Seq(blobId.value)) + assertThat(Mono.from(testee.getAttendanceStatus(mailbox.getUser, blobIds)).block()) + .isEqualTo(done(blobId, AttendanceStatus.Declined)) } @Test def givenTentativelyAcceptedFlagIsLinkedToMailGetAttendanceStatusShouldReturnTentative(): Unit = { val flags: Flags = new Flags("$tentativelyaccepted") val messageId: MessageId = createMessage(flags) - assertThat(Mono.from(testee.getAttendanceStatus(mailbox.getUser, messageId)).block).isEqualTo(AttendanceStatus.Tentative) + val blobId = createFakeCalendaerEventBlobId(messageId) + val blobIds = BlobIds(Seq(blobId.value)) + assertThat(Mono.from(testee.getAttendanceStatus(mailbox.getUser, blobIds)).block()) + .isEqualTo(done(blobId, AttendanceStatus.Tentative)) } // It should also print a warning message @@ -75,24 +83,22 @@ class StandaloneEventAttendanceRepositoryTest { val flags: Flags = new Flags("$rejected") flags.add("$accepted") val messageId: MessageId = createMessage(flags) + val blobId = createFakeCalendaerEventBlobId(messageId) + val blobIds = BlobIds(Seq(blobId.value)) - - assertThat(util.List.of(AttendanceStatus.Accepted, AttendanceStatus.Declined)) - .contains(Mono.from(testee.getAttendanceStatus(mailbox.getUser, messageId)).block) + assertThat(util.List.of((blobId, AttendanceStatus.Accepted), done(blobId, AttendanceStatus.Declined)) + .contains(Mono.from(testee.getAttendanceStatus(mailbox.getUser, blobIds)).block())) } @Test def getAttendanceStatusShouldFallbackToNeedsActionWhenNoFlagIsLinkedToMail(): Unit = { val flags: Flags = new Flags val messageId: MessageId = createMessage(flags) - assertThat(Mono.from(testee.getAttendanceStatus(mailbox.getUser, messageId)).block).isEqualTo(AttendanceStatus.NeedsAction) - } + val blobId = createFakeCalendaerEventBlobId(messageId) + val blobIds = BlobIds(Seq(blobId.value)) - @Test - def getAttendanceStatusShouldFallbackToNeedsActionWhenNoEventAttendanceFlagIsLinkedToMail(): Unit = { - val flags: Flags = new Flags(Flags.Flag.RECENT) - val messageId: MessageId = createMessage(flags) - assertThat(Mono.from(testee.getAttendanceStatus(mailbox.getUser, messageId)).block).isEqualTo(AttendanceStatus.NeedsAction) + assertThat(Mono.from(testee.getAttendanceStatus(mailbox.getUser, blobIds)).block()) + .isEqualTo(done(blobId, AttendanceStatus.NeedsAction)) } @Test @@ -199,4 +205,12 @@ class StandaloneEventAttendanceRepositoryTest { .build() new MessageIdManagerTestSystem(resources.getMessageIdManager, messageIdFactory, resources.getMailboxManager.getMapperFactory, resources.getMailboxManager) } + + private def done(blobId: BlobId, attendanceStatus: AttendanceStatus) = + CalendarEventAttendanceResults.done( + EventAttendanceStatusEntry( + blobId.value.value, + attendanceStatus + ) + ) } From a9ac2b817d63f729d936b69b5ced910d9b58a5e5 Mon Sep 17 00:00:00 2001 From: HoussemNasri Date: Wed, 8 Jan 2025 14:50:41 +0100 Subject: [PATCH 2/8] Add a few integration tests --- ...ndarEventAttendanceGetMethodContract.scala | 308 ++++++++++++++++++ ...aCalendarEventAttendanceGetMethodTest.java | 34 ++ 2 files changed, 342 insertions(+) create mode 100644 tmail-backend/integration-tests/jmap/jmap-integration-tests-common/src/main/scala/com/linagora/tmail/james/common/LinagoraCalendarEventAttendanceGetMethodContract.scala create mode 100644 tmail-backend/integration-tests/jmap/memory-jmap-integration-tests/src/test/java/com/linagora/tmail/james/MemoryLinagoraCalendarEventAttendanceGetMethodTest.java diff --git a/tmail-backend/integration-tests/jmap/jmap-integration-tests-common/src/main/scala/com/linagora/tmail/james/common/LinagoraCalendarEventAttendanceGetMethodContract.scala b/tmail-backend/integration-tests/jmap/jmap-integration-tests-common/src/main/scala/com/linagora/tmail/james/common/LinagoraCalendarEventAttendanceGetMethodContract.scala new file mode 100644 index 0000000000..7973cf9afd --- /dev/null +++ b/tmail-backend/integration-tests/jmap/jmap-integration-tests-common/src/main/scala/com/linagora/tmail/james/common/LinagoraCalendarEventAttendanceGetMethodContract.scala @@ -0,0 +1,308 @@ +package com.linagora.tmail.james.common + +import com.linagora.tmail.james.common.LinagoraCalendarEventMethodContractUtilities.sendInvitationEmailToBobAndGetIcsBlobIds +import io.netty.handler.codec.http.HttpHeaderNames.ACCEPT +import io.restassured.RestAssured.{`given`, requestSpecification} +import io.restassured.http.ContentType.JSON +import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson +import net.javacrumbs.jsonunit.core.Option.IGNORING_ARRAY_ORDER +import org.apache.http.HttpStatus.SC_OK +import org.apache.james.GuiceJamesServer +import org.apache.james.jmap.http.UserCredential +import org.apache.james.jmap.rfc8621.contract.Fixture._ +import org.apache.james.mailbox.model.MailboxPath +import org.apache.james.modules.MailboxProbeImpl +import org.apache.james.utils.DataProbeImpl +import org.junit.jupiter.api.{BeforeEach, Test} + +trait LinagoraCalendarEventAttendanceGetMethodContract { + + @BeforeEach + def setUp(server: GuiceJamesServer): Unit = { + server.getProbe(classOf[DataProbeImpl]) + .fluent + .addDomain(_2_DOT_DOMAIN.asString) + .addDomain(DOMAIN.asString) + .addUser(BOB.asString, BOB_PASSWORD) + .addUser(ALICE.asString, ALICE_PASSWORD) + + requestSpecification = baseRequestSpecBuilder(server) + .setAuth(authScheme(UserCredential(BOB, BOB_PASSWORD))) + .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .build + + server.getProbe(classOf[MailboxProbeImpl]).createMailbox(MailboxPath.inbox(BOB)) + } + + @Test + def shouldReturnAccepted(server: GuiceJamesServer): Unit = { + val blobId: String = + sendInvitationEmailToBobAndGetIcsBlobIds(server, "emailWithAliceInviteBobIcsAttachment.eml", icsPartId = "3") + + acceptInvitation(blobId) + + val request: String = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "com:linagora:params:calendar:event"], + | "methodCalls": [[ + | "CalendarEventAttendance/get", + | { + | "accountId": "$ACCOUNT_ID", + | "blobIds": [ "$blobId" ] + | }, + | "c1"]] + |}""".stripMargin + + val response = + `given` + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0]") + .isEqualTo( + s"""|[ + | "CalendarEventAttendance/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "accepted": ["$blobId"], + | "rejected": [], + | "tentativelyAccepted": [], + | "needsAction": [] + | }, + | "c1" + |]""".stripMargin + ) + } + + @Test + def shouldReturnRejected(server: GuiceJamesServer): Unit = { + val blobId: String = + sendInvitationEmailToBobAndGetIcsBlobIds(server, "emailWithAliceInviteBobIcsAttachment.eml", icsPartId = "3") + + rejectInvitation(blobId) + + val request: String = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "com:linagora:params:calendar:event"], + | "methodCalls": [[ + | "CalendarEventAttendance/get", + | { + | "accountId": "$ACCOUNT_ID", + | "blobIds": [ "$blobId" ] + | }, + | "c1"]] + |}""".stripMargin + + val response = + `given` + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0]") + .isEqualTo( + s"""|[ + | "CalendarEventAttendance/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "accepted": [], + | "rejected": ["$blobId"], + | "tentativelyAccepted": [], + | "needsAction": [] + | }, + | "c1" + |]""".stripMargin + ) + } + + @Test + def shouldReturnMaybe(server: GuiceJamesServer): Unit = { + val blobId: String = + sendInvitationEmailToBobAndGetIcsBlobIds(server, "emailWithAliceInviteBobIcsAttachment.eml", icsPartId = "3") + + maybeInvitation(blobId) + + val request: String = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "com:linagora:params:calendar:event"], + | "methodCalls": [[ + | "CalendarEventAttendance/get", + | { + | "accountId": "$ACCOUNT_ID", + | "blobIds": [ "$blobId" ] + | }, + | "c1"]] + |}""".stripMargin + + val response = + `given` + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0]") + .isEqualTo( + s"""|[ + | "CalendarEventAttendance/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "accepted": [], + | "rejected": [], + | "tentativelyAccepted": ["$blobId"], + | "needsAction": [] + | }, + | "c1" + |]""".stripMargin + ) + } + + @Test + def shouldReturnNeedsActionByDefault(server: GuiceJamesServer): Unit = { + val blobId: String = + sendInvitationEmailToBobAndGetIcsBlobIds(server, "emailWithAliceInviteBobIcsAttachment.eml", icsPartId = "3") + + val request: String = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "com:linagora:params:calendar:event"], + | "methodCalls": [[ + | "CalendarEventAttendance/get", + | { + | "accountId": "$ACCOUNT_ID", + | "blobIds": [ "$blobId" ] + | }, + | "c1"]] + |}""".stripMargin + + val response = + `given` + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0]") + .isEqualTo( + s"""|[ + | "CalendarEventAttendance/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "accepted": [], + | "rejected": [], + | "tentativelyAccepted": [], + | "needsAction": ["$blobId"] + | }, + | "c1" + |]""".stripMargin + ) + } + + private def acceptInvitation(blobId: String) = { + val request: String = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "com:linagora:params:calendar:event"], + | "methodCalls": [[ + | "CalendarEvent/accept", + | { + | "accountId": "$ACCOUNT_ID", + | "blobIds": [ "$blobId" ] + | }, + | "c1"]] + |}""".stripMargin + + `given` + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + } + + private def rejectInvitation(blobId: String) = { + val request: String = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "com:linagora:params:calendar:event"], + | "methodCalls": [[ + | "CalendarEvent/reject", + | { + | "accountId": "$ACCOUNT_ID", + | "blobIds": [ "$blobId" ] + | }, + | "c1"]] + |}""".stripMargin + + `given` + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + } + + + private def maybeInvitation(blobId: String) = { + val request: String = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "com:linagora:params:calendar:event"], + | "methodCalls": [[ + | "CalendarEvent/maybe", + | { + | "accountId": "$ACCOUNT_ID", + | "blobIds": [ "$blobId" ] + | }, + | "c1"]] + |}""".stripMargin + + `given` + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + } + +} diff --git a/tmail-backend/integration-tests/jmap/memory-jmap-integration-tests/src/test/java/com/linagora/tmail/james/MemoryLinagoraCalendarEventAttendanceGetMethodTest.java b/tmail-backend/integration-tests/jmap/memory-jmap-integration-tests/src/test/java/com/linagora/tmail/james/MemoryLinagoraCalendarEventAttendanceGetMethodTest.java new file mode 100644 index 0000000000..fde1a429d5 --- /dev/null +++ b/tmail-backend/integration-tests/jmap/memory-jmap-integration-tests/src/test/java/com/linagora/tmail/james/MemoryLinagoraCalendarEventAttendanceGetMethodTest.java @@ -0,0 +1,34 @@ +package com.linagora.tmail.james; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; + +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.jmap.rfc8621.contract.probe.DelegationProbeModule; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.linagora.tmail.james.app.MemoryConfiguration; +import com.linagora.tmail.james.app.MemoryServer; +import com.linagora.tmail.james.common.LinagoraCalendarEventAcceptMethodContract; +import com.linagora.tmail.james.common.LinagoraCalendarEventAttendanceGetMethodContract; +import com.linagora.tmail.james.jmap.firebase.FirebaseModuleChooserConfiguration; +import com.linagora.tmail.module.LinagoraTestJMAPServerModule; + +import java.util.UUID; + +public class MemoryLinagoraCalendarEventAttendanceGetMethodTest implements + LinagoraCalendarEventAttendanceGetMethodContract { + + @RegisterExtension + static JamesServerExtension + jamesServerExtension = new JamesServerBuilder(tmpDir -> + MemoryConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .usersRepository(DEFAULT) + .firebaseModuleChooserConfiguration(FirebaseModuleChooserConfiguration.DISABLED) + .build()) + .server(configuration -> MemoryServer.createServer(configuration) + .overrideWith(new LinagoraTestJMAPServerModule(), new DelegationProbeModule())) + .build(); +} From 10357fbc00db9c7b962a488a370b084241c7dd9a Mon Sep 17 00:00:00 2001 From: HoussemNasri Date: Thu, 9 Jan 2025 14:03:46 +0100 Subject: [PATCH 3/8] Add more integration tests --- ...ndarEventAttendanceGetMethodContract.scala | 284 +++++++++++++++++- 1 file changed, 277 insertions(+), 7 deletions(-) diff --git a/tmail-backend/integration-tests/jmap/jmap-integration-tests-common/src/main/scala/com/linagora/tmail/james/common/LinagoraCalendarEventAttendanceGetMethodContract.scala b/tmail-backend/integration-tests/jmap/jmap-integration-tests-common/src/main/scala/com/linagora/tmail/james/common/LinagoraCalendarEventAttendanceGetMethodContract.scala index 7973cf9afd..df7e877e5e 100644 --- a/tmail-backend/integration-tests/jmap/jmap-integration-tests-common/src/main/scala/com/linagora/tmail/james/common/LinagoraCalendarEventAttendanceGetMethodContract.scala +++ b/tmail-backend/integration-tests/jmap/jmap-integration-tests-common/src/main/scala/com/linagora/tmail/james/common/LinagoraCalendarEventAttendanceGetMethodContract.scala @@ -4,8 +4,8 @@ import com.linagora.tmail.james.common.LinagoraCalendarEventMethodContractUtilit import io.netty.handler.codec.http.HttpHeaderNames.ACCEPT import io.restassured.RestAssured.{`given`, requestSpecification} import io.restassured.http.ContentType.JSON +import io.restassured.specification.RequestSpecification import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson -import net.javacrumbs.jsonunit.core.Option.IGNORING_ARRAY_ORDER import org.apache.http.HttpStatus.SC_OK import org.apache.james.GuiceJamesServer import org.apache.james.jmap.http.UserCredential @@ -13,7 +13,8 @@ import org.apache.james.jmap.rfc8621.contract.Fixture._ import org.apache.james.mailbox.model.MailboxPath import org.apache.james.modules.MailboxProbeImpl import org.apache.james.utils.DataProbeImpl -import org.junit.jupiter.api.{BeforeEach, Test} +import org.junit.jupiter.api.{BeforeEach, Disabled, Test} +import play.api.libs.json.Json trait LinagoraCalendarEventAttendanceGetMethodContract { @@ -25,6 +26,7 @@ trait LinagoraCalendarEventAttendanceGetMethodContract { .addDomain(DOMAIN.asString) .addUser(BOB.asString, BOB_PASSWORD) .addUser(ALICE.asString, ALICE_PASSWORD) + .addUser(ANDRE.asString, ANDRE_PASSWORD) requestSpecification = baseRequestSpecBuilder(server) .setAuth(authScheme(UserCredential(BOB, BOB_PASSWORD))) @@ -180,8 +182,7 @@ trait LinagoraCalendarEventAttendanceGetMethodContract { | "needsAction": [] | }, | "c1" - |]""".stripMargin - ) + |]""".stripMargin) } @Test @@ -228,8 +229,272 @@ trait LinagoraCalendarEventAttendanceGetMethodContract { | "needsAction": ["$blobId"] | }, | "c1" - |]""".stripMargin - ) + |]""".stripMargin) + } + + @Test + def shouldFailWhenNumberOfBlobIdsTooLarge(): Unit = { + val blobIds: Array[String] = Range.inclusive(1, 999) + .map(_ + "") + .toArray + val blobIdsJson: String = Json.stringify(Json.arr(blobIds)).replace("[[", "[").replace("]]", "]") + val request: String = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "com:linagora:params:calendar:event"], + | "methodCalls": [[ + | "CalendarEventAttendance/get", + | { + | "accountId": "$ACCOUNT_ID", + | "blobIds": $blobIdsJson + | }, + | "c1"]] + |}""".stripMargin + + val response: String = + `given` + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0]") + .isEqualTo( + s"""[ + | "error", + | { + | "type": "requestTooLarge", + | "description": "The number of ids requested by the client exceeds the maximum number the server is willing to process in a single method call" + | }, + | "c1" + |]""".stripMargin) + } + + @Test + def shouldFailWhenWrongAccountId(): Unit = { + val request: String = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "com:linagora:params:calendar:event"], + | "methodCalls": [[ + | "CalendarEventAttendance/get", + | { + | "accountId": "unknownAccountId", + | "blobIds": [ "0f9f65ab-dc7b-4146-850f-6e4881093965" ] + | }, + | "c1"]] + |}""".stripMargin + + val response = + `given` + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0]") + .isEqualTo( + s"""[ + | "error", + | { + | "type": "accountNotFound" + | }, + | "c1" + |]""".stripMargin) + } + + @Disabled("A system session is used to fetch message flags from user inbox which does not seem to handle user mailbox isolation.") + @Test + def shouldNotFoundWhenDoesNotHavePermission(server: GuiceJamesServer): Unit = { + val blobId: String = + sendInvitationEmailToBobAndGetIcsBlobIds(server, "emailWithAliceInviteBobIcsAttachment.eml", icsPartId = "3") + + val request: String = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "com:linagora:params:calendar:event"], + | "methodCalls": [[ + | "CalendarEventAttendance/get", + | { + | "accountId": "$ANDRE_ACCOUNT_ID", + | "blobIds": [ "$blobId" ] + | }, + | "c1"]] + |}""".stripMargin + + val response = + `given`(buildAndreRequestSpecification(server)) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0]") + .isEqualTo( + s"""|[ + | "CalendarEventAttendance/get", + | { + | "accountId": "$ANDRE_ACCOUNT_ID", + | "accepted": [], + | "rejected": [], + | "tentativelyAccepted": [], + | "needsAction": [], + | "notFound": ["$blobId"] + | }, + | "c1" + |]""".stripMargin) + } + + @Test + def shouldReturnUnknownMethodWhenMissingOneCapability(): Unit = { + val request: String = + s"""{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [[ + | "CalendarEventAttendance/get", + | { + | "accountId": "$ACCOUNT_ID", + | "blobIds": [ "123" ] + | }, + | "c1"]] + |}""".stripMargin + + val response = + `given` + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0]") + .isEqualTo( + s"""[ + | "error", + | { + | "type": "unknownMethod", + | "description": "Missing capability(ies): com:linagora:params:calendar:event" + | }, + | "c1"]""".stripMargin) + } + + @Test + def shouldReturnUnknownMethodWhenMissingAllCapabilities(): Unit = { + val request: String = + s"""{ + | "using": [], + | "methodCalls": [[ + | "CalendarEventAttendance/get", + | { + | "accountId": "$ACCOUNT_ID", + | "blobIds": [ "123" ] + | }, + | "c1"]] + |}""".stripMargin + + val response = + `given` + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0]") + .isEqualTo( + s"""[ + | "error", + | { + | "type": "unknownMethod", + | "description": "Missing capability(ies): urn:ietf:params:jmap:core, com:linagora:params:calendar:event" + | }, + | "c1"]""".stripMargin) + } + + @Test + def shouldSucceedWhenMixSeveralCases(server: GuiceJamesServer): Unit = { + val acceptedEventBlobId: String = + sendInvitationEmailToBobAndGetIcsBlobIds(server, "emailWithAliceInviteBobIcsAttachment.eml", icsPartId = "3") + acceptInvitation(acceptedEventBlobId) + + val rejectedEventBlobId: String = + sendInvitationEmailToBobAndGetIcsBlobIds(server, "emailWithAliceInviteBobIcsAttachment.eml", icsPartId = "3") + rejectInvitation(rejectedEventBlobId) + + val needsActionEventBlobId: String = + sendInvitationEmailToBobAndGetIcsBlobIds(server, "emailWithAliceInviteBobIcsAttachment.eml", icsPartId = "3") + + val request: String = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "com:linagora:params:calendar:event"], + | "methodCalls": [[ + | "CalendarEventAttendance/get", + | { + | "accountId": "$ACCOUNT_ID", + | "blobIds": [ "$acceptedEventBlobId", "$rejectedEventBlobId", "$needsActionEventBlobId" ] + | }, + | "c1"]] + |}""".stripMargin + + val response = + `given` + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + println(response) + assertThatJson(response) + .inPath("methodResponses[0]") + .isEqualTo( + s"""|[ + | "CalendarEventAttendance/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "accepted": ["$acceptedEventBlobId"], + | "rejected": ["$rejectedEventBlobId"], + | "tentativelyAccepted": [], + | "needsAction": ["$needsActionEventBlobId"] + | }, + | "c1" + |]""".stripMargin) } private def acceptInvitation(blobId: String) = { @@ -280,7 +545,6 @@ trait LinagoraCalendarEventAttendanceGetMethodContract { .contentType(JSON) } - private def maybeInvitation(blobId: String) = { val request: String = s"""{ @@ -305,4 +569,10 @@ trait LinagoraCalendarEventAttendanceGetMethodContract { .contentType(JSON) } + private def buildAndreRequestSpecification(server: GuiceJamesServer): RequestSpecification = + baseRequestSpecBuilder(server) + .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD))) + .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .build + } From b0a0954080fe09d22a75e4ec8524fbf82c945557 Mon Sep 17 00:00:00 2001 From: HoussemNasri Date: Thu, 9 Jan 2025 17:34:05 +0100 Subject: [PATCH 4/8] Return needsAction when mail message accessible but no flags attached --- .../StandaloneEventAttendanceRepository.java | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/tmail-backend/jmap/extensions/src/main/java/com/linagora/tmail/james/jmap/StandaloneEventAttendanceRepository.java b/tmail-backend/jmap/extensions/src/main/java/com/linagora/tmail/james/jmap/StandaloneEventAttendanceRepository.java index 05b50103d4..f32ff9dff6 100644 --- a/tmail-backend/jmap/extensions/src/main/java/com/linagora/tmail/james/jmap/StandaloneEventAttendanceRepository.java +++ b/tmail-backend/jmap/extensions/src/main/java/com/linagora/tmail/james/jmap/StandaloneEventAttendanceRepository.java @@ -62,26 +62,34 @@ public Publisher getAttendanceStatus(Username us .reduce(CalendarEventAttendanceResults$.MODULE$.empty(), CalendarEventAttendanceResults$.MODULE$::merge); } - private Flux getAttendanceStatusFromEventBlob(String blobId, MailboxSession systemMailboxSession) { + private Mono getAttendanceStatusFromEventBlob(String blobId, MailboxSession systemMailboxSession) { return extractMessageId(blobId) - .flatMapMany(messageId -> - getFlags(messageId, systemMailboxSession) - .flatMap(userFlags -> Mono.justOrEmpty(AttendanceStatus.fromMessageFlags(userFlags))) - .switchIfEmpty(handleMissingEventAttendanceFlag(messageId))) - .map(attendanceStatus -> - CalendarEventAttendanceResults$.MODULE$.done( - new EventAttendanceStatusEntry(blobId, attendanceStatus))) + .flatMap(messageId -> + Mono.fromDirect(messageIdManager.getMessagesReactive(List.of(messageId), FetchGroup.MINIMAL, systemMailboxSession)) + .flatMap(messageResult -> getAttendanceStatusFromMessage(messageResult, blobId)) + .defaultIfEmpty(CalendarEventAttendanceResults$.MODULE$.notFound(BlobId.of(blobId).get()))) .onErrorResume(Exception.class, (error) -> Mono.just(CalendarEventAttendanceResults$.MODULE$.notDone( BlobId.of(blobId).get(), error, systemMailboxSession))); } - private Mono handleMissingEventAttendanceFlag(MessageId messageId) { + private Mono getAttendanceStatusFromMessage(MessageResult messageResult, String blobId) { + return Mono.just(messageResult.getFlags()) + .flatMap(userFlags -> + Mono.justOrEmpty(AttendanceStatus.fromMessageFlags(userFlags)) + .map(attendanceStatus -> + CalendarEventAttendanceResults$.MODULE$.done( + new EventAttendanceStatusEntry(blobId, attendanceStatus)))) + .defaultIfEmpty(handleMissingEventAttendanceFlag(blobId)); + } + + private CalendarEventAttendanceResults handleMissingEventAttendanceFlag(String blobId) { LOGGER.debug(""" - No event attendance flag found for message {}. + No event attendance flag found for blob: {}. Defaulting to NeedsAction - """, messageId); - return Mono.just(AttendanceStatus.NeedsAction); + """, blobId); + return CalendarEventAttendanceResults$.MODULE$.done( + new EventAttendanceStatusEntry(blobId, AttendanceStatus.NeedsAction)); } @Override @@ -161,9 +169,4 @@ private Flux updateEventAttendanceFlags(MessageResult message, AttendanceS session) ); } - - private Flux getFlags(MessageId messageId, MailboxSession session) { - return Flux.from(messageIdManager.getMessagesReactive(List.of(messageId), FetchGroup.MINIMAL, session)) - .map(MessageResult::getFlags); - } } From cbb7e2527d80a27a1ac42d15a1d27c1a072ba3c7 Mon Sep 17 00:00:00 2001 From: HoussemNasri Date: Thu, 9 Jan 2025 17:35:24 +0100 Subject: [PATCH 5/8] Fix failing tests --- ...ndarEventAttendanceGetMethodContract.scala | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/tmail-backend/integration-tests/jmap/jmap-integration-tests-common/src/main/scala/com/linagora/tmail/james/common/LinagoraCalendarEventAttendanceGetMethodContract.scala b/tmail-backend/integration-tests/jmap/jmap-integration-tests-common/src/main/scala/com/linagora/tmail/james/common/LinagoraCalendarEventAttendanceGetMethodContract.scala index df7e877e5e..b82809cdaa 100644 --- a/tmail-backend/integration-tests/jmap/jmap-integration-tests-common/src/main/scala/com/linagora/tmail/james/common/LinagoraCalendarEventAttendanceGetMethodContract.scala +++ b/tmail-backend/integration-tests/jmap/jmap-integration-tests-common/src/main/scala/com/linagora/tmail/james/common/LinagoraCalendarEventAttendanceGetMethodContract.scala @@ -6,6 +6,7 @@ import io.restassured.RestAssured.{`given`, requestSpecification} import io.restassured.http.ContentType.JSON import io.restassured.specification.RequestSpecification import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson +import net.javacrumbs.jsonunit.core.Option import org.apache.http.HttpStatus.SC_OK import org.apache.james.GuiceJamesServer import org.apache.james.jmap.http.UserCredential @@ -13,7 +14,7 @@ import org.apache.james.jmap.rfc8621.contract.Fixture._ import org.apache.james.mailbox.model.MailboxPath import org.apache.james.modules.MailboxProbeImpl import org.apache.james.utils.DataProbeImpl -import org.junit.jupiter.api.{BeforeEach, Disabled, Test} +import org.junit.jupiter.api.{BeforeEach, Test} import play.api.libs.json.Json trait LinagoraCalendarEventAttendanceGetMethodContract { @@ -186,7 +187,7 @@ trait LinagoraCalendarEventAttendanceGetMethodContract { } @Test - def shouldReturnNeedsActionByDefault(server: GuiceJamesServer): Unit = { + def shouldReturnNeedsActionWhenNoEventAttendanceFlagAttachedToMail(server: GuiceJamesServer): Unit = { val blobId: String = sendInvitationEmailToBobAndGetIcsBlobIds(server, "emailWithAliceInviteBobIcsAttachment.eml", icsPartId = "3") @@ -234,10 +235,10 @@ trait LinagoraCalendarEventAttendanceGetMethodContract { @Test def shouldFailWhenNumberOfBlobIdsTooLarge(): Unit = { - val blobIds: Array[String] = Range.inclusive(1, 999) + val blobIds: List[String] = Range.inclusive(1, 999) .map(_ + "") - .toArray - val blobIdsJson: String = Json.stringify(Json.arr(blobIds)).replace("[[", "[").replace("]]", "]") + .toList + val blobIdsJson = blobIdsAsJson(blobIds) val request: String = s"""{ | "using": [ @@ -317,7 +318,6 @@ trait LinagoraCalendarEventAttendanceGetMethodContract { |]""".stripMargin) } - @Disabled("A system session is used to fetch message flags from user inbox which does not seem to handle user mailbox isolation.") @Test def shouldNotFoundWhenDoesNotHavePermission(server: GuiceJamesServer): Unit = { val blobId: String = @@ -452,9 +452,15 @@ trait LinagoraCalendarEventAttendanceGetMethodContract { sendInvitationEmailToBobAndGetIcsBlobIds(server, "emailWithAliceInviteBobIcsAttachment.eml", icsPartId = "3") rejectInvitation(rejectedEventBlobId) + val rejectedEventBlobId2: String = + sendInvitationEmailToBobAndGetIcsBlobIds(server, "emailWithAliceInviteBobIcsAttachment.eml", icsPartId = "3") + rejectInvitation(rejectedEventBlobId2) + val needsActionEventBlobId: String = sendInvitationEmailToBobAndGetIcsBlobIds(server, "emailWithAliceInviteBobIcsAttachment.eml", icsPartId = "3") + val notFoundBlobId = "99999_99999" + val request: String = s"""{ | "using": [ @@ -464,7 +470,7 @@ trait LinagoraCalendarEventAttendanceGetMethodContract { | "CalendarEventAttendance/get", | { | "accountId": "$ACCOUNT_ID", - | "blobIds": [ "$acceptedEventBlobId", "$rejectedEventBlobId", "$needsActionEventBlobId" ] + | "blobIds": [ "$acceptedEventBlobId", "$rejectedEventBlobId", "$needsActionEventBlobId", "$rejectedEventBlobId2", "$notFoundBlobId" ] | }, | "c1"]] |}""".stripMargin @@ -480,8 +486,9 @@ trait LinagoraCalendarEventAttendanceGetMethodContract { .extract .body .asString - println(response) + assertThatJson(response) + .when(Option.IGNORING_ARRAY_ORDER) .inPath("methodResponses[0]") .isEqualTo( s"""|[ @@ -489,9 +496,10 @@ trait LinagoraCalendarEventAttendanceGetMethodContract { | { | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", | "accepted": ["$acceptedEventBlobId"], - | "rejected": ["$rejectedEventBlobId"], + | "rejected": ["$rejectedEventBlobId", "$rejectedEventBlobId2"], | "tentativelyAccepted": [], - | "needsAction": ["$needsActionEventBlobId"] + | "needsAction": ["$needsActionEventBlobId"], + | "notFound": ["$notFoundBlobId"] | }, | "c1" |]""".stripMargin) @@ -569,6 +577,9 @@ trait LinagoraCalendarEventAttendanceGetMethodContract { .contentType(JSON) } + private def blobIdsAsJson(blobIds: List[String]) : String = + Json.stringify(Json.arr(blobIds)).replace("[[", "[").replace("]]", "]") + private def buildAndreRequestSpecification(server: GuiceJamesServer): RequestSpecification = baseRequestSpecBuilder(server) .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD))) From 8e97b5a7842e4c85f2e488af560b9c068a864f55 Mon Sep 17 00:00:00 2001 From: HoussemNasri Date: Thu, 9 Jan 2025 17:46:49 +0100 Subject: [PATCH 6/8] Test behavior on delegation --- ...ndarEventAttendanceGetMethodContract.scala | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tmail-backend/integration-tests/jmap/jmap-integration-tests-common/src/main/scala/com/linagora/tmail/james/common/LinagoraCalendarEventAttendanceGetMethodContract.scala b/tmail-backend/integration-tests/jmap/jmap-integration-tests-common/src/main/scala/com/linagora/tmail/james/common/LinagoraCalendarEventAttendanceGetMethodContract.scala index b82809cdaa..a931481097 100644 --- a/tmail-backend/integration-tests/jmap/jmap-integration-tests-common/src/main/scala/com/linagora/tmail/james/common/LinagoraCalendarEventAttendanceGetMethodContract.scala +++ b/tmail-backend/integration-tests/jmap/jmap-integration-tests-common/src/main/scala/com/linagora/tmail/james/common/LinagoraCalendarEventAttendanceGetMethodContract.scala @@ -7,10 +7,12 @@ import io.restassured.http.ContentType.JSON import io.restassured.specification.RequestSpecification import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson import net.javacrumbs.jsonunit.core.Option +import net.javacrumbs.jsonunit.core.Option.IGNORING_ARRAY_ORDER import org.apache.http.HttpStatus.SC_OK import org.apache.james.GuiceJamesServer import org.apache.james.jmap.http.UserCredential import org.apache.james.jmap.rfc8621.contract.Fixture._ +import org.apache.james.jmap.rfc8621.contract.probe.DelegationProbe import org.apache.james.mailbox.model.MailboxPath import org.apache.james.modules.MailboxProbeImpl import org.apache.james.utils.DataProbeImpl @@ -505,6 +507,59 @@ trait LinagoraCalendarEventAttendanceGetMethodContract { |]""".stripMargin) } + @Test + def shouldSucceedWhenDelegated(server: GuiceJamesServer): Unit = { + server.getProbe(classOf[DelegationProbe]).addAuthorizedUser(BOB, ANDRE) + + val blobId: String = + sendInvitationEmailToBobAndGetIcsBlobIds(server, "emailWithAliceInviteBobIcsAttachment.eml", icsPartId = "3") + + acceptInvitation(blobId) + + val bobAccountId = ACCOUNT_ID + val request: String = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "com:linagora:params:calendar:event"], + | "methodCalls": [[ + | "CalendarEventAttendance/get", + | { + | "accountId": "$bobAccountId", + | "blobIds": [ "$blobId" ] + | }, + | "c1"]] + |}""".stripMargin + + val response = + `given`(buildAndreRequestSpecification(server)) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .withOptions(IGNORING_ARRAY_ORDER) + .inPath("methodResponses[0]") + .isEqualTo( + s"""[ + | "CalendarEventAttendance/get", + | { + | "accountId": "$ACCOUNT_ID", + | "accepted": ["$blobId"], + | "rejected": [], + | "tentativelyAccepted": [], + | "needsAction": [] + | }, + | "c1" + |]""".stripMargin) + } + private def acceptInvitation(blobId: String) = { val request: String = s"""{ From e890ebbfdcf2a71ecda49e5095c54f9402fa6499 Mon Sep 17 00:00:00 2001 From: HoussemNasri Date: Thu, 9 Jan 2025 17:48:05 +0100 Subject: [PATCH 7/8] Fix formatting --- .../james/jmap/StandaloneEventAttendanceRepositoryTest.scala | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tmail-backend/jmap/extensions/src/test/scala/com/linagora/tmail/james/jmap/StandaloneEventAttendanceRepositoryTest.scala b/tmail-backend/jmap/extensions/src/test/scala/com/linagora/tmail/james/jmap/StandaloneEventAttendanceRepositoryTest.scala index 9e3f02c61c..f2c38b0503 100644 --- a/tmail-backend/jmap/extensions/src/test/scala/com/linagora/tmail/james/jmap/StandaloneEventAttendanceRepositoryTest.scala +++ b/tmail-backend/jmap/extensions/src/test/scala/com/linagora/tmail/james/jmap/StandaloneEventAttendanceRepositoryTest.scala @@ -210,7 +210,5 @@ class StandaloneEventAttendanceRepositoryTest { CalendarEventAttendanceResults.done( EventAttendanceStatusEntry( blobId.value.value, - attendanceStatus - ) - ) + attendanceStatus)) } From ecd7ac4fa62b8170110ed34f2b64ec1f3fdc8c61 Mon Sep 17 00:00:00 2001 From: HoussemNasri Date: Fri, 10 Jan 2025 13:54:00 +0100 Subject: [PATCH 8/8] Document CalendarEventAttendance/get --- .../jmap-extensions/calendarEventReply.adoc | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/docs/modules/ROOT/pages/tmail-backend/jmap-extensions/calendarEventReply.adoc b/docs/modules/ROOT/pages/tmail-backend/jmap-extensions/calendarEventReply.adoc index e34bb8614d..cebec076f2 100644 --- a/docs/modules/ROOT/pages/tmail-backend/jmap-extensions/calendarEventReply.adoc +++ b/docs/modules/ROOT/pages/tmail-backend/jmap-extensions/calendarEventReply.adoc @@ -96,3 +96,102 @@ However, in the response properties, 'rejected' replace 'accepted', while 'notRe == CalendarEvent/maybe Similarly to CalendarEvent/accept, CalendarEvent/maybe function in a similar manner. However, in the response properties, 'maybe' replace 'accepted', while 'notMaybe' replace 'notAccepted'. + +== CalendarEventAttendance/get +This method allow clients to get the attendance status of a calendar event invitation. +The CalendarEventAttendance/get method takes the following arguments: + +- *accountId*: `Id` The id of the account to use. +- *blobIds*: `Id[]` The ids correspond to the blob of calendar event invitation files that the user wants to query for status. + +The response object contains the following arguments: + +- *accountId*: `Id` The id of the account used for the call. +- *accepted*: `Id[CalendarEvent[]]|null` A list of ids of the calendar events that were successfully accepted, or `null` if none. +- *rejected*: `Id[CalendarEvent[]]|null` A list of ids of the calendar events that were successfully rejected, or `null` if none. +- *tentativelyAccepted*: `Id[CalendarEvent[]]|null` A list of ids of the calendar events that were tentatively accepted successfully (user chose maybe as a response for his attendance), or `null` if none. +- *needsAction*: `Id[CalendarEvent[]]|null` A list of ids of the calendar events that were neither accepted, rejected nor tentatively accepted by user thus they need action, or `null` if none. +- *notFound*: `Id[]|null` A list of blob ids given that could not be found, or `null` if none. +- *notDone*: `Id[SetError]|null` A map of the blobId to a SetError object for each calendar event that failed to reply, or null if all successful. + +The associated `replySupportedLanguage` capability property is not needed for this method to function. + +Note: The sum of sizes of arrays `accepted`, `rejected`, `tentativelyAccepted`, `needsAction` and `notFound` must be equal to the size of `blobIds`, otherwise the server must return an error. + +== Example + +The client makes a request to get the attendance status of calendar event invitations `1_5` that was previously accepted and `1_3` that was rejected: + +.... +{ + "using": ["urn:ietf:params:jmap:core", "com:linagora:params:calendar:event"], + "methodCalls": [ + [ "CalendarEventAttendance/get", { + "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + "blobIds": ["1_5", "1_3"] + }, "c1"] + ] +} +.... + +The server responds: + +[source] +---- +[[ "CalendarEventAttendance/get", +{ + "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + "accepted": [ "1_5" ], + "rejected": [ "1_3" ], + "tentativelyAccepted": [], + "needsAction": [] +}, "c1" ]] +---- + +In the case that a blob id is not found or not accessible for current user, the server would respond: + +[source] +---- +[[ "CalendarEventAttendance/get", +{ + "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + "accepted": [], + "rejected": [], + "tentativelyAccepted": [], + "needsAction": [] + "notFound": ["0f9f65ab-dc7b-4146-850f-6e4881093965" ] +}, "c1" ]] +---- + +If the blob id was in an invalid format, the server would respond: + +[source] +---- +[[ "CalendarEventAttendance/get", +{ + "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + "accepted": [], + "rejected": [], + "tentativelyAccepted": [], + "needsAction": [], + notDone: { + "BAD_BLOB_ID": { + "type": "invalidArguments", + "description": "Invalid BlobId 'BAD_BLOB_ID'. Blob id needs to match this format: {message_id}_{partId1}_{partId2}_..." + } + } +}, "c1" ]] +---- + +If the number of blob ids in the request exceeds the limit (currently 16), the server would respond: + +---- +[ + "error", + { + "type": "requestTooLarge", + "description": "The number of ids requested by the client exceeds the maximum number the server is willing to process in a single method call" + }, + "c1" +] +----