Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ import jakarta.mail.internet.InternetAddress
*/
fun Thread.getUniqueRecipients(account: String): List<InternetAddress> {
return mutableListOf<InternetAddress>().apply {
if (messages == null || messages.isEmpty()) {
if (messages.isNullOrEmpty()) {
return@apply
}

val fromHeaderName = "From"

val filteredHeaders = if (messages.size > 1) {
Expand All @@ -42,7 +43,7 @@ fun Thread.getUniqueRecipients(account: String): List<InternetAddress> {
} 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 = "&lt;(\\S+@\\S+)&gt;".toRegex()
val emailAddressReplacement = "<a href=mailto:\$1>\$1</a>"
val br = "<br>"
return Entities
//escape given text to fit HTML standard
.escape(content)
//Prepare <a href> for email addresses.
.replace(patternEscapedEmailAddress, emailAddressReplacement)
//Replace CRLF with <br> to transform to HTML.
.replace(patternNewLine, br)
}

/**
* replace content of images: <img src="cid:16c7a8c3c6a8d4ab1e01">
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: <messageid@flowcrypt.test>
Subject: Re: Quotes for plain text
From: Den at FlowCrypt <den@flowcrypt.test>
To: DenBond7 <denbond7@flowcrypt.test>
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 <denbond7@flowcrypt.tes=
t> 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 <den@flowcrypt.test> =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 <denbond7@flowcrypt.tes=
t> 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?,
Expand Down