blob: f746c2c48f908946ed73194d8050cf80ce27ece5 [file] [log] [blame]
/****************************************************************
* Licensed to the Apache Software Foundation (ASF) under one *
* or more contributor license agreements. See the NOTICE file *
* distributed with this work for additional information *
* regarding copyright ownership. The ASF licenses this file *
* to you under the Apache License, Version 2.0 (the *
* "License"); you may not use this file except in compliance *
* with the License. You may obtain a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, *
* software distributed under the License is distributed on an *
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY *
* KIND, either express or implied. See the License for the *
* specific language governing permissions and limitations *
* under the License. *
****************************************************************/
package org.apache.james.jmap.api.identity
import java.nio.charset.StandardCharsets
import java.util.{Optional, UUID}
import jakarta.inject.Inject
import org.apache.james.core.{MailAddress, Username}
import org.apache.james.jmap.api.model.{EmailAddress, ForbiddenSendFromException, HtmlSignature, Identity, IdentityId, IdentityName, MayDeleteIdentity, TextSignature}
import org.apache.james.rrt.api.CanSendFrom
import org.apache.james.user.api.UsersRepository
import org.reactivestreams.Publisher
import reactor.core.scala.publisher.{SFlux, SMono}
import scala.util.Try
import scala.jdk.OptionConverters._
import scala.jdk.CollectionConverters._
object IdentityCreationRequest {
def fromJava(mailAddress: MailAddress,
identityName: Optional[String],
replyTo: Optional[java.util.List[EmailAddress]],
bcc: Optional[java.util.List[EmailAddress]],
sortOrder: Optional[Integer],
textSignature: Optional[String],
htmlSignature: Optional[String]): IdentityCreationRequest = {
IdentityCreationRequest(
name = identityName.toScala.map(IdentityName(_)),
email = mailAddress,
replyTo = replyTo.toScala.map(_.asScala.toList),
bcc = bcc.toScala.map(_.asScala.toList),
sortOrder = sortOrder.toScala.map(_.toInt),
textSignature = textSignature.toScala.map(TextSignature(_)),
htmlSignature = htmlSignature.toScala.map(HtmlSignature(_)))
}
def from(name: Option[IdentityName],
email: MailAddress,
replyTo: Option[List[EmailAddress]],
bcc: Option[List[EmailAddress]],
sortOrder: Option[Int] = None,
textSignature: Option[TextSignature],
htmlSignature: Option[HtmlSignature]): IdentityCreationRequest = {
IdentityCreationRequest(
name = name,
email = email,
replyTo = replyTo,
bcc = bcc,
sortOrder = sortOrder,
textSignature = textSignature,
htmlSignature = htmlSignature)
}
}
case class IdentityCreationRequest(name: Option[IdentityName],
email: MailAddress,
replyTo: Option[List[EmailAddress]],
bcc: Option[List[EmailAddress]],
sortOrder: Option[Int] = None,
textSignature: Option[TextSignature],
htmlSignature: Option[HtmlSignature],
mayDeleteIdentity: MayDeleteIdentity = MayDeleteIdentity(true)) {
def asIdentity(id: IdentityId): Identity = Identity(
id = id,
name = name.getOrElse(IdentityName.DEFAULT),
email = email,
replyTo = replyTo,
bcc = bcc,
textSignature = textSignature.getOrElse(TextSignature.DEFAULT),
htmlSignature = htmlSignature.getOrElse(HtmlSignature.DEFAULT),
mayDelete = mayDeleteIdentity,
sortOrder = sortOrder.getOrElse(Identity.DEFAULT_SORTORDER))
}
trait IdentityUpdate {
def update(identity: Identity): Identity
}
case class IdentityNameUpdate(name: IdentityName) extends IdentityUpdate {
override def update(identity: Identity): Identity = identity.copy(name = name)
}
case class IdentityReplyToUpdate(replyTo: Option[List[EmailAddress]]) extends IdentityUpdate {
override def update(identity: Identity): Identity = identity.copy(replyTo = replyTo)
}
case class IdentityBccUpdate(bcc: Option[List[EmailAddress]]) extends IdentityUpdate {
override def update(identity: Identity): Identity = identity.copy(bcc = bcc)
}
case class IdentitySortOrderUpdate(sortOrder: Int) extends IdentityUpdate {
override def update(identity: Identity): Identity = identity.copy(sortOrder = sortOrder)
}
case class IdentityTextSignatureUpdate(textSignature: TextSignature) extends IdentityUpdate {
override def update(identity: Identity): Identity = identity.copy(textSignature = textSignature)
}
case class IdentityHtmlSignatureUpdate(htmlSignature: HtmlSignature) extends IdentityUpdate {
override def update(identity: Identity): Identity = identity.copy(htmlSignature = htmlSignature)
}
object IdentityUpdateRequest {
def fromJava(name: Optional[String],
replyTo: Optional[java.util.List[EmailAddress]],
bcc: Optional[java.util.List[EmailAddress]],
sortOrder: Optional[Integer],
textSignature: Optional[String],
htmlSignature: Optional[String]): IdentityUpdateRequest = {
IdentityUpdateRequest(
name = name.toScala.map(IdentityName(_)).map(IdentityNameUpdate),
sortOrder = sortOrder.toScala.map(IdentitySortOrderUpdate(_)),
replyTo = Option(IdentityReplyToUpdate(replyTo.toScala.map(_.asScala.toList))),
bcc = Option(IdentityBccUpdate(bcc.toScala.map(_.asScala.toList))),
textSignature = textSignature.toScala.map(TextSignature(_)).map(IdentityTextSignatureUpdate),
htmlSignature = htmlSignature.toScala.map(HtmlSignature(_)).map(IdentityHtmlSignatureUpdate))
}
}
case class IdentityUpdateRequest(name: Option[IdentityNameUpdate] = None,
replyTo: Option[IdentityReplyToUpdate] = None,
sortOrder: Option[IdentitySortOrderUpdate] = None,
bcc: Option[IdentityBccUpdate] = None,
textSignature: Option[IdentityTextSignatureUpdate] = None,
htmlSignature: Option[IdentityHtmlSignatureUpdate] = None) extends IdentityUpdate {
def update(identity: Identity): Identity =
List(name, replyTo, bcc, textSignature, htmlSignature, sortOrder)
.flatten
.foldLeft(identity)((acc, update) => update.update(acc))
def asCreationRequest(email: MailAddress, mayDelete: Boolean): IdentityCreationRequest =
IdentityCreationRequest(
name = name.map(_.name),
email = email,
replyTo = replyTo.flatMap(_.replyTo),
bcc = bcc.flatMap(_.bcc),
textSignature = textSignature.map(_.textSignature),
htmlSignature = htmlSignature.map(_.htmlSignature),
mayDeleteIdentity = MayDeleteIdentity(mayDelete))
}
trait CustomIdentityDAO {
def save(user: Username, creationRequest: IdentityCreationRequest): Publisher[Identity]
def save(user: Username, identityId: IdentityId, creationRequest: IdentityCreationRequest): Publisher[Identity]
def list(user: Username): Publisher[Identity]
def findByIdentityId(user: Username, identityId: IdentityId): SMono[Identity]
def update(user: Username, identityId: IdentityId, identityUpdate: IdentityUpdate): Publisher[Unit]
def upsert(user: Username, patch: Identity): SMono[Unit]
def delete(username: Username, ids: Set[IdentityId]): Publisher[Unit]
def delete(username: Username): Publisher[Unit]
}
class DefaultIdentitySupplier @Inject()(canSendFrom: CanSendFrom, usersRepository: UsersRepository) {
def listIdentities(username: Username): Publisher[Identity] = SFlux(canSendFrom.allValidFromAddressesForUser(username))
.map(address =>
from(address).map(id =>
Identity(
id = id,
name = IdentityName(address.asString()),
email = address,
replyTo = None,
bcc = None,
textSignature = TextSignature.DEFAULT,
htmlSignature = HtmlSignature.DEFAULT,
mayDelete = MayDeleteIdentity(false),
sortOrder = Identity.DEFAULT_SORTORDER)))
.flatMap(option => option.map(SMono.just).getOrElse(SMono.empty))
def userCanSendFrom(username: Username, mailAddress: MailAddress): SMono[Boolean] =
SMono.fromPublisher(canSendFrom.userCanSendFromReactive(username, usersRepository.getUsername(mailAddress)))
.map(boolean2Boolean(_))
private def from(address: MailAddress): Option[IdentityId] =
Try(UUID.nameUUIDFromBytes(address.asString().getBytes(StandardCharsets.UTF_8)))
.toEither
.toOption
.map(IdentityId(_))
}
// This class is intended to merge default (server-set0 identities with (user defined) custom identities
// Using the custom identities we can stores deltas of the default (server-set) identities allowing to modify them.
class IdentityRepository @Inject()(customIdentityDao: CustomIdentityDAO, identityFactory: DefaultIdentitySupplier) {
def save(user: Username, creationRequest: IdentityCreationRequest): Publisher[Identity] =
identityFactory.userCanSendFrom(user, creationRequest.email)
.filter(bool => bool)
.flatMap(_ => SMono(customIdentityDao.save(user, creationRequest)))
.switchIfEmpty(SMono.error(ForbiddenSendFromException(creationRequest.email)))
def list(user: Username): Publisher[Identity] =
listServerSetIdentity(user)
.flatMapMany { case (mailAddressSet, identityList) => listCustomIdentity(user, mailAddressSet)
.map(IdentityWithOrigin.fromCustom)
.mergeWith(SFlux.fromIterable(identityList)
.map(IdentityWithOrigin.fromServerSet))
}
.groupBy(_.identity.id)
.flatMap(_.reduce(IdentityWithOrigin.merge))
.map(_.identity)
private def listServerSetIdentity(user: Username): SMono[(Set[MailAddress], List[Identity])] =
SFlux(identityFactory.listIdentities(user))
.collectSeq().map(s => s.toList)
.map(list => (list.map(_.email).toSet, list))
private def listCustomIdentity(user: Username, availableMailAddresses: Set[MailAddress]): SFlux[Identity] =
SFlux.fromPublisher(customIdentityDao.list(user))
.filter(identity => availableMailAddresses.contains(identity.email))
def update(user: Username, identityId: IdentityId, identityUpdateRequest: IdentityUpdateRequest): Publisher[Unit] = {
val findServerSetIdentity: SMono[Option[Identity]] = SFlux(identityFactory.listIdentities(user))
.collectSeq().map(s => s.toList)
.map(list => list.find(identity => identity.id.equals(identityId)))
val findCustomIdentity: SMono[Option[Identity]] = SMono(customIdentityDao.findByIdentityId(user, identityId))
.map(Some(_))
.switchIfEmpty(SMono.just(None))
SFlux.zip(findServerSetIdentity, findCustomIdentity)
.next()
.flatMap {
case (None, None) => SMono.error(IdentityNotFoundException(identityId))
case (Some(_), Some(customIdentity)) => customIdentityDao.upsert(user, identityUpdateRequest.update(customIdentity))
case (Some(serverSetIdentity), None) => SMono(customIdentityDao.save(user, identityId, identityUpdateRequest.asCreationRequest(serverSetIdentity.email, mayDelete = false)))
case (None, Some(customIdentity)) =>
identityFactory.userCanSendFrom(user, customIdentity.email)
.filter(bool => bool)
.switchIfEmpty(SMono.error(IdentityNotFoundException(identityId)))
.flatMap(_ => SMono(customIdentityDao.upsert(user, identityUpdateRequest.update(customIdentity))))
}
.`then`()
}
def delete(username: Username, ids: Set[IdentityId]): Publisher[Unit] = {
SFlux(identityFactory.listIdentities(username))
.map(_.id)
.collectSeq()
.flatMapMany(serverSetIdentities => SFlux.fromIterable(ids)
.handle[IdentityId] {
case (id, sink) => if (serverSetIdentities.contains(id)) {
sink.error(IdentityForbiddenDeleteException(id))
} else {
sink.next(id)
}
}).collectSeq()
.flatMap(ids => SMono.fromPublisher(customIdentityDao.delete(username, ids.toSet)))
}
}
case class IdentityNotFoundException(id: IdentityId) extends RuntimeException(s"$id could not be found")
case class IdentityForbiddenDeleteException(id: IdentityId) extends IllegalArgumentException(s"User do not have permission to delete $id")
object IdentityWithOrigin {
sealed trait IdentityWithOrigin {
def identity: Identity
def merge(other: IdentityWithOrigin): IdentityWithOrigin
}
case class CustomIdentityOrigin(inputIdentity: Identity) extends IdentityWithOrigin {
override def identity: Identity = inputIdentity
override def merge(other: IdentityWithOrigin): IdentityWithOrigin = CustomIdentityOrigin(identity.copy(mayDelete = MayDeleteIdentity(false)))
}
case class ServerSetIdentityOrigin(inputIdentity: Identity) extends IdentityWithOrigin {
override def identity: Identity = inputIdentity
override def merge(other: IdentityWithOrigin): IdentityWithOrigin = CustomIdentityOrigin(other.identity.copy(mayDelete = MayDeleteIdentity(false)))
}
def fromCustom(identity: Identity): IdentityWithOrigin = CustomIdentityOrigin(identity)
def fromServerSet(identity: Identity): IdentityWithOrigin = ServerSetIdentityOrigin(identity)
def merge(value1: IdentityWithOrigin, value2: IdentityWithOrigin): IdentityWithOrigin = value1.merge(value2)
}