Add `GenericUriParser`

Remove support for linkifying `bitcoin:` and `ethereum:` URIs. The time where crypto currencies were a harmless playground for nerds are long gone.

Add a `UriParser` that matches generic URIs. We can use this to support linkifying arbitrary URI schemes. See #6545.

Closes #2265

Add support for linkifying more URI schemes
We currently linkify HTTP URLs and Bitcoin URIs when converting plain text to HTML. It'd be nice to have support for additional URI schemes. See #684.

* `bridge:` - Tor bridge
* `geo:` - Physical location
* `market:` - Link to app in Google Play (previously Android Market)
* `openpgp4fpr:` - OpenPGP key IDs and fingerprints
* `xmpp:` - Identify or interact with entities using XMPP

However, I'm concerned about the performance impact of this. Ideally we'd use something similar to [linkifyjs](https://github.com/SoapBox/linkifyjs) that goes through the text only once to find all URIs.

We might also want to think about linkifying supported URIs in HTML parts. That's bad when e.g. malicious URLs have been left unlinkified intentionally. But I believe in the majority of cases linkifying is what is expected by users.

I will like to give this a try. Can I work on this?
@karishnu: This issue is probably not suitable for a beginner.
We could add a generic URI ([RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986)) parser and allow the user to specify which URI schemes they want to have linkified.

./gradlew :app:core:testDebugUnitTest --tests "com.fsck.k9.message.html.BitcoinUriParserTest" --tests "com.fsck.k9.message.html.EthereumUriParserTest" --tests "com.fsck.k9.message.html.GenericUriParserTest"

diff --git a/app/core/src/main/java/com/fsck/k9/message/html/BitcoinUriParser.java b/app/core/src/main/java/com/fsck/k9/message/html/BitcoinUriParser.java
deleted file mode 100644
index 632231a00a4..00000000000
--- a/app/core/src/main/java/com/fsck/k9/message/html/BitcoinUriParser.java
+++ /dev/null
@@ -1,29 +0,0 @@
-package com.fsck.k9.message.html;
-
-
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-import org.jetbrains.annotations.NotNull;
-import org.jetbrains.annotations.Nullable;
-
-
-class BitcoinUriParser implements UriParser {
-    private static final Pattern BITCOIN_URI_PATTERN =
-            Pattern.compile("bitcoin:[1-9a-km-zA-HJ-NP-Z]{27,34}(\\?[a-zA-Z0-9$\\-_.+!*'(),%:@&=]*)?");
-
-    @Nullable
-    @Override
-    public UriMatch parseUri(@NotNull CharSequence text, int startPos) {
-        Matcher matcher = BITCOIN_URI_PATTERN.matcher(text);
-
-        if (!matcher.find(startPos) || matcher.start() != startPos) {
-            return null;
-        }
-
-        int startIndex = matcher.start();
-        int endIndex = matcher.end();
-        CharSequence uri = text.subSequence(startIndex, endIndex);
-        return new UriMatch(startIndex, endIndex, uri);
-    }
-}
diff --git a/app/core/src/main/java/com/fsck/k9/message/html/EthereumUriParser.java b/app/core/src/main/java/com/fsck/k9/message/html/EthereumUriParser.java
deleted file mode 100644
index 70f5878a23c..00000000000
--- a/app/core/src/main/java/com/fsck/k9/message/html/EthereumUriParser.java
+++ /dev/null
@@ -1,33 +0,0 @@
-package com.fsck.k9.message.html;
-
-
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-import org.jetbrains.annotations.NotNull;
-import org.jetbrains.annotations.Nullable;
-
-
-/**
- * Parses ERC-67 URIs
- * https://github.com/ethereum/EIPs/issues/67
- */
-class EthereumUriParser implements UriParser {
-    private static final Pattern ETHEREUM_URI_PATTERN =
-            Pattern.compile("ethereum:0x[0-9a-fA-F]*(\\?[a-zA-Z0-9$\\-_.+!*'(),%:@&=]*)?");
-
-    @Nullable
-    @Override
-    public UriMatch parseUri(@NotNull CharSequence text, int startPos) {
-        Matcher matcher = ETHEREUM_URI_PATTERN.matcher(text);
-
-        if (!matcher.find(startPos) || matcher.start() != startPos) {
-            return null;
-        }
-
-        int startIndex = matcher.start();
-        int endIndex = matcher.end();
-        CharSequence uri = text.subSequence(startIndex, endIndex);
-        return new UriMatch(startIndex, endIndex, uri);
-    }
-}
diff --git a/app/core/src/main/java/com/fsck/k9/message/html/GenericUriParser.kt b/app/core/src/main/java/com/fsck/k9/message/html/GenericUriParser.kt
new file mode 100644
index 00000000000..14cede4cdc2
--- /dev/null
+++ b/app/core/src/main/java/com/fsck/k9/message/html/GenericUriParser.kt
@@ -0,0 +1,34 @@
+package com.fsck.k9.message.html
+
+import java.util.regex.Pattern
+
+/**
+ * Matches the URI generic syntax.
+ *
+ * See [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986).
+ */
+class GenericUriParser : UriParser {
+    override fun parseUri(text: CharSequence, startPos: Int): UriMatch? {
+        val matcher = PATTERN.matcher(text)
+        if (!matcher.find(startPos) || matcher.start() != startPos) return null
+
+        val startIndex = matcher.start()
+        val endIndex = matcher.end()
+        val uri = text.subSequence(startIndex, endIndex)
+
+        return UriMatch(startIndex, endIndex, uri)
+    }
+
+    companion object {
+        private const val SCHEME = "[a-zA-Z][a-zA-Z0-9+.\\-]*"
+        private const val AUTHORITY = "[a-zA-Z0-9\\-._~%!\$&'()*+,;=:\\[\\]@]*"
+        private const val PATH = "[a-zA-Z0-9\\-._~%!\$&'()*+,;=:@/]*"
+        private const val QUERY = "[a-zA-Z0-9\\-._~%!\$&'()*+,;=:@/?]*"
+        private const val FRAGMENT = "[a-zA-Z0-9\\-._~%!\$&'()*+,;=:@/?]*"
+
+        // This regular expression matches more than allowed by the generic URI syntax. So we might end up linkifying
+        // text that is not a proper URI. We leave apps actually handling the URI when the user clicks on such a link
+        // to deal with this case.
+        private val PATTERN = Pattern.compile("$SCHEME:(?://$AUTHORITY)?(?:$PATH)?(?:\\?$QUERY)?(?:#$FRAGMENT)?")
+    }
+}
diff --git a/app/core/src/main/java/com/fsck/k9/message/html/UriMatcher.kt b/app/core/src/main/java/com/fsck/k9/message/html/UriMatcher.kt
index 5dea02560e4..44329acfdb6 100644
--- a/app/core/src/main/java/com/fsck/k9/message/html/UriMatcher.kt
+++ b/app/core/src/main/java/com/fsck/k9/message/html/UriMatcher.kt
@@ -3,12 +3,14 @@ package com.fsck.k9.message.html
 object UriMatcher {
     private val SUPPORTED_URIS = run {
         val httpUriParser = HttpUriParser()
+        val genericUriParser = GenericUriParser()
         mapOf(
-            "ethereum:" to EthereumUriParser(),
-            "bitcoin:" to BitcoinUriParser(),
             "http:" to httpUriParser,
             "https:" to httpUriParser,
-            "rtsp:" to httpUriParser
+            "mailto:" to genericUriParser,
+            "matrix:" to genericUriParser,
+            "rtsp:" to httpUriParser,
+            "xmpp:" to genericUriParser,
         )
     }

diff --git a/app/core/src/test/java/com/fsck/k9/message/html/BitcoinUriParserTest.java b/app/core/src/test/java/com/fsck/k9/message/html/BitcoinUriParserTest.java
deleted file mode 100644
index 0478024cefe..00000000000
--- a/app/core/src/test/java/com/fsck/k9/message/html/BitcoinUriParserTest.java
+++ /dev/null
@@ -1,73 +0,0 @@
-package com.fsck.k9.message.html;
-
-
-import org.junit.Test;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertNull;
-
-
-public class BitcoinUriParserTest {
-    BitcoinUriParser parser = new BitcoinUriParser();
-
-
-    @Test
-    public void basicBitcoinUri() throws Exception {
-        assertValidUri("bitcoin:19W6QZkx8SYPG7BBCS7odmWGRxqRph5jFU");
-    }
-
-    @Test
-    public void bitcoinUriWithAmount() throws Exception {
-        assertValidUri("bitcoin:12A1MyfXbW6RhdRAZEqofac5jCQQjwEPBu?amount=1.2");
-    }
-
-    @Test
-    public void bitcoinUriWithQueryParameters() throws Exception {
-        assertValidUri("bitcoin:12A1MyfXbW6RhdRAZEqofac5jCQQjwEPBu?amount=1.2" +
-                "&message=Payment&label=Satoshi&extra=other-param");
-    }
-
-    @Test
-    public void uriInMiddleOfInput() throws Exception {
-        String prefix = "prefix ";
-        String uri = "bitcoin:12A1MyfXbW6RhdRAZEqofac5jCQQjwEPBu?amount=1.2";
-        String text = prefix + uri;
-
-        UriMatch uriMatch = parser.parseUri(text, prefix.length());
-
-        assertUriMatch(uri, uriMatch, prefix.length());
-    }
-
-    @Test
-    public void invalidScheme() throws Exception {
-        assertInvalidUri("bitcion:19W6QZkx8SYPG7BBCS7odmWGRxqRph5jFU");
-    }
-
-    @Test
-    public void invalidAddress() throws Exception {
-        assertInvalidUri("bitcoin:[invalid]");
-    }
-
-
-    private void assertValidUri(String uri) {
-        UriMatch uriMatch = parser.parseUri(uri, 0);
-        assertUriMatch(uri, uriMatch);
-    }
-
-    private void assertUriMatch(String uri, UriMatch uriMatch) {
-        assertUriMatch(uri, uriMatch, 0);
-    }
-
-    private void assertUriMatch(String uri, UriMatch uriMatch, int offset) {
-        assertNotNull(uriMatch);
-        assertEquals(offset, uriMatch.getStartIndex());
-        assertEquals(uri.length() + offset, uriMatch.getEndIndex());
-        assertEquals(uri, uriMatch.getUri().toString());
-    }
-
-    private void assertInvalidUri(String text) {
-        UriMatch uriMatch = parser.parseUri(text, 0);
-        assertNull(uriMatch);
-    }
-}
diff --git a/app/core/src/test/java/com/fsck/k9/message/html/EthereumUriParserTest.java b/app/core/src/test/java/com/fsck/k9/message/html/EthereumUriParserTest.java
deleted file mode 100644
index 9927f329c41..00000000000
--- a/app/core/src/test/java/com/fsck/k9/message/html/EthereumUriParserTest.java
+++ /dev/null
@@ -1,73 +0,0 @@
-package com.fsck.k9.message.html;
-
-
-import org.junit.Test;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertNull;
-
-
-public class EthereumUriParserTest {
-    EthereumUriParser parser = new EthereumUriParser();
-
-
-    @Test
-    public void basicEthereumUri() throws Exception {
-        assertValidUri("ethereum:0xfdf1210fc262c73d0436236a0e07be419babbbc4");
-    }
-
-    @Test
-    public void ethereumUriWithValue() throws Exception {
-        assertValidUri("ethereum:0xfdf1210fc262c73d0436236a0e07be419babbbc4?value=42");
-    }
-
-    @Test
-    public void ethereumUriWithQueryParameters() throws Exception {
-        assertValidUri("ethereum:0xfdf1210fc262c73d0436236a0e07be419babbbc4?value=42" +
-                "&gas=100000&bytecode=0xa9059cbb0000000000000000000000000000000dead");
-    }
-
-    @Test
-    public void uriInMiddleOfInput() throws Exception {
-        String prefix = "prefix ";
-        String uri = "ethereum:0xfdf1210fc262c73d0436236a0e07be419babbbc4?value=42";
-        String text = prefix + uri;
-
-        UriMatch uriMatch = parser.parseUri(text, prefix.length());
-
-        assertUriMatch(uri, uriMatch, prefix.length());
-    }
-
-    @Test
-    public void invalidScheme() throws Exception {
-        assertInvalidUri("ethereMU:0xfdf1210fc262c73d0436236a0e07be419babbbc4");
-    }
-
-    @Test
-    public void invalidAddress() throws Exception {
-        assertInvalidUri("ethereum:[invalid]");
-    }
-
-
-    private void assertValidUri(String uri) {
-        UriMatch uriMatch = parser.parseUri(uri, 0);
-        assertUriMatch(uri, uriMatch);
-    }
-
-    private void assertUriMatch(String uri, UriMatch uriMatch) {
-        assertUriMatch(uri, uriMatch, 0);
-    }
-
-    private void assertUriMatch(String uri, UriMatch uriMatch, int offset) {
-        assertNotNull(uriMatch);
-        assertEquals(offset, uriMatch.getStartIndex());
-        assertEquals(uri.length() + offset, uriMatch.getEndIndex());
-        assertEquals(uri, uriMatch.getUri().toString());
-    }
-
-    private void assertInvalidUri(String text) {
-        UriMatch uriMatch = parser.parseUri(text, 0);
-        assertNull(uriMatch);
-    }
-}
diff --git a/app/core/src/test/java/com/fsck/k9/message/html/GenericUriParserTest.kt b/app/core/src/test/java/com/fsck/k9/message/html/GenericUriParserTest.kt
new file mode 100644
index 00000000000..4dc0b1af15d
--- /dev/null
+++ b/app/core/src/test/java/com/fsck/k9/message/html/GenericUriParserTest.kt
@@ -0,0 +1,71 @@
+package com.fsck.k9.message.html
+
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertNotNull
+import org.junit.Test
+
+class GenericUriParserTest {
+    private val parser = GenericUriParser()
+
+    @Test
+    fun `mailto URIs`() {
+        // Examples from RFC 6068
+        assertUriValid("mailto:chris@example.com")
+        assertUriValid("mailto:infobot@example.com?subject=current-issue")
+        assertUriValid("mailto:infobot@example.com?body=send%20current-issue")
+        assertUriValid("mailto:infobot@example.com?body=send%20current-issue%0D%0Asend%20index")
+        assertUriValid("mailto:list@example.org?In-Reply-To=%3C3469A91.D10AF4C@example.com%3E")
+        assertUriValid("mailto:majordomo@example.com?body=subscribe%20bamboo-l")
+        assertUriValid("mailto:joe@example.com?cc=bob@example.com&body=hello")
+        assertUriValid("mailto:gorby%25kremvax@example.com")
+        assertUriValid("mailto:unlikely%3Faddress@example.com?blat=foop")
+        assertUriValid("mailto:%22not%40me%22@example.org")
+        assertUriValid("mailto:%22oh%5C%5Cno%22@example.org")
+        assertUriValid("mailto:%22%5C%5C%5C%22it's%5C%20ugly%5C%5C%5C%22%22@example.org")
+        assertUriValid("mailto:user@example.org?subject=caf%C3%A9")
+        assertUriValid("mailto:user@example.org?subject=%3D%3Futf-8%3FQ%3Fcaf%3DC3%3DA9%3F%3D")
+        assertUriValid("mailto:user@example.org?subject=%3D%3Fiso-8859-1%3FQ%3Fcaf%3DE9%3F%3D")
+        assertUriValid("mailto:user@example.org?subject=caf%C3%A9&body=caf%C3%A9")
+        assertUriValid("mailto:user@%E7%B4%8D%E8%B1%86.example.org?subject=Test&body=NATTO")
+    }
+
+    @Test
+    fun `XMPP URIs`() {
+        // Examples from RFC 5122
+        assertUriValid("xmpp:node@example.com")
+        assertUriValid("xmpp://guest@example.com")
+        assertUriValid("xmpp://guest@example.com/support@example.com?message")
+        assertUriValid("xmpp:support@example.com?message")
+        assertUriValid("xmpp:example-node@example.com/some-resource")
+        assertUriValid("xmpp:example.com")
+        assertUriValid("xmpp:example-node@example.com?message")
+        assertUriValid("xmpp:example-node@example.com?message;subject=Hello%20World")
+        assertUriValid("xmpp:nasty!%23\$%25()*+,-.;=%3F%5B%5C%5D%5E_%60%7B%7C%7D~node@example.com")
+        assertUriValid("xmpp:node@example.com/repulsive%20!%23%22\$%25&'()*+,-.%2F:;%3C=%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~resource")
+        assertUriValid("xmpp:ji%C5%99i@%C4%8Dechy.example/v%20Praze")
+    }
+
+    @Test
+    fun `matrix URIs`() {
+        // Examples from MSC 2312
+        assertUriValid("matrix:r/someroom:example.org")
+        assertUriValid("matrix:u/me:example.org")
+        assertUriValid("matrix:r/someroom:example.org/e/Arbitrary_Event_Id")
+        assertUriValid("matrix:u/her:example.org")
+        assertUriValid("matrix:u/her:example.org?action=chat")
+        assertUriValid("matrix:roomid/rid:example.org")
+        assertUriValid("matrix:r/us:example.org")
+        assertUriValid("matrix:roomid/rid:example.org?action=join&via=example2.org")
+        assertUriValid("matrix:r/us:example.org?action=join")
+        assertUriValid("matrix:r/us:example.org/e/lol823y4bcp3qo4")
+        assertUriValid("matrix:roomid/rid:example.org/event/lol823y4bcp3qo4?via=example2.org")
+    }
+
+    private fun assertUriValid(input: String) {
+        val result = parser.parseUri(input, 0)
+
+        assertNotNull(result) { uriMatch ->
+            assertThat(uriMatch.uri).isEqualTo(input)
+        }
+    }
+}


Add support for search in recipient addresses

Closes #1127

Support search in recipient addresses

Support search in recipient addresses
When I search my inbox for, let's say, `current@freebsd` mails with `current@freebsd.org` in CC won't show up.
With lists were cross-posting is rather common this is a bit annoying.

Please support searching recipient addresses in the "Sent" folder.
I consider this a a critical usability feature.
Thanks

./gradlew :mail:protocols:imap:test --tests "com.fsck.k9.mail.store.imap.RealImapFolderTest" --tests "com.fsck.k9.mail.store.imap.UidSearchCommandBuilderTest"

diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageList.kt b/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageList.kt
index 5189ab58d82..ab3cee1bb85 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageList.kt
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageList.kt
@@ -453,6 +453,9 @@ open class MessageList :
             val search = LocalSearch().apply {
                 isManualSearch = true
                 or(SearchCondition(SearchField.SENDER, SearchSpecification.Attribute.CONTAINS, query))
+                or(SearchCondition(SearchField.TO, SearchSpecification.Attribute.CONTAINS, query))
+                or(SearchCondition(SearchField.CC, SearchSpecification.Attribute.CONTAINS, query))
+                or(SearchCondition(SearchField.BCC, SearchSpecification.Attribute.CONTAINS, query))
                 or(SearchCondition(SearchField.SUBJECT, SearchSpecification.Attribute.CONTAINS, query))
                 or(SearchCondition(SearchField.MESSAGE_CONTENTS, SearchSpecification.Attribute.CONTAINS, query))
             }
diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UidSearchCommandBuilder.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UidSearchCommandBuilder.java
index 631986b4913..65cef9062be 100644
--- a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UidSearchCommandBuilder.java
+++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UidSearchCommandBuilder.java
@@ -50,7 +50,11 @@ private void addQueryString(StringBuilder builder) {
         if (performFullTextSearch) {
             builder.append(" TEXT ").append(encodedQuery);
         } else {
-            builder.append(" OR SUBJECT ").append(encodedQuery).append(" FROM ").append(encodedQuery);
+            builder.append(" OR OR OR OR SUBJECT ").append(encodedQuery)
+                    .append(" FROM ").append(encodedQuery)
+                    .append(" TO ").append(encodedQuery)
+                    .append(" CC ").append(encodedQuery)
+                    .append(" BCC ").append(encodedQuery);
         }
     }

diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapFolderTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapFolderTest.kt
index c74fccfcbac..0ff0ada4a95 100644
--- a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapFolderTest.kt
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapFolderTest.kt
@@ -1066,7 +1066,7 @@ class RealImapFolderTest {

         folder.search("query", emptySet(), emptySet(), false)

-        assertCommandIssued("UID SEARCH OR SUBJECT \"query\" FROM \"query\"")
+        assertCommandIssued("""UID SEARCH OR OR OR OR SUBJECT "query" FROM "query" TO "query" CC "query" BCC "query"""")
     }

     @Test
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidSearchCommandBuilderTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidSearchCommandBuilderTest.java
index eef6e46268e..1d78cea23d5 100644
--- a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidSearchCommandBuilderTest.java
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidSearchCommandBuilderTest.java
@@ -32,6 +32,7 @@ public void build_withoutFullTextSearch() {
                 .queryString("query")
                 .build();

-        assertEquals("UID SEARCH OR SUBJECT \"query\" FROM \"query\" NOT DELETED", command);
+        assertEquals("UID SEARCH OR OR OR OR SUBJECT \"query\" FROM \"query\" TO \"query\" CC \"query\"" +
+                " BCC \"query\" NOT DELETED", command);
     }
 }


Message View Redesign: Reply actions in message view

Closes #6388

Message View Redesign: Add functionality for reply action icon
What needs to be done:
- When an identity of the current account is the only recipient, display the "reply" icon
- In all other cases display the "reply all" icon
- Remove the "primary action" from the overflow menu, so we don't display actions twice.

Note: We'll update the rules to select the primary action when we get to implement #3738.

./gradlew :app:core:testDebugUnitTest --tests "com.fsck.k9.message.ReplyActionStrategyTest"

diff --git a/app/core/src/main/java/com/fsck/k9/message/ReplyActionStrategy.kt b/app/core/src/main/java/com/fsck/k9/message/ReplyActionStrategy.kt
new file mode 100644
index 00000000000..a208b982093
--- /dev/null
+++ b/app/core/src/main/java/com/fsck/k9/message/ReplyActionStrategy.kt
@@ -0,0 +1,36 @@
+package com.fsck.k9.message
+
+import com.fsck.k9.Account
+import com.fsck.k9.helper.ReplyToParser
+import com.fsck.k9.mail.Message
+
+/**
+ * Figures out which reply actions are available to the user.
+ */
+class ReplyActionStrategy(private val replyRoParser: ReplyToParser) {
+    fun getReplyActions(account: Account, message: Message): ReplyActions {
+        val recipientsToReplyTo = replyRoParser.getRecipientsToReplyTo(message, account)
+        val recipientsToReplyAllTo = replyRoParser.getRecipientsToReplyAllTo(message, account)
+
+        val replyToAllCount = recipientsToReplyAllTo.to.size + recipientsToReplyAllTo.cc.size
+        return if (replyToAllCount <= 1) {
+            if (recipientsToReplyTo.to.isEmpty()) {
+                ReplyActions(defaultAction = null)
+            } else {
+                ReplyActions(defaultAction = ReplyAction.REPLY)
+            }
+        } else {
+            ReplyActions(defaultAction = ReplyAction.REPLY_ALL, additionalActions = listOf(ReplyAction.REPLY))
+        }
+    }
+}
+
+data class ReplyActions(
+    val defaultAction: ReplyAction?,
+    val additionalActions: List<ReplyAction> = emptyList()
+)
+
+enum class ReplyAction {
+    REPLY,
+    REPLY_ALL
+}
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageHeaderOnMenuItemClickListener.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageHeaderOnMenuItemClickListener.kt
new file mode 100644
index 00000000000..fc1126d01be
--- /dev/null
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageHeaderOnMenuItemClickListener.kt
@@ -0,0 +1,5 @@
+package com.fsck.k9.ui.messageview
+
+fun interface MessageHeaderOnMenuItemClickListener {
+    fun onMenuItemClick(itemId: Int)
+}
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.java b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.java
index da82cc2dcf8..0329d528e5c 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.java
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.java
@@ -9,7 +9,7 @@
 import android.os.Parcel;
 import android.os.Parcelable;
 import androidx.annotation.NonNull;
-import androidx.appcompat.widget.PopupMenu.OnMenuItemClickListener;
+
 import android.util.AttributeSet;
 import android.view.LayoutInflater;
 import android.view.View;
@@ -226,7 +226,7 @@ public void setOnToggleFlagClickListener(OnClickListener listener) {
         mHeaderContainer.setOnFlagListener(listener);
     }

-    public void setOnMenuItemClickListener(OnMenuItemClickListener listener) {
+    public void setOnMenuItemClickListener(MessageHeaderOnMenuItemClickListener listener) {
         mHeaderContainer.setOnMenuItemClickListener(listener);
     }

diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt
index f7b7d012447..513b97cbf52 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt
@@ -158,9 +158,7 @@ class MessageViewFragment :
             onToggleFlagged()
         }

-        messageTopView.setOnMenuItemClickListener { item ->
-            onReplyMenuItemClicked(item.itemId)
-        }
+        messageTopView.setOnMenuItemClickListener(::onReplyMenuItemClicked)

         messageTopView.setOnDownloadButtonClickListener {
             onDownloadButtonClicked()
@@ -381,7 +379,7 @@ class MessageViewFragment :
         messageTopView.setSubject(displaySubject)
     }

-    private fun onReplyMenuItemClicked(itemId: Int): Boolean {
+    private fun onReplyMenuItemClicked(itemId: Int) {
         when (itemId) {
             R.id.reply -> onReply()
             R.id.reply_all -> onReplyAll()
@@ -390,8 +388,6 @@ class MessageViewFragment :
             R.id.share -> onSendAlternate()
             else -> error("Missing handler for reply menu item $itemId")
         }
-
-        return true
     }

     private fun onDownloadButtonClicked() {
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/view/KoinModule.kt b/app/ui/legacy/src/main/java/com/fsck/k9/view/KoinModule.kt
index d86dd2b718c..269d220fd9c 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/view/KoinModule.kt
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/view/KoinModule.kt
@@ -1,9 +1,13 @@
 package com.fsck.k9.view

+import com.fsck.k9.helper.ReplyToParser
+import com.fsck.k9.message.ReplyActionStrategy
 import com.fsck.k9.ui.helper.RelativeDateTimeFormatter
 import org.koin.dsl.module

 val viewModule = module {
     single { WebViewConfigProvider(themeManager = get()) }
     factory { RelativeDateTimeFormatter(context = get(), clock = get()) }
+    factory { ReplyToParser() }
+    factory { ReplyActionStrategy(replyRoParser = get()) }
 }
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/view/MessageHeader.java b/app/ui/legacy/src/main/java/com/fsck/k9/view/MessageHeader.java
index 94afcd61d59..433051a0221 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/view/MessageHeader.java
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/view/MessageHeader.java
@@ -1,6 +1,8 @@
 package com.fsck.k9.view;

+import java.util.List;
+
 import android.content.Context;
 import android.content.res.ColorStateList;
 import android.util.AttributeSet;
@@ -12,9 +14,9 @@
 import android.widget.TextView;
 import android.widget.Toast;

+import androidx.annotation.DrawableRes;
 import androidx.annotation.NonNull;
 import androidx.appcompat.widget.PopupMenu;
-import androidx.appcompat.widget.PopupMenu.OnMenuItemClickListener;
 import com.fsck.k9.Account;
 import com.fsck.k9.DI;
 import com.fsck.k9.FontSizes;
@@ -26,8 +28,12 @@
 import com.fsck.k9.mail.Address;
 import com.fsck.k9.mail.Flag;
 import com.fsck.k9.mail.Message;
+import com.fsck.k9.message.ReplyAction;
+import com.fsck.k9.message.ReplyActionStrategy;
+import com.fsck.k9.message.ReplyActions;
 import com.fsck.k9.ui.R;
 import com.fsck.k9.ui.helper.RelativeDateTimeFormatter;
+import com.fsck.k9.ui.messageview.MessageHeaderOnMenuItemClickListener;
 import com.google.android.material.chip.Chip;
 import com.google.android.material.snackbar.Snackbar;

@@ -35,6 +41,7 @@
 public class MessageHeader extends LinearLayout implements OnClickListener, OnLongClickListener {
     private static final int DEFAULT_SUBJECT_LINES = 3;

+    private final ReplyActionStrategy replyActionStrategy = DI.get(ReplyActionStrategy.class);
     private final FontSizes fontSizes = K9.getFontSizes();

     private Chip accountChip;
@@ -46,11 +53,13 @@ public class MessageHeader extends LinearLayout implements OnClickListener, OnLo
     private TextView toView;
     private TextView toCountView;
     private TextView dateView;
+    private ImageView menuPrimaryActionView;

     private MessageHelper messageHelper;
     private RelativeDateTimeFormatter relativeDateTimeFormatter;

-    private OnMenuItemClickListener onMenuItemClickListener;
+    private MessageHeaderOnMenuItemClickListener onMenuItemClickListener;
+    private ReplyActions replyActions;

     public MessageHeader(Context context, AttributeSet attrs) {
@@ -85,7 +94,7 @@ protected void onFinishInflate() {
         subjectView.setOnClickListener(this);
         subjectView.setOnLongClickListener(this);

-        View menuPrimaryActionView = findViewById(R.id.menu_primary_action);
+        menuPrimaryActionView = findViewById(R.id.menu_primary_action);
         menuPrimaryActionView.setOnClickListener(this);
         menuPrimaryActionView.setOnLongClickListener(this);

@@ -102,17 +111,46 @@ public void onClick(View view) {
         if (id == R.id.subject) {
             toggleSubjectViewMaxLines();
         } else if (id == R.id.menu_primary_action) {
-            Snackbar.make(getRootView(), "TODO: Perform primary action", Snackbar.LENGTH_LONG).show();
+            performPrimaryReplyAction();
         } else if (id == R.id.menu_overflow) {
-            PopupMenu popupMenu = new PopupMenu(getContext(), view);
-            popupMenu.setOnMenuItemClickListener(onMenuItemClickListener);
-            popupMenu.inflate(R.menu.single_message_options);
-            popupMenu.show();
+            showOverflowMenu(view);
         } else if (id == R.id.participants_container) {
             Snackbar.make(getRootView(), "TODO: Display details popup", Snackbar.LENGTH_LONG).show();
         }
     }

+    private void performPrimaryReplyAction() {
+        ReplyAction defaultAction = replyActions.getDefaultAction();
+        if (defaultAction == null) {
+            return;
+        }
+
+        switch (defaultAction) {
+            case REPLY: {
+                onMenuItemClickListener.onMenuItemClick(R.id.reply);
+                break;
+            }
+            case REPLY_ALL: {
+                onMenuItemClickListener.onMenuItemClick(R.id.reply_all);
+                break;
+            }
+            default: {
+                throw new IllegalStateException("Unknown reply action: " + defaultAction);
+            }
+        }
+    }
+
+    private void showOverflowMenu(View view) {
+        PopupMenu popupMenu = new PopupMenu(getContext(), view);
+        popupMenu.setOnMenuItemClickListener(item -> {
+            onMenuItemClickListener.onMenuItemClick(item.getItemId());
+            return true;
+        });
+        popupMenu.inflate(R.menu.single_message_options);
+        setAdditionalReplyActions(popupMenu);
+        popupMenu.show();
+    }
+
     @Override
     public boolean onLongClick(View view) {
         int id = view.getId();
@@ -190,9 +228,53 @@ public void populate(final Message message, final Account account, boolean showS
             dateView.setText("");
         }

+        setReplyActions(message, account);
+
         setVisibility(View.VISIBLE);
     }

+    private void setReplyActions(Message message, Account account) {
+        ReplyActions replyActions = replyActionStrategy.getReplyActions(account, message);
+        this.replyActions = replyActions;
+
+        setDefaultReplyAction(replyActions.getDefaultAction());
+    }
+
+    private void setDefaultReplyAction(ReplyAction defaultAction) {
+        if (defaultAction == null) {
+            menuPrimaryActionView.setVisibility(View.GONE);
+        } else {
+            int replyIconResource = getReplyImageResource(defaultAction);
+            menuPrimaryActionView.setImageResource(replyIconResource);
+            menuPrimaryActionView.setVisibility(View.VISIBLE);
+        }
+    }
+
+    @DrawableRes
+    private int getReplyImageResource(@NonNull ReplyAction replyAction) {
+        switch (replyAction) {
+            case REPLY: {
+                return R.drawable.ic_reply;
+            }
+            case REPLY_ALL: {
+                return R.drawable.ic_reply_all;
+            }
+            default: {
+                throw new IllegalStateException("Unknown reply action: " + replyAction);
+            }
+        }
+    }
+
+    private void setAdditionalReplyActions(PopupMenu popupMenu) {
+        List<ReplyAction> additionalActions = replyActions.getAdditionalActions();
+        if (!additionalActions.contains(ReplyAction.REPLY)) {
+            popupMenu.getMenu().removeItem(R.id.reply);
+        }
+        if (!additionalActions.contains(ReplyAction.REPLY_ALL)) {
+            popupMenu.getMenu().removeItem(R.id.reply_all);
+        }
+    }
+
     public void setSubject(@NonNull String subject) {
         subjectView.setText(subject);
     }
@@ -221,7 +303,7 @@ private void setCryptoDisplayStatus(MessageCryptoDisplayStatus displayStatus) {
         cryptoStatusIcon.setColorFilter(color);
     }

-    public void setOnMenuItemClickListener(OnMenuItemClickListener onMenuItemClickListener) {
+    public void setOnMenuItemClickListener(MessageHeaderOnMenuItemClickListener onMenuItemClickListener) {
         this.onMenuItemClickListener = onMenuItemClickListener;
     }
 }
diff --git a/app/ui/legacy/src/main/res/drawable/ic_reply.xml b/app/ui/legacy/src/main/res/drawable/ic_reply.xml
new file mode 100644
index 00000000000..bd35ee9e7e1
--- /dev/null
+++ b/app/ui/legacy/src/main/res/drawable/ic_reply.xml
@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:tint="?attr/colorControlNormal"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M10,9V5l-7,7 7,7v-4.1c5,0 8.5,1.6 11,5.1 -1,-5 -4,-10 -11,-11z" />
+</vector>

diff --git a/app/core/src/test/java/com/fsck/k9/message/ReplyActionStrategyTest.kt b/app/core/src/test/java/com/fsck/k9/message/ReplyActionStrategyTest.kt
new file mode 100644
index 00000000000..e366859072e
--- /dev/null
+++ b/app/core/src/test/java/com/fsck/k9/message/ReplyActionStrategyTest.kt
@@ -0,0 +1,111 @@
+package com.fsck.k9.message
+
+import com.fsck.k9.Account
+import com.fsck.k9.Identity
+import com.fsck.k9.helper.ReplyToParser
+import com.fsck.k9.mail.buildMessage
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+private const val IDENTITY_EMAIL_ADDRESS = "myself@domain.example"
+
+class ReplyActionStrategyTest {
+    private val account = createAccount()
+    private val replyActionStrategy = ReplyActionStrategy(ReplyToParser())
+
+    @Test
+    fun `message sent to only our identity`() {
+        val message = buildMessage {
+            header("From", "sender@domain.example")
+            header("To", IDENTITY_EMAIL_ADDRESS)
+        }
+
+        val replyActions = replyActionStrategy.getReplyActions(account, message)
+
+        assertThat(replyActions.defaultAction).isEqualTo(ReplyAction.REPLY)
+        assertThat(replyActions.additionalActions).isEmpty()
+    }
+
+    @Test
+    fun `message sent to our identity and others`() {
+        val message = buildMessage {
+            header("From", "sender@domain.example")
+            header("To", "$IDENTITY_EMAIL_ADDRESS, other@domain.example")
+        }
+
+        val replyActions = replyActionStrategy.getReplyActions(account, message)
+
+        assertThat(replyActions.defaultAction).isEqualTo(ReplyAction.REPLY_ALL)
+        assertThat(replyActions.additionalActions).containsExactly(ReplyAction.REPLY)
+    }
+
+    @Test
+    fun `message sent to our identity and others (CC)`() {
+        val message = buildMessage {
+            header("From", "sender@domain.example")
+            header("Cc", "$IDENTITY_EMAIL_ADDRESS, other@domain.example")
+        }
+
+        val replyActions = replyActionStrategy.getReplyActions(account, message)
+
+        assertThat(replyActions.defaultAction).isEqualTo(ReplyAction.REPLY_ALL)
+        assertThat(replyActions.additionalActions).containsExactly(ReplyAction.REPLY)
+    }
+
+    @Test
+    fun `message sent to our identity and others (To+CC)`() {
+        val message = buildMessage {
+            header("From", "sender@domain.example")
+            header("To", IDENTITY_EMAIL_ADDRESS)
+            header("Cc", "other@domain.example")
+        }
+
+        val replyActions = replyActionStrategy.getReplyActions(account, message)
+
+        assertThat(replyActions.defaultAction).isEqualTo(ReplyAction.REPLY_ALL)
+        assertThat(replyActions.additionalActions).containsExactly(ReplyAction.REPLY)
+    }
+
+    @Test
+    fun `message sent to our identity and others (CC+To)`() {
+        val message = buildMessage {
+            header("From", "sender@domain.example")
+            header("To", "other@domain.example")
+            header("Cc", IDENTITY_EMAIL_ADDRESS)
+        }
+
+        val replyActions = replyActionStrategy.getReplyActions(account, message)
+
+        assertThat(replyActions.defaultAction).isEqualTo(ReplyAction.REPLY_ALL)
+        assertThat(replyActions.additionalActions).containsExactly(ReplyAction.REPLY)
+    }
+
+    @Test
+    fun `message where neither sender nor recipient addresses belong to account`() {
+        val message = buildMessage {
+            header("From", "sender@domain.example")
+            header("To", "recipient@domain.example")
+        }
+
+        val replyActions = replyActionStrategy.getReplyActions(account, message)
+
+        assertThat(replyActions.defaultAction).isEqualTo(ReplyAction.REPLY_ALL)
+        assertThat(replyActions.additionalActions).containsExactly(ReplyAction.REPLY)
+    }
+
+    @Test
+    fun `message without any sender or recipient headers`() {
+        val message = buildMessage {}
+
+        val replyActions = replyActionStrategy.getReplyActions(account, message)
+
+        assertThat(replyActions.defaultAction).isNull()
+        assertThat(replyActions.additionalActions).isEmpty()
+    }
+
+    private fun createAccount(): Account {
+        return Account("00000000-0000-4000-0000-000000000000").apply {
+            identities += Identity(name = "Myself", email = IDENTITY_EMAIL_ADDRESS)
+        }
+    }
+}


Don't throw when calling `MessageStore.getMessageServerId()`

Return `null` when the message can no longer be found in the message store.

Fixes #6432

Crash when attempting to delete a draft message that has already been removed (by another client)
### Checklist

- [X] I have used the search function to see if someone else has already submitted the same bug report.
- [X] I will describe the problem with as much detail as possible.

### App version

6.309

### Where did you get the app from?

Google Play

### Android version

Android 12

### Device model

_No response_

### Steps to reproduce

1. Open a draft message in K-9 Mail
2. Delete the draft message from the server using another client. Make sure this change is synchronized to K-9 Mail while the compose screen is open (e.g. use Push for the Drafts folder)
3. Send the message in K-9 Mail

### Expected behavior

Don't crash

### Actual behavior

Crash

### Logs

Strack trace (via Google Play Developer Console):

```text
Exception java.lang.RuntimeException: An error occurred while executing doInBackground()
  at android.os.AsyncTask$4.done (AsyncTask.java:415)
  at java.util.concurrent.FutureTask.finishCompletion (FutureTask.java:381)
  at java.util.concurrent.FutureTask.setException (FutureTask.java:250)
  at java.util.concurrent.FutureTask.run (FutureTask.java:269)
  at android.os.AsyncTask$SerialExecutor$1.run (AsyncTask.java:305)
  at java.util.concurrent.ThreadPoolExecutor.runWorker (ThreadPoolExecutor.java:1137)
  at java.util.concurrent.ThreadPoolExecutor$Worker.run (ThreadPoolExecutor.java:637)
  at java.lang.Thread.run (Thread.java:1012)
Caused by com.fsck.k9.mail.MessagingException:
  at com.fsck.k9.storage.messages.RetrieveMessageOperations.getMessageServerId$lambda$1 (RetrieveMessageOperations.kt:28)
  at com.fsck.k9.storage.messages.RetrieveMessageOperations.$r8$lambda$InoHsttbjEG6Xq4uHbyl8sOVdZQ
  at com.fsck.k9.storage.messages.RetrieveMessageOperations$$ExternalSyntheticLambda1.doDbWork
  at com.fsck.k9.mailstore.LockableDatabase.execute (LockableDatabase.java:169)
  at com.fsck.k9.storage.messages.RetrieveMessageOperations.getMessageServerId (RetrieveMessageOperations.kt:17)
  at com.fsck.k9.storage.messages.K9MessageStore.getMessageServerId (K9MessageStore.kt:82)
  at com.fsck.k9.mailstore.NotifierMessageStore.getMessageServerId
  at com.fsck.k9.mailstore.ListenableMessageStore.getMessageServerId
  at com.fsck.k9.controller.MessagingController.deleteDraft (MessagingController.java:1925)
  at com.fsck.k9.activity.MessageCompose$SendMessageTask.doInBackground (MessageCompose.java:1503)
  at com.fsck.k9.activity.MessageCompose$SendMessageTask.doInBackground (MessageCompose.java:1464)
  at android.os.AsyncTask$3.call (AsyncTask.java:394)
  at java.util.concurrent.FutureTask.run (FutureTask.java:264)
```

./gradlew :app:storage:testDebugUnitTest --tests "com.fsck.k9.storage.messages.RetrieveMessageOperationsTest"

diff --git a/app/core/src/main/java/com/fsck/k9/controller/DraftOperations.kt b/app/core/src/main/java/com/fsck/k9/controller/DraftOperations.kt
index b613b650e63..83963f052d2 100644
--- a/app/core/src/main/java/com/fsck/k9/controller/DraftOperations.kt
+++ b/app/core/src/main/java/com/fsck/k9/controller/DraftOperations.kt
@@ -75,8 +75,10 @@ internal class DraftOperations(
             messagingController.queuePendingCommand(account, command)
         } else {
             val fakeMessageServerId = messageStore.getMessageServerId(messageId)
-            val command = PendingAppend.create(folderId, fakeMessageServerId)
-            messagingController.queuePendingCommand(account, command)
+            if (fakeMessageServerId != null) {
+                val command = PendingAppend.create(folderId, fakeMessageServerId)
+                messagingController.queuePendingCommand(account, command)
+            }
         }

         messagingController.processPendingCommands(account)
diff --git a/app/core/src/main/java/com/fsck/k9/controller/MessagingController.java b/app/core/src/main/java/com/fsck/k9/controller/MessagingController.java
index 0d9ed85cf1b..8d503e35d2a 100644
--- a/app/core/src/main/java/com/fsck/k9/controller/MessagingController.java
+++ b/app/core/src/main/java/com/fsck/k9/controller/MessagingController.java
@@ -1615,9 +1615,11 @@ private void moveOrDeleteSentMessage(Account account, LocalStore localStore, Loc

             if (!sentFolder.isLocalOnly()) {
                 String destinationUid = messageStore.getMessageServerId(destinationMessageId);
-                PendingCommand command = PendingAppend.create(sentFolderId, destinationUid);
-                queuePendingCommand(account, command);
-                processPendingCommands(account);
+                if (destinationUid != null) {
+                    PendingCommand command = PendingAppend.create(sentFolderId, destinationUid);
+                    queuePendingCommand(account, command);
+                    processPendingCommands(account);
+                }
             }
         }

@@ -1901,9 +1903,10 @@ public void deleteDraft(Account account, long messageId) {

         MessageStore messageStore = messageStoreManager.getMessageStore(account);
         String messageServerId = messageStore.getMessageServerId(messageId);
-        MessageReference messageReference = new MessageReference(account.getUuid(), folderId, messageServerId);
-
-        deleteMessage(messageReference);
+        if (messageServerId != null) {
+            MessageReference messageReference = new MessageReference(account.getUuid(), folderId, messageServerId);
+            deleteMessage(messageReference);
+        }
     }

     public void deleteThreads(final List<MessageReference> messages) {
diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/MessageStore.kt b/app/core/src/main/java/com/fsck/k9/mailstore/MessageStore.kt
index 724f78083a1..75c292e237e 100644
--- a/app/core/src/main/java/com/fsck/k9/mailstore/MessageStore.kt
+++ b/app/core/src/main/java/com/fsck/k9/mailstore/MessageStore.kt
@@ -91,7 +91,7 @@ interface MessageStore {
     /**
      * Retrieve the server ID for a given message.
      */
-    fun getMessageServerId(messageId: Long): String
+    fun getMessageServerId(messageId: Long): String?

     /**
      * Retrieve the server IDs for the given messages.
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/K9MessageStore.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/K9MessageStore.kt
index ee69f27d90a..ca1e7dec6ab 100644
--- a/app/storage/src/main/java/com/fsck/k9/storage/messages/K9MessageStore.kt
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/K9MessageStore.kt
@@ -78,7 +78,7 @@ class K9MessageStore(
         updateMessageOperations.clearNewMessageState()
     }

-    override fun getMessageServerId(messageId: Long): String {
+    override fun getMessageServerId(messageId: Long): String? {
         return retrieveMessageOperations.getMessageServerId(messageId)
     }

diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageOperations.kt
index a57bfee5411..c094ae3b3c2 100644
--- a/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageOperations.kt
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageOperations.kt
@@ -4,7 +4,6 @@ import androidx.core.database.getLongOrNull
 import com.fsck.k9.K9
 import com.fsck.k9.mail.Flag
 import com.fsck.k9.mail.Header
-import com.fsck.k9.mail.MessagingException
 import com.fsck.k9.mail.internet.MimeHeader
 import com.fsck.k9.mail.message.MessageHeaderParser
 import com.fsck.k9.mailstore.LockableDatabase
@@ -13,7 +12,7 @@ import java.util.Date

 internal class RetrieveMessageOperations(private val lockableDatabase: LockableDatabase) {

-    fun getMessageServerId(messageId: Long): String {
+    fun getMessageServerId(messageId: Long): String? {
         return lockableDatabase.execute(false) { database ->
             database.query(
                 "messages",
@@ -25,7 +24,7 @@ internal class RetrieveMessageOperations(private val lockableDatabase: LockableD
                 if (cursor.moveToFirst()) {
                     cursor.getString(0)
                 } else {
-                    throw MessagingException("Message [ID: $messageId] not found in database")
+                    null
                 }
             }
         }

diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageOperationsTest.kt
index 5825dccec9d..09dccb31d70 100644
--- a/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageOperationsTest.kt
+++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageOperationsTest.kt
@@ -2,7 +2,6 @@ package com.fsck.k9.storage.messages

 import com.fsck.k9.mail.Flag
 import com.fsck.k9.mail.Header
-import com.fsck.k9.mail.MessagingException
 import com.fsck.k9.mail.crlf
 import com.fsck.k9.storage.RobolectricTest
 import com.google.common.truth.Truth.assertThat
@@ -14,9 +13,11 @@ class RetrieveMessageOperationsTest : RobolectricTest() {
     private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase)
     private val retrieveMessageOperations = RetrieveMessageOperations(lockableDatabase)

-    @Test(expected = MessagingException::class)
+    @Test
     fun `get message server id of non-existent message`() {
-        retrieveMessageOperations.getMessageServerId(42)
+        val messageServerId = retrieveMessageOperations.getMessageServerId(42)
+
+        assertThat(messageServerId).isNull()
     }

     @Test


Keep `<tt>` tags when sanitizing HTML

Fixes #6423

Render in HTML <tt> with monospaced font
### Checklist

- [X] I have used the search function to see if someone else has already submitted the same bug report.
- [X] I will describe the problem with as much detail as possible.

### App version

6.202

### Where did you get the app from?

F-Droid

### Android version

12

### Device model

_No response_

### Steps to reproduce

I (send and) receive an email containing:
```
--example.org=_3175999=_1666532806=_1948217805_A
Content-Type: text/html; charset=utf-8
Content-Disposition: inline

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html><head><title></title></head><body>
<b>You have received an updated invitation from <a href="mailto:abc@example.org">A B C</a></b><p>
<table border cellpadding=5>
<tr><td><b>Summary</b></td><td>Hokus Pokus</td></tr>
<tr><td><b>Location</b></td><td>somewhereL2</td></tr>
<tr><td><b>Start</b></td><td><tt>Thu, 27 Oct 2022 07:00 UTC<br>
                 10:00 Europe/Sofia </tt></td></tr>
<tr><td><b>Attendees</b></td><td>• <a href="mailto:gggg@example.int">Ombre</a>
  <br>• <a href="mailto:1@mail.aegee.org">fff@example.be</a></td></tr>
<tr><td><b>Description</b></td><td>Long text, no meaning.</td></tr>
</table></body></html>
```

### Expected behavior

I expect that the text bewenn `<tt>` and `</tt>` is rendered with monospaced font.

### Actual behavior

No monospaced font, the lower line is aligned to the upper line

### Logs

_No response_

./gradlew :app:html-cleaner:test --tests "app.k9mail.html.cleaner.HtmlSanitizerTest"

diff --git a/app/html-cleaner/src/main/java/app/k9mail/html/cleaner/BodyCleaner.kt b/app/html-cleaner/src/main/java/app/k9mail/html/cleaner/BodyCleaner.kt
index 3bc44effdc7..43a6d827385 100644
--- a/app/html-cleaner/src/main/java/app/k9mail/html/cleaner/BodyCleaner.kt
+++ b/app/html-cleaner/src/main/java/app/k9mail/html/cleaner/BodyCleaner.kt
@@ -13,7 +13,7 @@ internal class BodyCleaner {

     init {
         val allowList = Safelist.relaxed()
-            .addTags("font", "hr", "ins", "del", "center", "map", "area", "title")
+            .addTags("font", "hr", "ins", "del", "center", "map", "area", "title", "tt", "kbd", "samp", "var")
             .addAttributes("font", "color", "face", "size")
             .addAttributes("a", "name")
             .addAttributes("div", "align")

diff --git a/app/html-cleaner/src/test/java/app/k9mail/html/cleaner/HtmlSanitizerTest.kt b/app/html-cleaner/src/test/java/app/k9mail/html/cleaner/HtmlSanitizerTest.kt
index 8720a3273d5..d60ae8b3812 100644
--- a/app/html-cleaner/src/test/java/app/k9mail/html/cleaner/HtmlSanitizerTest.kt
+++ b/app/html-cleaner/src/test/java/app/k9mail/html/cleaner/HtmlSanitizerTest.kt
@@ -445,6 +445,43 @@ class HtmlSanitizerTest {
         )
     }

+    @Test
+    fun `should keep 'tt' element`() {
+        assertTagsNotStripped("tt")
+    }
+
+    @Test
+    fun `should keep 'kbd' element`() {
+        assertTagsNotStripped("kbd")
+    }
+
+    @Test
+    fun `should keep 'samp' element`() {
+        assertTagsNotStripped("samp")
+    }
+
+    @Test
+    fun `should keep 'var' element`() {
+        assertTagsNotStripped("var")
+    }
+
+    private fun assertTagsNotStripped(element: String) {
+        val html = """<$element>some text</$element>"""
+
+        val result = htmlSanitizer.sanitize(html)
+
+        assertThat(result.toCompactString()).isEqualTo(
+            """
+            <html>
+            <head></head>
+            <body>
+            <$element>some text</$element>
+            </body>
+            </html>
+            """.trimIndent().trimLineBreaks()
+        )
+    }
+
     private fun Document.toCompactString(): String {
         outputSettings()
             .prettyPrint(false)


Clear log buffer before reading EHLO response

Fixes #6063

Fix log output of `SmtpResponseParser`

Fix log output of `SmtpResponseParser`
### Checklist

- [X] I have used the search function to see if someone else has already submitted the same bug report.
- [X] I will describe the problem with as much detail as possible.

### App version

2205202769770bc37feb98414bb8a1ae084b9c98

### Where did you get the app from?

_No response_

### Android version

all

### Device model

_No response_

### Steps to reproduce

See log attached to #6062

### Expected behavior

Have nice log output.

### Actual behavior

- The log contains "SMTP response data on parser error" twice. Find out why that is and if it needs to be fixed.
- `SmtpResponseParser.readHelloResponse()` doesn't clear `logBuffer`, so the greeting appears under "SMTP response data on parser error" even though it was parsed without error.

When fixing this please add tests to make sure this doesn't happen again.

### Logs

_No response_

./gradlew :mail:protocols:smtp:test --tests "com.fsck.k9.mail.transport.smtp.SmtpResponseParserTest"

diff --git a/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParser.kt b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParser.kt
index a7ceefaa101..5428e12b7e4 100644
--- a/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParser.kt
+++ b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParser.kt
@@ -33,6 +33,8 @@ internal class SmtpResponseParser(
     }

     fun readHelloResponse(): SmtpHelloResponse {
+        logBuffer.clear()
+
         val replyCode = readReplyCode()

         if (replyCode != 250) {

diff --git a/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParserTest.kt b/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParserTest.kt
index a9d4d3e62ba..75923e5f1b7 100644
--- a/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParserTest.kt
+++ b/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParserTest.kt
@@ -219,6 +219,30 @@ class SmtpResponseParserTest {
             .isEqualTo("Ignoring EHLO keyword line: KEYWORD para${"\t"}meter")
     }

+    @Test
+    fun `error in EHLO response after successfully reading greeting`() {
+        val input = """
+            220 Greeting
+            INVALID
+        """.toPeekableInputStream()
+        val parser = SmtpResponseParser(logger, input)
+        parser.readGreeting()
+
+        assertFailsWithMessage("Unexpected character: I (73)") {
+            parser.readHelloResponse()
+        }
+
+        assertThat(logger.logEntries).containsExactly(
+            LogEntry(
+                throwable = null,
+                message = """
+                    SMTP response data on parser error:
+                    I
+                """.trimIndent()
+            )
+        )
+    }
+
     @Test
     fun `positive response`() {
         val input = "200 OK".toPeekableInputStream()