Skip to content

Commit 283017f

Browse files
authored
quotes for the plain text version + refactoring for #2957 (#2962)
* wip * wip * wip * wip * wip * Added PgpMsgTest.testQuotesParsingAndHtmlManipulationForPlainMode().| #2961 * Removed unused code
1 parent ac00bd4 commit 283017f

3 files changed

Lines changed: 219 additions & 4 deletions

File tree

  • FlowCrypt/src

FlowCrypt/src/main/java/com/flowcrypt/email/extensions/com/google/api/services/gmail/model/ThreadExt.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@ import jakarta.mail.internet.InternetAddress
2020
*/
2121
fun Thread.getUniqueRecipients(account: String): List<InternetAddress> {
2222
return mutableListOf<InternetAddress>().apply {
23-
if (messages == null || messages.isEmpty()) {
23+
if (messages.isNullOrEmpty()) {
2424
return@apply
2525
}
26+
2627
val fromHeaderName = "From"
2728

2829
val filteredHeaders = if (messages.size > 1) {
@@ -42,7 +43,7 @@ fun Thread.getUniqueRecipients(account: String): List<InternetAddress> {
4243
} else emptyList()
4344
}.ifEmpty {
4445
//otherwise we will use all recipients
45-
messages.flatMap { message -> message.filterHeadersWithName(fromHeaderName) }
46+
messages.flatMap { it.filterHeadersWithName(fromHeaderName) }
4647
}
4748
} else {
4849
messages.first().filterHeadersWithName(fromHeaderName)

FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpMsg.kt

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import org.json.JSONObject
5959
import org.jsoup.Jsoup
6060
import org.jsoup.nodes.Document
6161
import org.jsoup.nodes.Element
62+
import org.jsoup.nodes.Entities
6263
import org.jsoup.nodes.TextNode
6364
import org.jsoup.parser.Parser
6465
import org.owasp.html.HtmlPolicyBuilder
@@ -530,7 +531,7 @@ object PgpMsg {
530531
if (dirtyHtml == null) return null
531532

532533
val originalDocument = Jsoup.parse(dirtyHtml, "", Parser.xmlParser())
533-
originalDocument.select("div.gmail_quote").firstOrNull()?.let { element ->
534+
originalDocument.select("div.gmail_quote,div.flowcrypt_quote").firstOrNull()?.let { element ->
534535
//we wrap Gmail quote with 'details' tag
535536
val generation = Element("details").apply {
536537
appendChild(Element("summary"))
@@ -1214,7 +1215,7 @@ object PgpMsg {
12141215

12151216
MsgBlock.Type.PLAIN_TEXT -> {
12161217
val html = fmtMsgContentBlockAsHtml(
1217-
content.toEscapedHtml(),
1218+
checkAndReturnQuotesFormatIfFound(content) ?: content.toEscapedHtml(),
12181219
if (block.isOpenPGPMimeSigned) FrameColor.GRAY else FrameColor.PLAIN
12191220
)
12201221
msgContentAsHtml.append(html)
@@ -1304,6 +1305,92 @@ object PgpMsg {
13041305
)
13051306
}
13061307

1308+
private fun checkAndReturnQuotesFormatIfFound(content: String): String? {
1309+
return buildQuotes(originalContent = content, unwrapContent = false)?.outerHtml()
1310+
}
1311+
1312+
private fun buildQuotes(originalContent: String, unwrapContent: Boolean = true): Element? {
1313+
val content = if (unwrapContent) {
1314+
//remove > at the beginning of all lines to define next quotes level
1315+
val patternQuotesSign = "^>([^\\S\\r\\n])?".toRegex(RegexOption.MULTILINE)
1316+
originalContent.replace(patternQuotesSign, "")
1317+
} else {
1318+
originalContent
1319+
}
1320+
1321+
val newLineStringPattern = "\\r\\n|\\r|\\n"
1322+
val beforeQuotesHeaderStringPattern = "^.*:($newLineStringPattern){1,2}"
1323+
val patternQuotes = (if (unwrapContent) {
1324+
"(^>.*\$($newLineStringPattern))+"
1325+
} else {
1326+
"($beforeQuotesHeaderStringPattern)(^>.*\$($newLineStringPattern))+"
1327+
}).toRegex(RegexOption.MULTILINE)
1328+
val tagDiv = "div"
1329+
val tagBlockquote = "blockquote"
1330+
1331+
val matchingResult = patternQuotes.find(content)?.groups?.firstOrNull()
1332+
?: return Element(tagDiv).apply {
1333+
append(prepareHtmlFromGivenText(content))
1334+
}.takeIf { unwrapContent }
1335+
val quotes = matchingResult.value
1336+
1337+
return Element(tagDiv).apply {
1338+
//prepend text before quotes
1339+
if (matchingResult.range.first > 0) {
1340+
prepend(prepareHtmlFromGivenText(content.substring(0, matchingResult.range.first)))
1341+
}
1342+
1343+
//append quotes
1344+
if (unwrapContent) {
1345+
appendChild(
1346+
Element(tagBlockquote).apply {
1347+
buildQuotes(quotes)?.let { appendChild(it) }
1348+
}
1349+
)
1350+
} else {
1351+
appendChild(
1352+
Element(tagDiv).apply {
1353+
attr("class", "flowcrypt_quote")
1354+
//for better UI experience we need to extract the quote header of the first quote
1355+
//and add it separately
1356+
val quotesHeader =
1357+
quotes.replace("(^>.*\$($newLineStringPattern))+".toRegex(RegexOption.MULTILINE), "")
1358+
append(prepareHtmlFromGivenText(quotesHeader))
1359+
1360+
appendChild(
1361+
Element(tagBlockquote).apply {
1362+
//here we should pass clear quotes and drop the first quote header
1363+
buildQuotes(quotes.replaceFirst(quotesHeader, ""))?.let { appendChild(it) }
1364+
}
1365+
)
1366+
}
1367+
)
1368+
}
1369+
1370+
//append text after quotes
1371+
if (matchingResult.range.last < content.length) {
1372+
append(
1373+
prepareHtmlFromGivenText(content.substring(matchingResult.range.last + 1, content.length))
1374+
)
1375+
}
1376+
}
1377+
}
1378+
1379+
private fun prepareHtmlFromGivenText(content: String): String {
1380+
val newLineStringPattern = "\\r\\n|\\r|\\n"
1381+
val patternNewLine = "($newLineStringPattern)".toRegex()
1382+
val patternEscapedEmailAddress = "&lt;(\\S+@\\S+)&gt;".toRegex()
1383+
val emailAddressReplacement = "<a href=mailto:\$1>\$1</a>"
1384+
val br = "<br>"
1385+
return Entities
1386+
//escape given text to fit HTML standard
1387+
.escape(content)
1388+
//Prepare <a href> for email addresses.
1389+
.replace(patternEscapedEmailAddress, emailAddressReplacement)
1390+
//Replace CRLF with <br> to transform to HTML.
1391+
.replace(patternNewLine, br)
1392+
}
1393+
13071394
/**
13081395
* replace content of images: <img src="cid:16c7a8c3c6a8d4ab1e01">
13091396
*/

FlowCrypt/src/test/java/com/flowcrypt/email/security/pgp/PgpMsgTest.kt

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,133 @@ class PgpMsgTest {
615615
assertEquals(1, document.select("summary").size)
616616
}
617617

618+
@Test
619+
fun testQuotesParsingAndHtmlManipulationForPlainMode() {
620+
val mimeMessageRaw = """
621+
MIME-Version: 1.0
622+
Date: Mon, 24 Feb 2025 17:02:47 +0200
623+
Message-ID: <messageid@flowcrypt.test>
624+
Subject: Re: Quotes for plain text
625+
From: Den at FlowCrypt <den@flowcrypt.test>
626+
To: DenBond7 <denbond7@flowcrypt.test>
627+
Content-Type: text/plain; charset="UTF-8"
628+
Content-Transfer-Encoding: quoted-printable
629+
630+
reply 2
631+
632+
The top-level build.gradle.kts file (for the Kotlin DSL) or
633+
build.gradle file (for the Groovy DSL) is located in the root project
634+
directory. It typically defines the common versions of plugins used by
635+
modules in your project.
636+
637+
The following code sample describes the default settings and DSL
638+
elements in the top-level build script after creating a new project
639+
640+
On Mon, Feb 24, 2025 at 5:02=E2=80=AFPM DenBond7 <denbond7@flowcrypt.tes=
641+
t> wrote:
642+
>
643+
> 2
644+
>
645+
> Creating custom build configurations requires you to make changes to
646+
> one or more build configuration files. These plain-text files use a
647+
> domain-specific language (DSL) to describe and manipulate the build
648+
> logic using Kotlin script, which is a flavor of the Kotlin language.
649+
> You can also use Groovy, which is a dynamic language for the Java
650+
> Virtual Machine (JVM), to configure your builds.
651+
>
652+
> You don't need to know Kotlin script or Groovy to start configuring
653+
> your build because the Android Gradle plugin introduces most of the
654+
> DSL elements you need. To learn more about the Android Gradle plugin
655+
> DSL, read the DSL reference documentation. Kotlin script also relies
656+
> on the underlying Gradle Kotlin DSL
657+
>
658+
> When starting a new project, Android Studio automatically creates some
659+
> of these files for you and populates them based on sensible defaults.
660+
> For an overview of the created files, see Android build structure.
661+
>
662+
> =D0=BF=D0=BD, 24 =D0=BB=D1=8E=D1=82. 2025=E2=80=AF=D1=80. =D0=BE 17:01 De=
663+
n at FlowCrypt <den@flowcrypt.test> =D0=BF=D0=B8=D1=88=D0=B5:
664+
> >
665+
> > reply 1
666+
> >
667+
> > Build types define certain properties that Gradle uses when building
668+
> > and packaging your app. Build types are typically configured for
669+
> > different stages of your development lifecycle.
670+
> >
671+
> > For example, the debug build type enables debug options and signs the
672+
> > app with the debug key, while the release build type may shrink,
673+
> > obfuscate, and sign your app with a release key for distribution.
674+
> >
675+
> > You must define at least one build type to build your app. Android
676+
> > Studio creates the debug and release build types by default. To start
677+
> > customizing packaging settings for your app, learn how to configure
678+
> > build types.
679+
> >
680+
> >
681+
> > On Mon, Feb 24, 2025 at 5:00=E2=80=AFPM DenBond7 <denbond7@flowcrypt.tes=
682+
t> wrote:
683+
> > >
684+
> > > 1
685+
> > >
686+
> > > The Android build system compiles app resources and source code and
687+
> > > packages them into APKs or Android App Bundles that you can test,
688+
> > > deploy, sign, and distribute.
689+
> > >
690+
> > > In Gradle build overview and Android build structure, we discussed
691+
> > > build concepts and the structure of an Android app. Now it's time to
692+
> > > configure the build.
693+
> > >
694+
> > >
695+
> > > --
696+
> > > Regards,
697+
> > > Denys Bondarenko
698+
> >
699+
> >
700+
> >
701+
> > --
702+
> > Regards,
703+
> > Den at FlowCrypt
704+
>
705+
>
706+
>
707+
> --
708+
> Regards,
709+
> Denys Bondarenko
710+
711+
712+
713+
--=20
714+
Regards,
715+
Den at FlowCrypt""".trimIndent()
716+
717+
val processedMimeMessageResult = runBlocking {
718+
PgpMsg.processMimeMessage(
719+
MimeMessage(Session.getInstance(Properties()), mimeMessageRaw.toInputStream()),
720+
PGPPublicKeyRingCollection(listOf()),
721+
PGPSecretKeyRingCollection(listOf()),
722+
SecretKeyRingProtector.unprotectedKeys(),
723+
)
724+
}
725+
726+
assertEquals(1, processedMimeMessageResult.blocks.size)
727+
728+
val plainHtmlBlock = processedMimeMessageResult.blocks.first {
729+
it.type == MsgBlock.Type.PLAIN_HTML
730+
}
731+
732+
val document = Jsoup.parse(requireNotNull(plainHtmlBlock.content), "", Parser.xmlParser())
733+
assertNotNull(document.select("details").first())
734+
assertEquals(1, document.select("details").size)
735+
assertNotNull(document.select("summary").first())
736+
assertEquals(1, document.select("summary").size)
737+
738+
val quotes = document.select("blockquote")
739+
assertEquals(3, quotes.size)
740+
assertTrue(quotes[0].text().startsWith("2 Creating custom build configurations requires"))
741+
assertTrue(quotes[1].text().startsWith("reply 1"))
742+
assertTrue(quotes[2].text().startsWith("1 The Android build system"))
743+
}
744+
618745
private data class RenderedBlock(
619746
val rendered: Boolean,
620747
val frameColor: String?,

0 commit comments

Comments
 (0)