This is an automated email from the ASF dual-hosted git repository. btellier pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/james-project.git
commit 3a53fafad2b6d6a7bf7039759f0c13611fa1ca1a Author: Tran Tien Duc <[email protected]> AuthorDate: Tue Mar 31 21:13:18 2020 +0700 JAMES-2891 AccountId should have its own type --- .../org/apache/james/jmap/http/SessionRoutes.scala | 4 +- .../apache/james/jmap/http/SessionSupplier.scala | 74 +++++++--------------- .../org/apache/james/jmap/json/Serializer.scala | 17 +++-- .../org/apache/james/jmap/model/Capability.scala | 4 +- .../org/apache/james/jmap/model/Invocation.scala | 6 +- .../org/apache/james/jmap/model/Session.scala | 54 ++++++++++++---- .../apache/james/jmap/http/SessionRoutesTest.scala | 38 +---------- .../james/jmap/http/SessionSupplierTest.scala | 10 ++- .../james/jmap/json/SessionSerializationTest.scala | 36 ++++++----- 9 files changed, 111 insertions(+), 132 deletions(-) diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/http/SessionRoutes.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/http/SessionRoutes.scala index 79bfb45..75a280b 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/http/SessionRoutes.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/http/SessionRoutes.scala @@ -46,10 +46,10 @@ object SessionRoutes { @Inject class SessionRoutes(val authFilter: Authenticator, val sessionSupplier: SessionSupplier = new SessionSupplier(), - val serializer: Serializer = new Serializer) extends JMAPRoutes { + val serializer: Serializer = new Serializer()) extends JMAPRoutes { val logger: Logger = SessionRoutes.LOGGER - val generateSession: BiFunction[HttpServerRequest, HttpServerResponse, Publisher[Void]] = + private val generateSession: BiFunction[HttpServerRequest, HttpServerResponse, Publisher[Void]] = (request, response) => SMono.fromPublisher(authFilter.authenticate(request)) .map(_.getUser) .flatMap(sessionSupplier.generate) diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/http/SessionSupplier.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/http/SessionSupplier.scala index c7fb076..231617a 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/http/SessionSupplier.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/http/SessionSupplier.scala @@ -21,16 +21,12 @@ package org.apache.james.jmap.http import java.net.URL -import com.google.common.annotations.VisibleForTesting import eu.timepit.refined.auto._ -import eu.timepit.refined.refineV import org.apache.james.core.Username -import org.apache.james.jmap.http.SessionSupplier.{CORE_CAPABILITY, MAIL_CAPABILITY} +import org.apache.james.jmap.http.SessionSupplier.{CORE_CAPABILITY, HARD_CODED_URL_PREFIX, MAIL_CAPABILITY} import org.apache.james.jmap.model.CapabilityIdentifier.CapabilityIdentifier -import org.apache.james.jmap.model.Id.Id import org.apache.james.jmap.model._ -import reactor.core.publisher.Mono -import reactor.core.scala.publisher.{SFlux, SMono} +import reactor.core.scala.publisher.SMono object SessionSupplier { private val CORE_CAPABILITY = CoreCapability( @@ -53,56 +49,30 @@ object SessionSupplier { emailQuerySortOptions = List("receivedAt", "cc", "from", "to", "subject", "size", "sentAt", "hasKeyword", "uid", "Id"), MayCreateTopLevelMailbox(true) )) + + private val HARD_CODED_URL_PREFIX = "http://this-url-is-hardcoded.org" } class SessionSupplier { - def generate(username: Username): SMono[Session] = - SMono.fromPublisher( - Mono.zip( - accounts(username).asJava(), - primaryAccounts(username).asJava())) - .map(tuple => generate(username, tuple.getT1, tuple.getT2)) - - private def accounts(username: Username): SMono[Map[Id, Account]] = - getId(username) - .map(id => Map( - id -> Account( - username, - IsPersonal(true), - IsReadOnly(false), - accountCapabilities = Set(CORE_CAPABILITY, MAIL_CAPABILITY)))) - - private def primaryAccounts(username: Username): SMono[Map[CapabilityIdentifier, Id]] = - SFlux.just(CORE_CAPABILITY, MAIL_CAPABILITY) - .flatMap(capability => getId(username) - .map(id => (capability.identifier, id))) - .collectMap(getIdentifier, getId) - private def getIdentifier(tuple : (CapabilityIdentifier, Id)): CapabilityIdentifier = tuple._1 - private def getId(tuple : (CapabilityIdentifier, Id)): Id = tuple._2 - - private def getId(username: Username): SMono[Id] = { - SMono.fromCallable(() => refineId(username)) - .flatMap { - case Left(errorMessage: String) => SMono.raiseError(new IllegalStateException(errorMessage)) - case Right(id) => SMono.just(id) - } + def generate(username: Username): SMono[Session] = { + accounts(username) + .map(account => Session( + Capabilities(CORE_CAPABILITY, MAIL_CAPABILITY), + List(account), + primaryAccounts(account.accountId), + username, + apiUrl = new URL(s"$HARD_CODED_URL_PREFIX/jmap"), + downloadUrl = new URL(s"$HARD_CODED_URL_PREFIX/download"), + uploadUrl = new URL(s"$HARD_CODED_URL_PREFIX/upload"), + eventSourceUrl = new URL(s"$HARD_CODED_URL_PREFIX/eventSource"))) } - private def refineId(username: Username): Either[String, Id] = refineV(usernameHashCode(username)) - @VisibleForTesting def usernameHashCode(username: Username) = username.asString().hashCode.toOctalString + private def accounts(username: Username): SMono[Account] = SMono.defer(() => + Account.from(username, IsPersonal(true), IsReadOnly(false), Set(CORE_CAPABILITY, MAIL_CAPABILITY)) match { + case Left(ex: IllegalArgumentException) => SMono.raiseError(ex) + case Right(account: Account) => SMono.just(account) + }) - private def generate(username: Username, - accounts: Map[Id, Account], - primaryAccounts: Map[CapabilityIdentifier, Id]): Session = { - Session( - Capabilities(CORE_CAPABILITY, MAIL_CAPABILITY), - accounts, - primaryAccounts, - username, - apiUrl = new URL("http://this-url-is-hardcoded.org/jmap"), - downloadUrl = new URL("http://this-url-is-hardcoded.org/download"), - uploadUrl = new URL("http://this-url-is-hardcoded.org/upload"), - eventSourceUrl = new URL("http://this-url-is-hardcoded.org/eventSource"), - state = "000001") - } + private def primaryAccounts(accountId: AccountId): Map[CapabilityIdentifier, AccountId] = + Map(CORE_CAPABILITY.identifier -> accountId, MAIL_CAPABILITY.identifier -> accountId) } diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/Serializer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/Serializer.scala index ebf9985..2189af9 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/Serializer.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/Serializer.scala @@ -24,7 +24,6 @@ import java.net.URL import org.apache.james.core.Username import org.apache.james.jmap.model import org.apache.james.jmap.model.CapabilityIdentifier.CapabilityIdentifier -import org.apache.james.jmap.model.Id.Id import org.apache.james.jmap.model.Invocation.{Arguments, MethodCallId, MethodName} import org.apache.james.jmap.model.{Account, Invocation, Session, _} import play.api.libs.functional.syntax._ @@ -103,17 +102,27 @@ class Serializer { private implicit val capabilitiesWrites: Writes[Capabilities] = capabilities => setCapabilityWrites.writes(Set(capabilities.coreCapability, capabilities.mailCapability)) - private implicit def identifierMapWrite[Any](implicit idWriter: Writes[Id]): Writes[Map[CapabilityIdentifier, Any]] = + private implicit val accountIdWrites: Format[AccountId] = Json.valueFormat[AccountId] + private implicit def identifierMapWrite[Any](implicit idWriter: Writes[AccountId]): Writes[Map[CapabilityIdentifier, Any]] = (m: Map[CapabilityIdentifier, Any]) => { m.foldLeft(JsObject.empty)((jsObject, kv) => { - val (identifier: CapabilityIdentifier, id: Id) = kv + val (identifier: CapabilityIdentifier, id: AccountId) = kv jsObject.+(identifier.value, idWriter.writes(id)) }) } private implicit val isPersonalFormat: Format[IsPersonal] = Json.valueFormat[IsPersonal] private implicit val isReadOnlyFormat: Format[IsReadOnly] = Json.valueFormat[IsReadOnly] - private implicit val accountWrites: Writes[Account] = Json.writes[Account] + private implicit val accountWrites: Writes[Account] = ( + (JsPath \ Account.NAME).write[Username] and + (JsPath \ Account.IS_PERSONAL).write[IsPersonal] and + (JsPath \ Account.IS_READ_ONLY).write[IsReadOnly] and + (JsPath \ Account.ACCOUNT_CAPABILITIES).write[Set[_ <: Capability]] + ) (unlift(Account.unapplyIgnoreAccountId)) + + private implicit def accountListWrites(implicit accountWrites: Writes[Account]): Writes[List[Account]] = + (list: List[Account]) => JsObject(list.map(account => (account.accountId.id.value, accountWrites.writes(account)))) + private implicit val sessionWrites: Writes[Session] = Json.writes[Session] def serialize(session: Session): JsValue = { diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/model/Capability.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/model/Capability.scala index 8e35c7d..17517fe 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/model/Capability.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/model/Capability.scala @@ -30,8 +30,8 @@ import org.apache.james.jmap.model.UnsignedInt.UnsignedInt object CapabilityIdentifier { type CapabilityIdentifier = String Refined Uri - val JMAP_CORE: CapabilityIdentifier = "urn:ietf:params:jmap:core" - val JMAP_MAIL: CapabilityIdentifier = "urn:ietf:params:jmap:mail" + private[model] val JMAP_CORE: CapabilityIdentifier = "urn:ietf:params:jmap:core" + private[model] val JMAP_MAIL: CapabilityIdentifier = "urn:ietf:params:jmap:mail" } sealed trait CapabilityProperties diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/model/Invocation.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/model/Invocation.scala index 7ad8e5c..c46d95b 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/model/Invocation.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/model/Invocation.scala @@ -25,9 +25,9 @@ import play.api.libs.json._ case class Invocation(methodName: MethodName, arguments: Arguments, methodCallId: MethodCallId) object Invocation { - val METHOD_NAME: Int = 0 - val ARGUMENTS: Int = 1 - val METHOD_CALL: Int = 2 + private[jmap] val METHOD_NAME: Int = 0 + private[jmap] val ARGUMENTS: Int = 1 + private[jmap] val METHOD_CALL: Int = 2 case class MethodName(value: NonEmptyString) case class Arguments(value: JsObject) extends AnyVal diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/model/Session.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/model/Session.scala index 5d85fca..0bb733d 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/model/Session.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/model/Session.scala @@ -20,44 +20,76 @@ package org.apache.james.jmap.model import java.net.URL +import java.nio.charset.StandardCharsets +import com.google.common.hash.Hashing import eu.timepit.refined.api.Refined +import eu.timepit.refined.auto._ import eu.timepit.refined.collection.NonEmpty +import eu.timepit.refined.refineV import org.apache.james.core.Username import org.apache.james.jmap.model.CapabilityIdentifier.CapabilityIdentifier import org.apache.james.jmap.model.Id.Id -import org.apache.james.jmap.model.State.State +import org.apache.james.jmap.model.State.{INSTANCE, State} case class IsPersonal(value: Boolean) case class IsReadOnly(value: Boolean) -object Account { - def apply(name: Username, - isPersonal: IsPersonal, - isReadOnly: IsReadOnly, - accountCapabilities: Set[_ <: Capability]): Account = { +object AccountId { + def from(username: Username): Either[IllegalArgumentException, AccountId] = { + val sha256String = Hashing.sha256() + .hashString(username.asString(), StandardCharsets.UTF_8) + .toString + val refinedId: Either[String, Id] = refineV(sha256String) - new Account(name, isPersonal, isReadOnly, accountCapabilities) + refinedId match { + case Left(errorMessage: String) => Left(new IllegalArgumentException(errorMessage)) + case Right(id) => Right(AccountId(id)) + } } } -final case class Account private(name: Username, +final case class AccountId(id: Id) + +object Account { + private[jmap] val NAME = "name"; + private[jmap] val IS_PERSONAL = "isPersonal" + private[jmap] val IS_READ_ONLY = "isReadOnly" + private[jmap] val ACCOUNT_CAPABILITIES = "accountCapabilities" + + def from(name: Username, + isPersonal: IsPersonal, + isReadOnly: IsReadOnly, + accountCapabilities: Set[_ <: Capability]): Either[IllegalArgumentException, Account] = + AccountId.from(name) match { + case Left(ex: IllegalArgumentException) => Left(ex) + case Right(accountId) => Right(new Account(accountId, name, isPersonal, isReadOnly, accountCapabilities)) + } + + def unapplyIgnoreAccountId(account: Account): Some[(Username, IsPersonal, IsReadOnly, Set[_ <: Capability])] = + Some(account.name, account.isPersonal, account.isReadOnly, account.accountCapabilities) +} + +final case class Account private(accountId: AccountId, + name: Username, isPersonal: IsPersonal, isReadOnly: IsReadOnly, accountCapabilities: Set[_ <: Capability]) object State { + private[model] val INSTANCE: State = "000001" + type State = String Refined NonEmpty } case class Capabilities(coreCapability: CoreCapability, mailCapability: MailCapability) final case class Session(capabilities: Capabilities, - accounts: Map[Id, Account], - primaryAccounts: Map[CapabilityIdentifier, Id], + accounts: List[Account], + primaryAccounts: Map[CapabilityIdentifier, AccountId], username: Username, apiUrl: URL, downloadUrl: URL, uploadUrl: URL, eventSourceUrl: URL, - state: State) + state: State = INSTANCE) diff --git a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/http/SessionRoutesTest.scala b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/http/SessionRoutesTest.scala index 6ff561d..a7b3e9c 100644 --- a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/http/SessionRoutesTest.scala +++ b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/http/SessionRoutesTest.scala @@ -31,7 +31,6 @@ import org.apache.james.core.Username import org.apache.james.jmap.http.SessionRoutesTest.{BOB, TEST_CONFIGURATION} import org.apache.james.jmap.{JMAPConfiguration, JMAPRoutes, JMAPServer} import org.apache.james.mailbox.MailboxSession -import org.hamcrest.CoreMatchers.is import org.mockito.ArgumentMatchers.any import org.mockito.Mockito._ import org.scalatest.BeforeAndAfter @@ -120,7 +119,7 @@ class SessionRoutesTest extends AnyFlatSpec with BeforeAndAfter with Matchers { | } | }, | "accounts" : { - | "25742733157" : { + | "0fe275bf13ff761407c17f64b1dfae2f4b3186feea223d7267b79f873a105401" : { | "name" : "[email protected]", | "isPersonal" : true, | "isReadOnly" : false, @@ -147,8 +146,8 @@ class SessionRoutesTest extends AnyFlatSpec with BeforeAndAfter with Matchers { | } | }, | "primaryAccounts" : { - | "urn:ietf:params:jmap:core" : "25742733157", - | "urn:ietf:params:jmap:mail" : "25742733157" + | "urn:ietf:params:jmap:core" : "0fe275bf13ff761407c17f64b1dfae2f4b3186feea223d7267b79f873a105401", + | "urn:ietf:params:jmap:mail" : "0fe275bf13ff761407c17f64b1dfae2f4b3186feea223d7267b79f873a105401" | }, | "username" : "[email protected]", | "apiUrl" : "http://this-url-is-hardcoded.org/jmap", @@ -160,35 +159,4 @@ class SessionRoutesTest extends AnyFlatSpec with BeforeAndAfter with Matchers { Json.parse(sessionJson) should equal(Json.parse(expectedJson)) } - - "get" should "return 500 when unexpected Id serialization" in { - when(sessionSupplier.usernameHashCode(BOB)) - .thenReturn("INVALID_JMAP_ID_()*&*$(#*") - - RestAssured.when() - .get - .then - .statusCode(HttpStatus.SC_INTERNAL_SERVER_ERROR) - } - - "get" should "return empty content type when unexpected Id serialization" in { - when(sessionSupplier.usernameHashCode(BOB)) - .thenReturn("INVALID_JMAP_ID_()*&*$(#*") - - RestAssured.when() - .get - .then - .contentType(is("")) - } - - "get" should "return empty body when unexpected Id serialization" in { - when(sessionSupplier.usernameHashCode(BOB)) - .thenReturn("INVALID_JMAP_ID_()*&*$(#*") - - RestAssured.`with`() - .get - .thenReturn() - .getBody - .asString() should equal("") - } } diff --git a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/http/SessionSupplierTest.scala b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/http/SessionSupplierTest.scala index 1b2bb0b..11bd415 100644 --- a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/http/SessionSupplierTest.scala +++ b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/http/SessionSupplierTest.scala @@ -19,10 +19,8 @@ package org.apache.james.jmap.http -import eu.timepit.refined.auto._ import org.apache.james.core.Username import org.apache.james.jmap.http.SessionSupplierTest.USERNAME -import org.apache.james.jmap.model.Id.Id import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpec @@ -45,12 +43,12 @@ class SessionSupplierTest extends AnyWordSpec with Matchers { } "has name" in { - accounts.view.mapValues(_.name).values.toList should equal(List(USERNAME)) + accounts.map(_.name) should equal(List(USERNAME)) } - "has id" in { - val usernameHashCode: Id = "22267206120" - accounts.keys.toList should equal(List(usernameHashCode)) + "has accountId being hash of username in string" in { + accounts.map(_.accountId) + .map(_.id.value) should equal(List("0cb33e029628ea603d1b988f0f81b069d89b6c5a093e12b275ecdc626bd7458c")) } } } diff --git a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/json/SessionSerializationTest.scala b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/json/SessionSerializationTest.scala index 8f6f228..98e3f81 100644 --- a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/json/SessionSerializationTest.scala +++ b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/json/SessionSerializationTest.scala @@ -50,9 +50,7 @@ object SessionSerializationTest { private val MAX_OBJECTS_IN_SET : MaxObjectsInSet = MaxObjectsInSet(128L) private val COLLATION_ALGORITHMS : List[CollationAlgorithm] = List(ALGO_1, ALGO_2, ALGO_3) private val USER_1 = Username.of("[email protected]") - private val USER_1_ID: Id = "user1Id" private val USER_2 = Username.of("[email protected]") - private val USER_2_ID: Id = "user2Id" private val URL = new URL("http://james.org") private val STATE : State = "fda9342jcm" @@ -87,28 +85,32 @@ object SessionSerializationTest { private val IS_NOT_PERSONAL : IsPersonal = IsPersonal(false) private val IS_NOT_READ_ONLY : IsReadOnly = IsReadOnly(false) - private val ACCOUNT_1 = Account( + private val ACCOUNT_1: Account = Account.from( name = USER_1, isPersonal = IS_PERSONAL, isReadOnly = IS_NOT_READ_ONLY, - accountCapabilities = Set(CORE_CAPABILITY)) - private val ACCOUNT_2 = Account( + accountCapabilities = Set(CORE_CAPABILITY)) match { + case Left(ex: IllegalArgumentException) => throw ex + case Right(account: Account) => account + } + + private val ACCOUNT_2: Account = Account.from( name = USER_2, isPersonal = IS_NOT_PERSONAL, isReadOnly = IS_NOT_READ_ONLY, - accountCapabilities = Set(CORE_CAPABILITY)) - private val ACCOUNTS = Map( - USER_1_ID -> ACCOUNT_1, - USER_2_ID -> ACCOUNT_2, - ) + accountCapabilities = Set(CORE_CAPABILITY)) match { + case Left(ex: IllegalArgumentException) => throw ex + case Right(account: Account) => account + } + private val PRIMARY_ACCOUNTS = Map( - MAIL_IDENTIFIER -> USER_1_ID, - CONTACT_IDENTIFIER -> USER_2_ID + MAIL_IDENTIFIER -> ACCOUNT_1.accountId, + CONTACT_IDENTIFIER -> ACCOUNT_1.accountId ) private val SESSION = Session( capabilities = CAPABILITIES, - accounts = ACCOUNTS, + accounts = List(ACCOUNT_1, ACCOUNT_2), primaryAccounts = PRIMARY_ACCOUNTS, username = USER_1, apiUrl = URL, @@ -155,7 +157,7 @@ class SessionSerializationTest extends AnyWordSpec with Matchers { | } | }, | "accounts": { - | "user1Id": { + | "807a5306ccb4527af7790a0f9b48a776514bdbfba064e355461a76bcffbf2c90": { | "name": "[email protected]", | "isPersonal": true, | "isReadOnly": false, @@ -176,7 +178,7 @@ class SessionSerializationTest extends AnyWordSpec with Matchers { | } | } | }, - | "user2Id": { + | "a9b46834e106ff73268a40a34ffba9fcfeee8bdb601939d1a96ef9199dc2695c": { | "name": "[email protected]", | "isPersonal": false, | "isReadOnly": false, @@ -199,8 +201,8 @@ class SessionSerializationTest extends AnyWordSpec with Matchers { | } | }, | "primaryAccounts": { - | "urn:ietf:params:jmap:mail": "user1Id", - | "urn:ietf:params:jmap:contact": "user2Id" + | "urn:ietf:params:jmap:mail": "807a5306ccb4527af7790a0f9b48a776514bdbfba064e355461a76bcffbf2c90", + | "urn:ietf:params:jmap:contact": "807a5306ccb4527af7790a0f9b48a776514bdbfba064e355461a76bcffbf2c90" | }, | "username": "[email protected]", | "apiUrl": "http://james.org", --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
