diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/com/google/api/services/gmail/model/ThreadExt.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/com/google/api/services/gmail/model/ThreadExt.kt index a6e8f6f964..378ff0df71 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/com/google/api/services/gmail/model/ThreadExt.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/com/google/api/services/gmail/model/ThreadExt.kt @@ -20,9 +20,10 @@ import jakarta.mail.internet.InternetAddress */ fun Thread.getUniqueRecipients(account: String): List { return mutableListOf().apply { - if (messages == null || messages.isEmpty()) { + if (messages.isNullOrEmpty()) { return@apply } + val fromHeaderName = "From" val filteredHeaders = if (messages.size > 1) { @@ -42,7 +43,7 @@ fun Thread.getUniqueRecipients(account: String): List { } else emptyList() }.ifEmpty { //otherwise we will use all recipients - messages.flatMap { message -> message.filterHeadersWithName(fromHeaderName) } + messages.flatMap { it.filterHeadersWithName(fromHeaderName) } } } else { messages.first().filterHeadersWithName(fromHeaderName) diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpMsg.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpMsg.kt index 0cbe27de11..5d7e9c23f9 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpMsg.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpMsg.kt @@ -59,6 +59,7 @@ import org.json.JSONObject import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.jsoup.nodes.Element +import org.jsoup.nodes.Entities import org.jsoup.nodes.TextNode import org.jsoup.parser.Parser import org.owasp.html.HtmlPolicyBuilder @@ -530,7 +531,7 @@ object PgpMsg { if (dirtyHtml == null) return null val originalDocument = Jsoup.parse(dirtyHtml, "", Parser.xmlParser()) - originalDocument.select("div.gmail_quote").firstOrNull()?.let { element -> + originalDocument.select("div.gmail_quote,div.flowcrypt_quote").firstOrNull()?.let { element -> //we wrap Gmail quote with 'details' tag val generation = Element("details").apply { appendChild(Element("summary")) @@ -1214,7 +1215,7 @@ object PgpMsg { MsgBlock.Type.PLAIN_TEXT -> { val html = fmtMsgContentBlockAsHtml( - content.toEscapedHtml(), + checkAndReturnQuotesFormatIfFound(content) ?: content.toEscapedHtml(), if (block.isOpenPGPMimeSigned) FrameColor.GRAY else FrameColor.PLAIN ) msgContentAsHtml.append(html) @@ -1304,6 +1305,92 @@ object PgpMsg { ) } + private fun checkAndReturnQuotesFormatIfFound(content: String): String? { + return buildQuotes(originalContent = content, unwrapContent = false)?.outerHtml() + } + + private fun buildQuotes(originalContent: String, unwrapContent: Boolean = true): Element? { + val content = if (unwrapContent) { + //remove > at the beginning of all lines to define next quotes level + val patternQuotesSign = "^>([^\\S\\r\\n])?".toRegex(RegexOption.MULTILINE) + originalContent.replace(patternQuotesSign, "") + } else { + originalContent + } + + val newLineStringPattern = "\\r\\n|\\r|\\n" + val beforeQuotesHeaderStringPattern = "^.*:($newLineStringPattern){1,2}" + val patternQuotes = (if (unwrapContent) { + "(^>.*\$($newLineStringPattern))+" + } else { + "($beforeQuotesHeaderStringPattern)(^>.*\$($newLineStringPattern))+" + }).toRegex(RegexOption.MULTILINE) + val tagDiv = "div" + val tagBlockquote = "blockquote" + + val matchingResult = patternQuotes.find(content)?.groups?.firstOrNull() + ?: return Element(tagDiv).apply { + append(prepareHtmlFromGivenText(content)) + }.takeIf { unwrapContent } + val quotes = matchingResult.value + + return Element(tagDiv).apply { + //prepend text before quotes + if (matchingResult.range.first > 0) { + prepend(prepareHtmlFromGivenText(content.substring(0, matchingResult.range.first))) + } + + //append quotes + if (unwrapContent) { + appendChild( + Element(tagBlockquote).apply { + buildQuotes(quotes)?.let { appendChild(it) } + } + ) + } else { + appendChild( + Element(tagDiv).apply { + attr("class", "flowcrypt_quote") + //for better UI experience we need to extract the quote header of the first quote + //and add it separately + val quotesHeader = + quotes.replace("(^>.*\$($newLineStringPattern))+".toRegex(RegexOption.MULTILINE), "") + append(prepareHtmlFromGivenText(quotesHeader)) + + appendChild( + Element(tagBlockquote).apply { + //here we should pass clear quotes and drop the first quote header + buildQuotes(quotes.replaceFirst(quotesHeader, ""))?.let { appendChild(it) } + } + ) + } + ) + } + + //append text after quotes + if (matchingResult.range.last < content.length) { + append( + prepareHtmlFromGivenText(content.substring(matchingResult.range.last + 1, content.length)) + ) + } + } + } + + private fun prepareHtmlFromGivenText(content: String): String { + val newLineStringPattern = "\\r\\n|\\r|\\n" + val patternNewLine = "($newLineStringPattern)".toRegex() + val patternEscapedEmailAddress = "<(\\S+@\\S+)>".toRegex() + val emailAddressReplacement = "\$1" + val br = "
" + return Entities + //escape given text to fit HTML standard + .escape(content) + //Prepare for email addresses. + .replace(patternEscapedEmailAddress, emailAddressReplacement) + //Replace CRLF with
to transform to HTML. + .replace(patternNewLine, br) + } + /** * replace content of images: */ diff --git a/FlowCrypt/src/test/java/com/flowcrypt/email/security/pgp/PgpMsgTest.kt b/FlowCrypt/src/test/java/com/flowcrypt/email/security/pgp/PgpMsgTest.kt index d09452f491..8b8cc7b8f6 100644 --- a/FlowCrypt/src/test/java/com/flowcrypt/email/security/pgp/PgpMsgTest.kt +++ b/FlowCrypt/src/test/java/com/flowcrypt/email/security/pgp/PgpMsgTest.kt @@ -615,6 +615,133 @@ class PgpMsgTest { assertEquals(1, document.select("summary").size) } + @Test + fun testQuotesParsingAndHtmlManipulationForPlainMode() { + val mimeMessageRaw = """ + MIME-Version: 1.0 + Date: Mon, 24 Feb 2025 17:02:47 +0200 + Message-ID: + Subject: Re: Quotes for plain text + From: Den at FlowCrypt + To: DenBond7 + Content-Type: text/plain; charset="UTF-8" + Content-Transfer-Encoding: quoted-printable + + reply 2 + + The top-level build.gradle.kts file (for the Kotlin DSL) or + build.gradle file (for the Groovy DSL) is located in the root project + directory. It typically defines the common versions of plugins used by + modules in your project. + + The following code sample describes the default settings and DSL + elements in the top-level build script after creating a new project + + On Mon, Feb 24, 2025 at 5:02=E2=80=AFPM DenBond7 wrote: + > + > 2 + > + > Creating custom build configurations requires you to make changes to + > one or more build configuration files. These plain-text files use a + > domain-specific language (DSL) to describe and manipulate the build + > logic using Kotlin script, which is a flavor of the Kotlin language. + > You can also use Groovy, which is a dynamic language for the Java + > Virtual Machine (JVM), to configure your builds. + > + > You don't need to know Kotlin script or Groovy to start configuring + > your build because the Android Gradle plugin introduces most of the + > DSL elements you need. To learn more about the Android Gradle plugin + > DSL, read the DSL reference documentation. Kotlin script also relies + > on the underlying Gradle Kotlin DSL + > + > When starting a new project, Android Studio automatically creates some + > of these files for you and populates them based on sensible defaults. + > For an overview of the created files, see Android build structure. + > + > =D0=BF=D0=BD, 24 =D0=BB=D1=8E=D1=82. 2025=E2=80=AF=D1=80. =D0=BE 17:01 De= + n at FlowCrypt =D0=BF=D0=B8=D1=88=D0=B5: + > > + > > reply 1 + > > + > > Build types define certain properties that Gradle uses when building + > > and packaging your app. Build types are typically configured for + > > different stages of your development lifecycle. + > > + > > For example, the debug build type enables debug options and signs the + > > app with the debug key, while the release build type may shrink, + > > obfuscate, and sign your app with a release key for distribution. + > > + > > You must define at least one build type to build your app. Android + > > Studio creates the debug and release build types by default. To start + > > customizing packaging settings for your app, learn how to configure + > > build types. + > > + > > + > > On Mon, Feb 24, 2025 at 5:00=E2=80=AFPM DenBond7 wrote: + > > > + > > > 1 + > > > + > > > The Android build system compiles app resources and source code and + > > > packages them into APKs or Android App Bundles that you can test, + > > > deploy, sign, and distribute. + > > > + > > > In Gradle build overview and Android build structure, we discussed + > > > build concepts and the structure of an Android app. Now it's time to + > > > configure the build. + > > > + > > > + > > > -- + > > > Regards, + > > > Denys Bondarenko + > > + > > + > > + > > -- + > > Regards, + > > Den at FlowCrypt + > + > + > + > -- + > Regards, + > Denys Bondarenko + + + + --=20 + Regards, + Den at FlowCrypt""".trimIndent() + + val processedMimeMessageResult = runBlocking { + PgpMsg.processMimeMessage( + MimeMessage(Session.getInstance(Properties()), mimeMessageRaw.toInputStream()), + PGPPublicKeyRingCollection(listOf()), + PGPSecretKeyRingCollection(listOf()), + SecretKeyRingProtector.unprotectedKeys(), + ) + } + + assertEquals(1, processedMimeMessageResult.blocks.size) + + val plainHtmlBlock = processedMimeMessageResult.blocks.first { + it.type == MsgBlock.Type.PLAIN_HTML + } + + val document = Jsoup.parse(requireNotNull(plainHtmlBlock.content), "", Parser.xmlParser()) + assertNotNull(document.select("details").first()) + assertEquals(1, document.select("details").size) + assertNotNull(document.select("summary").first()) + assertEquals(1, document.select("summary").size) + + val quotes = document.select("blockquote") + assertEquals(3, quotes.size) + assertTrue(quotes[0].text().startsWith("2 Creating custom build configurations requires")) + assertTrue(quotes[1].text().startsWith("reply 1")) + assertTrue(quotes[2].text().startsWith("1 The Android build system")) + } + private data class RenderedBlock( val rendered: Boolean, val frameColor: String?,