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
The following commit(s) were added to refs/heads/master by this push: new a2583d4e8a JAMES-4008 JMAP - Email/set - Should be able to save a draft with invalid email address (#2040) a2583d4e8a is described below commit a2583d4e8afe69b0974085004a92eff15445445b Author: vttran <vtt...@linagora.com> AuthorDate: Mon Feb 26 14:35:39 2024 +0700 JAMES-4008 JMAP - Email/set - Should be able to save a draft with invalid email address (#2040) --- .../rfc8621/contract/EmailSetMethodContract.scala | 238 ++++++++++++++++++++- .../james/jmap/json/EmailGetSerializer.scala | 3 + .../james/jmap/json/EmailSetSerializer.scala | 18 +- .../scala/org/apache/james/jmap/mail/Email.scala | 20 +- .../org/apache/james/jmap/mail/EmailSet.scala | 91 +++++++- .../jmap/method/EmailSetCreatePerformer.scala | 4 +- .../jmap/method/EmailSubmissionSetMethod.scala | 19 ++ .../james/jmap/json/EmailSetSerializerTest.scala | 87 ++++++++ 8 files changed, 451 insertions(+), 29 deletions(-) diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailSetMethodContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailSetMethodContract.scala index 32beadf08b..25163a8e8c 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailSetMethodContract.scala +++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailSetMethodContract.scala @@ -45,11 +45,11 @@ import org.apache.james.jmap.http.UserCredential import org.apache.james.jmap.rfc8621.contract.DownloadContract.accountId import org.apache.james.jmap.rfc8621.contract.Fixture.{ACCEPT_RFC8621_VERSION_HEADER, ACCOUNT_ID, ANDRE, ANDRE_ACCOUNT_ID, ANDRE_PASSWORD, BOB, BOB_PASSWORD, DOMAIN, authScheme, baseRequestSpecBuilder} import org.apache.james.jmap.rfc8621.contract.probe.DelegationProbe -import org.apache.james.mailbox.FlagsBuilder import org.apache.james.mailbox.MessageManager.AppendCommand import org.apache.james.mailbox.model.MailboxACL.Right import org.apache.james.mailbox.model.{ComposedMessageId, MailboxACL, MailboxConstants, MailboxId, MailboxPath, MessageId} import org.apache.james.mailbox.probe.MailboxProbe +import org.apache.james.mailbox.{DefaultMailboxes, FlagsBuilder} import org.apache.james.mime4j.dom.Message import org.apache.james.modules.{ACLProbeImpl, MailboxProbeImpl, QuotaProbesImpl} import org.apache.james.util.ClassLoaderUtils @@ -59,7 +59,7 @@ import org.awaitility.Awaitility import org.awaitility.Durations.ONE_HUNDRED_MILLISECONDS import org.hamcrest.Matchers import org.hamcrest.Matchers.{equalTo, not} -import org.junit.jupiter.api.{BeforeEach, Test} +import org.junit.jupiter.api.{BeforeEach, Disabled, Test} import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource import play.api.libs.json.{JsNumber, JsString, Json} @@ -7455,6 +7455,240 @@ trait EmailSetMethodContract { |}""".stripMargin) } + @Test + def emailSetShouldSucceedWhenInvalidToMailAddressAndHaveDraftKeyword(server: GuiceJamesServer): Unit = { + val bobPath = MailboxPath.inbox(BOB) + val mailboxId = server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath) + + val request = + s"""{ + | "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], + | "methodCalls": [ + | ["Email/set", { + | "accountId": "$ACCOUNT_ID", + | "create": { + | "aaaaaa":{ + | "mailboxIds": { + | "${mailboxId.serialize}": true + | }, + | "keywords":{ "$$draft": true }, + | "to": [{"email": "invalid1"}], + | "from": [{"email": "${BOB.asString}"}] + | } + | } + | }, "c1"], + | ["Email/get", + | { + | "accountId": "$ACCOUNT_ID", + | "ids": ["#aaaaaa"], + | "properties": ["sentAt", "messageId"] + | }, + | "c2"]] + |}""".stripMargin + + val response = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[1][1].list.[0].sentAt") + .isEqualTo("\"${json-unit.ignore}\"") + assertThatJson(response) + .inPath("methodResponses[1][1].list.[0].messageId") + .isEqualTo("[\"${json-unit.ignore}\"]") + + } + + @Test + def emailGetShouldReturnUncheckedMailAddressValueWhenDraftEmail(server: GuiceJamesServer): Unit = { + val bobDraftsPath = MailboxPath.forUser(BOB, DefaultMailboxes.DRAFTS) + val draftId = server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobDraftsPath) + + val request = + s"""{ + | "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", "urn:ietf:params:jmap:submission"], + | "methodCalls": [ + | ["Email/set", { + | "accountId": "$ACCOUNT_ID", + | "create": { + | "e1526":{ + | "mailboxIds": { + | "${draftId.serialize}": true + | }, + | "keywords":{ "$$draft": true }, + | "to": [{"email": "invalid1", "name" : "name1"}], + | "from": [{"email": "${BOB.asString}"}] + | } + | } + | }, "c1"], + | ["Email/get", + | { + | "accountId": "$ACCOUNT_ID", + | "ids": ["#e1526"], + | "properties": ["to", "from" ] + | }, + | "c2"]] + |}""".stripMargin + + val response = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[1][1].list") + .isEqualTo("""[{ + | "to": [{ + | "name": "name1", + | "email": "invalid1" + | }], + | "id": "1", + | "from": [{ + | "email": "b...@domain.tld" + | } + | ]}]""".stripMargin) + + } + + @Test + def emailSubmissionSetShouldFailWhenInvalidEmailAddressHeader(server: GuiceJamesServer): Unit = { + val bobDraftsPath = MailboxPath.forUser(BOB, DefaultMailboxes.DRAFTS) + + val bobPath = MailboxPath.inbox(BOB) + val mailboxId = server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath) + val draftId = server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobDraftsPath) + + val request = + s"""{ + | "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", "urn:ietf:params:jmap:submission"], + | "methodCalls": [ + | ["Email/set", { + | "accountId": "$ACCOUNT_ID", + | "create": { + | "e1526":{ + | "mailboxIds": { + | "${draftId.serialize}": true + | }, + | "keywords":{ "$$draft": true }, + | "to": [{"email": "invalid1"}], + | "from": [{"email": "${BOB.asString}"}] + | } + | } + | }, "c1"], + | ["Email/get", + | { + | "accountId": "$ACCOUNT_ID", + | "ids": ["#e1526"], + | "properties": ["sentAt"] + | }, + | "c2"], + | ["EmailSubmission/set", { + | "accountId": "$ACCOUNT_ID", + | "create": { + | "k1490": { + | "emailId": "#e1526", + | "envelope": { + | "mailFrom": {"email": "${BOB.asString}"}, + | "rcptTo": [{"email": "${BOB.asString}"}] + | } + | } + | } + | }, "c3"]] + |}""".stripMargin + + val response = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[2]") + .isEqualTo(s"""[ + | "EmailSubmission/set", + | { + | "accountId": "$${json-unit.ignore}", + | "newState": "$${json-unit.ignore}", + | "notCreated": { + | "k1490": { + | "type": "invalidArguments", + | "description": "Invalid mail address: invalid1 in to header" + | } + | } + | }, + | "c3" + |]""".stripMargin) + + } + + @Test + def emailSetShouldFailWhenInvalidToEmailAddressAndHaveNotDraftKeyword(server: GuiceJamesServer): Unit = { + val bobPath = MailboxPath.inbox(BOB) + val mailboxId = server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath) + + val request = + s"""{ + | "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], + | "methodCalls": [ + | ["Email/set", { + | "accountId": "$ACCOUNT_ID", + | "create": { + | "aaaaaa":{ + | "mailboxIds": { + | "${mailboxId.serialize}": true + | }, + | "keywords":{ }, + | "to": [{"email": "invalid1"}], + | "from": [{"email": "${BOB.asString}"}] + | } + | } + | }, "c1"]] + |}""".stripMargin + + val response = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1].notCreated") + .isEqualTo( + """{ + | "aaaaaa": { + | "type": "invalidArguments", + | "description": "/to: Invalid email address `invalid1`" + | } + |}""".stripMargin) + } + private def buildTestMessage = { Message.Builder .of diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailGetSerializer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailGetSerializer.scala index 3da03d5c39..001a3874c8 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailGetSerializer.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailGetSerializer.scala @@ -81,6 +81,8 @@ object EmailGetSerializer { private implicit val locationWrites: Writes[Location] = Json.valueWrites[Location] private implicit val emailerNameWrites: Writes[EmailerName] = Json.valueWrites[EmailerName] private implicit val emailAddressWrites: Writes[EmailAddress] = Json.writes[EmailAddress] + private implicit val uncheckedEmailWrites: Writes[UncheckedEmail] = Json.valueWrites[UncheckedEmail] + private implicit val uncheckedEmailAddressWrites: Writes[UncheckedEmailAddress] = Json.writes[UncheckedEmailAddress] private implicit val headerMessageIdWrites: Writes[HeaderMessageId] = Json.valueWrites[HeaderMessageId] private implicit val isEncodingProblemWrites: Writes[IsEncodingProblem] = Json.valueWrites[IsEncodingProblem] private implicit val isTruncatedWrites: Writes[IsTruncated] = Json.valueWrites[IsTruncated] @@ -91,6 +93,7 @@ object EmailGetSerializer { private implicit val rawHeaderWrites: Writes[RawHeaderValue] = Json.valueWrites[RawHeaderValue] private implicit val textHeaderWrites: Writes[TextHeaderValue] = Json.valueWrites[TextHeaderValue] private implicit val addressesHeaderWrites: Writes[AddressesHeaderValue] = Json.valueWrites[AddressesHeaderValue] + private implicit val uncheckedAddressesHeaderValueWrites: Writes[UncheckedAddressesHeaderValue] = Json.valueWrites[UncheckedAddressesHeaderValue] private implicit val GroupNameWrites: Writes[GroupName] = Json.valueWrites[GroupName] private implicit val emailAddressGroupWrites: Writes[EmailAddressGroup] = (o: EmailAddressGroup) => Json.obj( diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailSetSerializer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailSetSerializer.scala index 53523f7307..f966a5e34b 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailSetSerializer.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailSetSerializer.scala @@ -29,7 +29,7 @@ import org.apache.james.jmap.api.model.{EmailAddress, EmailerName} import org.apache.james.jmap.core.Id.IdConstraint import org.apache.james.jmap.core.{Id, SetError, UTCDate, UuidState} import org.apache.james.jmap.mail.KeywordsFactory.STRICT_KEYWORDS_FACTORY -import org.apache.james.jmap.mail.{AddressesHeaderValue, AllHeaderValues, AsAddresses, AsDate, AsGroupedAddresses, AsMessageIds, AsRaw, AsText, AsURLs, Attachment, BlobId, Charset, ClientBody, ClientCid, ClientEmailBodyValue, ClientEmailBodyValueWithoutHeaders, ClientPartId, DateHeaderValue, DestroyIds, Disposition, EmailAddressGroup, EmailCreationId, EmailCreationRequest, EmailCreationResponse, EmailHeader, EmailHeaderName, EmailHeaderValue, EmailImport, EmailImportRequest, EmailImportR [...] +import org.apache.james.jmap.mail.{AddressesHeaderValue, AllHeaderValues, AsAddresses, AsDate, AsGroupedAddresses, AsMessageIds, AsRaw, AsText, AsURLs, Attachment, BlobId, Charset, ClientBody, ClientCid, ClientEmailBodyValue, ClientEmailBodyValueWithoutHeaders, ClientPartId, DateHeaderValue, DestroyIds, Disposition, EmailAddressGroup, EmailCreationId, EmailCreationRequest, EmailCreationResponse, EmailHeader, EmailHeaderName, EmailHeaderValue, EmailImport, EmailImportRequest, EmailImportR [...] import org.apache.james.mailbox.model.{MailboxId, MessageId} import play.api.libs.json.{Format, JsArray, JsBoolean, JsError, JsNull, JsObject, JsResult, JsString, JsSuccess, JsValue, Json, OWrites, Reads, Writes} @@ -241,8 +241,12 @@ class EmailSetSerializer @Inject()(messageIdFactory: MessageId.Factory, mailboxI private implicit val subjectReads: Reads[Subject] = Json.valueReads[Subject] private implicit val emailerNameReads: Reads[EmailerName] = Json.valueReads[EmailerName] + private implicit val unvalidatedEmailReads: Reads[UncheckedEmail] = Json.valueReads[UncheckedEmail] + private implicit val headerMessageIdReads: Reads[HeaderMessageId] = Json.valueReads[HeaderMessageId] private implicit val emailAddressReads: Reads[EmailAddress] = Json.reads[EmailAddress] + private implicit val unvalidatedEmailAddressReads: Reads[UncheckedEmailAddress] = Json.reads[UncheckedEmailAddress] + private implicit val unvalidatedAddressesHeaderValueReads: Reads[UncheckedAddressesHeaderValue] = Json.valueReads[UncheckedAddressesHeaderValue] private implicit val addressesHeaderValueReads: Reads[AddressesHeaderValue] = Json.valueReads[AddressesHeaderValue] private implicit val messageIdsHeaderValueReads: Reads[MessageIdsHeaderValue] = { case JsArray(value) => value.map(headerMessageIdReads.reads) @@ -308,12 +312,12 @@ class EmailSetSerializer @Inject()(messageIdFactory: MessageId.Factory, mailboxI messageId: Option[MessageIdsHeaderValue], references: Option[MessageIdsHeaderValue], inReplyTo: Option[MessageIdsHeaderValue], - from: Option[AddressesHeaderValue], - to: Option[AddressesHeaderValue], - cc: Option[AddressesHeaderValue], - bcc: Option[AddressesHeaderValue], - sender: Option[AddressesHeaderValue], - replyTo: Option[AddressesHeaderValue], + from: Option[UncheckedAddressesHeaderValue], + to: Option[UncheckedAddressesHeaderValue], + cc: Option[UncheckedAddressesHeaderValue], + bcc: Option[UncheckedAddressesHeaderValue], + sender: Option[UncheckedAddressesHeaderValue], + replyTo: Option[UncheckedAddressesHeaderValue], subject: Option[Subject], sentAt: Option[UTCDate], keywords: Option[Keywords], diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Email.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Email.scala index 82c51a7633..50f2c7c096 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Email.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Email.scala @@ -363,14 +363,14 @@ object EmailHeaders { .map(_.flatten)) .filter(_.nonEmpty)) - private def extractAddresses(mime4JMessage: Message, fieldName: String): Option[AddressesHeaderValue] = + private def extractAddresses(mime4JMessage: Message, fieldName: String): Option[UncheckedAddressesHeaderValue] = extractLastField(mime4JMessage, fieldName) .flatMap { - case f: AddressListField => Some(AddressesHeaderValue(EmailAddress.from(f.getAddressList))) - case f: MailboxListField => Some(AddressesHeaderValue(EmailAddress.from(f.getMailboxList))) + case f: AddressListField => Some(UncheckedAddressesHeaderValue(UncheckedEmailAddress.from(f.getAddressList))) + case f: MailboxListField => Some(UncheckedAddressesHeaderValue(UncheckedEmailAddress.from(f.getMailboxList))) case f: MailboxField => val asMailboxListField = AddressListFieldLenientImpl.PARSER.parse(RawFieldParser.DEFAULT.parseField(f.getRaw), DecodeMonitor.SILENT) - Some(AddressesHeaderValue(EmailAddress.from(asMailboxListField.getAddressList))) + Some(UncheckedAddressesHeaderValue(UncheckedEmailAddress.from(asMailboxListField.getAddressList))) case _ => None } .filter(_.value.nonEmpty) @@ -392,12 +392,12 @@ case class EmailHeaders(headers: List[EmailHeader], messageId: MessageIdsHeaderValue, inReplyTo: MessageIdsHeaderValue, references: MessageIdsHeaderValue, - to: Option[AddressesHeaderValue], - cc: Option[AddressesHeaderValue], - bcc: Option[AddressesHeaderValue], - from: Option[AddressesHeaderValue], - sender: Option[AddressesHeaderValue], - replyTo: Option[AddressesHeaderValue], + to: Option[UncheckedAddressesHeaderValue], + cc: Option[UncheckedAddressesHeaderValue], + bcc: Option[UncheckedAddressesHeaderValue], + from: Option[UncheckedAddressesHeaderValue], + sender: Option[UncheckedAddressesHeaderValue], + replyTo: Option[UncheckedAddressesHeaderValue], subject: Option[Subject], sentAt: Option[UTCDate]) diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailSet.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailSet.scala index 8999b38e6f..cb919180db 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailSet.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailSet.scala @@ -25,17 +25,20 @@ import cats.implicits._ import com.google.common.net.MediaType import com.google.common.net.MediaType.{HTML_UTF_8, PLAIN_TEXT_UTF_8} import eu.timepit.refined +import org.apache.james.core.MailAddress import org.apache.james.jmap.api.model.Size.Size +import org.apache.james.jmap.api.model.{EmailAddress, EmailerName} import org.apache.james.jmap.core.Id.{Id, IdConstraint} import org.apache.james.jmap.core.{AccountId, SetError, UTCDate, UuidState} import org.apache.james.jmap.mail.Disposition.INLINE +import org.apache.james.jmap.mail.EmailCreationRequest.KEYWORD_DRAFT import org.apache.james.jmap.method.WithAccountId import org.apache.james.jmap.routes.{Blob, BlobResolvers} import org.apache.james.mailbox.MailboxSession import org.apache.james.mailbox.model.{Cid, MessageId} import org.apache.james.mime4j.codec.EncoderUtil.Usage import org.apache.james.mime4j.codec.{DecodeMonitor, EncoderUtil} -import org.apache.james.mime4j.dom.address.Mailbox +import org.apache.james.mime4j.dom.address.{AddressList, MailboxList, Mailbox => Mime4jMailbox} import org.apache.james.mime4j.dom.field.{ContentIdField, ContentTypeField, FieldName} import org.apache.james.mime4j.dom.{Entity, Message} import org.apache.james.mime4j.field.{ContentIdFieldImpl, Fields} @@ -112,16 +115,66 @@ case class Attachment(blobId: BlobId, def isInline: Boolean = disposition.contains(INLINE) } +case class UncheckedEmail(value: String) extends AnyVal + +object UncheckedEmailAddress { + def from(addressList: AddressList): List[UncheckedEmailAddress] = Option(addressList) + .map(addressList => from(addressList.flatten())) + .getOrElse(List()) + + def from(addressList: MailboxList): List[UncheckedEmailAddress] = + addressList.asScala + .toList + .filter(address => !address.getAddress.equals(">")) // Temporary fix for https://github.com/linagora/james-project/issues/5086 + .map(mailbox => UncheckedEmailAddress( + name = Option(mailbox.getName).map(EmailerName.from), + email = UncheckedEmail(mailbox.getAddress))) +} +case class UncheckedEmailAddress(name: Option[EmailerName], email: UncheckedEmail) { + def asMime4JMailbox: Mime4jMailbox = { + val parts = email.value.split('@') + val domainPart: String = parts match { + case Array(_, domain) => domain + case _ => "" + } + Some(email.value.split('@')) + .map(parts => new Mime4jMailbox( + name.map(_.value).orNull, + parts.head, + domainPart)) + .get + } + + def validate: Either[IllegalArgumentException, EmailAddress] = + Try(new MailAddress(email.value)) + .map(email => EmailAddress(name, email)) + .toEither match { + case scala.Right(value) => scala.Right(value) + case Left(e) => Left(new IllegalArgumentException(s"Invalid email address `${email.value}`", e)) + } +} + +case class UncheckedAddressesHeaderValue(value: List[UncheckedEmailAddress]) { + def asMime4JMailboxList: Option[List[Mime4jMailbox]] = Some(value.map(_.asMime4JMailbox)).filter(_.nonEmpty) + + def validate: Either[IllegalArgumentException, AddressesHeaderValue] = value.map(_.validate) + .sequence + .map(l => AddressesHeaderValue(l)) +} + +object EmailCreationRequest { + val KEYWORD_DRAFT: Keyword = org.apache.james.jmap.mail.Keyword("$draft") +} case class EmailCreationRequest(mailboxIds: MailboxIds, messageId: Option[MessageIdsHeaderValue], references: Option[MessageIdsHeaderValue], inReplyTo: Option[MessageIdsHeaderValue], - from: Option[AddressesHeaderValue], - to: Option[AddressesHeaderValue], - cc: Option[AddressesHeaderValue], - bcc: Option[AddressesHeaderValue], - sender: Option[AddressesHeaderValue], - replyTo: Option[AddressesHeaderValue], + from: Option[UncheckedAddressesHeaderValue], + to: Option[UncheckedAddressesHeaderValue], + cc: Option[UncheckedAddressesHeaderValue], + bcc: Option[UncheckedAddressesHeaderValue], + sender: Option[UncheckedAddressesHeaderValue], + replyTo: Option[UncheckedAddressesHeaderValue], subject: Option[Subject], sentAt: Option[UTCDate], keywords: Option[Keywords], @@ -142,7 +195,7 @@ case class EmailCreationRequest(mailboxIds: MailboxIds, references.flatMap(_.asString).map(new RawField("References", _)).foreach(builder.setField) inReplyTo.flatMap(_.asString).map(new RawField("In-Reply-To", _)).foreach(builder.setField) subject.foreach(value => builder.setSubject(value.value)) - val maybeFrom: Option[List[Mailbox]] = from.flatMap(_.asMime4JMailboxList) + val maybeFrom: Option[List[Mime4jMailbox]] = from.flatMap(_.asMime4JMailboxList) maybeFrom.map(_.asJava).foreach(builder.setFrom) to.flatMap(_.asMime4JMailboxList).map(_.asJava).foreach(builder.setTo) cc.flatMap(_.asMime4JMailboxList).map(_.asJava).foreach(builder.setCc) @@ -167,7 +220,7 @@ case class EmailCreationRequest(mailboxIds: MailboxIds, }) } - private def generateUniqueMessageId(fromAddress: Option[List[Mailbox]]): String = + private def generateUniqueMessageId(fromAddress: Option[List[Mime4jMailbox]]): String = MimeUtil.createUniqueMessageId(fromAddress.flatMap(_.headOption).map(_.getDomain).orNull) private def createAlternativeBody(htmlBody: Option[ClientBodyPart], textBody: Option[ClientBodyPart], htmlTextExtractor: HtmlTextExtractor) = { @@ -316,6 +369,26 @@ case class EmailCreationRequest(mailboxIds: MailboxIds, case _ => Left(new IllegalArgumentException("Expecting textBody to contains only 1 part")) } + def validateRequest: Either[IllegalArgumentException, EmailCreationRequest] = validateEmailAddressHeader + + def validateEmailAddressHeader: Either[IllegalArgumentException, EmailCreationRequest] = keywords match { + case Some(k) if k.keywords.contains(KEYWORD_DRAFT) => scala.Right(this) + case _ => doValidateEmailAddressHeader() + } + + private def doValidateEmailAddressHeader(): Either[IllegalArgumentException, EmailCreationRequest] = { + val addressesHeaderInvalid: Map[String, IllegalArgumentException] = Map("from" -> from, "to" -> to, "cc" -> cc, "bcc" -> bcc, "sender" -> sender, "replyTo" -> replyTo) + .map { + case (name, maybeAddresses) => (name, maybeAddresses.map(_.validate)) + }.collect { case (name, Some(addresses)) => (name, addresses) } + .collect { case (name, scala.Left(exception)) => (name, exception) } + + addressesHeaderInvalid match { + case invalid if invalid.nonEmpty => Left(new IllegalArgumentException(s"/${addressesHeaderInvalid.map { case (name, exception) => s"$name: ${exception.getMessage}" }.mkString(", ")}")) + case _ => scala.Right(this) + } + } + private def retrieveCorrespondingBody(partId: ClientPartId): Option[Either[IllegalArgumentException, Some[ClientBodyPart]]] = bodyValues.getOrElse(Map()) .get(partId) diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSetCreatePerformer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSetCreatePerformer.scala index 8a8361fd43..d32a474fc7 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSetCreatePerformer.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSetCreatePerformer.scala @@ -104,7 +104,9 @@ class EmailSetCreatePerformer @Inject()(serializer: EmailSetSerializer, .concatMap { case (clientId, json) => serializer.deserializeCreationRequest(json) .fold(e => SMono.just[CreationResult](CreationFailure(clientId, new IllegalArgumentException(e.toString))), - creationRequest => create(clientId, creationRequest, mailboxSession)) + creationRequest => creationRequest.validateRequest + .fold(e => SMono.just[CreationResult](CreationFailure(clientId, e)), + _ => create(clientId, creationRequest, mailboxSession))) }.collectSeq() .map(CreationResults) diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSubmissionSetMethod.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSubmissionSetMethod.scala index a2be422488..1478c0994b 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSubmissionSetMethod.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSubmissionSetMethod.scala @@ -269,6 +269,7 @@ class EmailSubmissionSetMethod @Inject()(serializer: EmailSubmissionSetSerialize .switchIfEmpty(SMono.error(MessageNotFoundException(request.emailId))) submissionId = EmailSubmissionId.generate message <- SMono.fromTry(toMimeMessage(submissionId.value, message)) + _ <- validateMimeMessages(message) envelope <- SMono.fromTry(resolveEnvelope(message, request.envelope)) _ <- validate(mailboxSession)(message, envelope) _ <- SMono.fromTry(validateFromParameters(envelope.mailFrom.parameters)) @@ -348,6 +349,24 @@ class EmailSubmissionSetMethod @Inject()(serializer: EmailSubmissionSetSerialize Failure(new IllegalArgumentException("Invalid delayed time!")) } + def validateMimeMessages(mimeMessage: MimeMessage) : SMono[MimeMessage] = validateMailAddressHeaderMimeMessage(mimeMessage) + private def validateMailAddressHeaderMimeMessage(mimeMessage: MimeMessage): SMono[MimeMessage] = + SFlux.fromIterable(Map("to" -> Option(mimeMessage.getRecipients(RecipientType.TO)).toList.flatten, + "cc" -> Option(mimeMessage.getRecipients(RecipientType.CC)).toList.flatten, + "bcc" -> Option(mimeMessage.getRecipients(RecipientType.BCC)).toList.flatten, + "from" -> Option(mimeMessage.getFrom).toList.flatten, + "sender" -> Option(mimeMessage.getSender).toList, + "replyTo" -> Option(mimeMessage.getReplyTo).toList.flatten)) + .doOnNext { case (headerName, addresses) => (headerName, addresses.foreach(address => validateMailAddress(headerName, address))) } + .`then`() + .`then`(SMono.just(mimeMessage)) + + private def validateMailAddress(headName: String, address: Address): MailAddress = + Try(new MailAddress(address.toString)) match { + case Success(mailAddress) => mailAddress + case Failure(_) => throw new IllegalArgumentException(s"Invalid mail address: $address in $headName header") + } + def validateRcptTo(recipients: List[EmailSubmissionAddress]): SMono[List[EmailSubmissionAddress]] = SFlux.fromIterable(recipients) .filter(validateRecipient) diff --git a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/json/EmailSetSerializerTest.scala b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/json/EmailSetSerializerTest.scala new file mode 100644 index 0000000000..300a5405a9 --- /dev/null +++ b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/json/EmailSetSerializerTest.scala @@ -0,0 +1,87 @@ +/**************************************************************** + * 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.json + +import org.apache.james.jmap.json.EmailSetSerializerTest.SERIALIZER +import org.apache.james.jmap.mail.EmailCreationRequest +import org.apache.james.mailbox.model.{TestId, TestMessageId} +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec +import play.api.libs.json.{JsResult, Json} + +object EmailSetSerializerTest { + val SERIALIZER: EmailSetSerializer = new EmailSetSerializer(new TestMessageId.Factory, new TestId.Factory) +} + +class EmailSetSerializerTest extends AnyWordSpec with Matchers { + + "Deserialize EmailSetRequest" should { + "Request should be success" in { + val jsResult: JsResult[EmailCreationRequest] = SERIALIZER.deserializeCreationRequest( + Json.parse( + """{ + | "mailboxIds": { + | "1": true + | }, + | "keywords": { + | "$draft": true, + | "$seen": true + | }, + | "subject": "draft 1", + | "from": [ + | { + | "name": "Van Tung TRAN", + | "email": "vtt...@linagora.com" + | } + | ], + | "to": [ + | { + | "name": null, + | "email": "bt" + | } + | ], + | "cc": [], + | "bcc": [], + | "replyTo": [ + | { + | "name": null, + | "email": "vtt...@linagora.com" + | } + | ], + | "htmlBody": [ + | { + | "partId": "951c3960-d139-11ee-843e-b70023541167", + | "type": "text/html" + | } + | ], + | "bodyValues": { + | "951c3960-d139-11ee-843e-b70023541167": { + | "value": "<div><br>xin chao</div>", + | "isEncodingProblem": false, + | "isTruncated": false + | } + | }, + | "header:User-Agent:asText": "Team-Mail/0.11.3 Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36" + |}""".stripMargin)) + + assert(jsResult.isSuccess) + } + } +} --------------------------------------------------------------------- To unsubscribe, e-mail: notifications-unsubscr...@james.apache.org For additional commands, e-mail: notifications-h...@james.apache.org