Add support for swipe actions to the message list screen

The default actions are currently:
- Swipe right: Toggle selection
- Swipe left: Mark as read/unread

https://user-images.githubusercontent.com/218061/194175023-d3a6f383-8130-4a02-9921-9aa48ee6ac6c.mp4

Closes #1426

Swipe left/right on message to perform action
### Expected behaviour

Swipe right on message in the message list may delete message (like in iOS and AquaMail for Android).
### Actual behaviour

Swipe do nothing. There is no such feature.
### Steps to reproduce
1. Just try to swipe on the message in message list. Nothing. Only context menu if you will hold your finger on message.
### Environment

K-9 Mail version: 5.0.10

Android version: 6.0.1

Account type (IMAP, POP3, WebDAV/Exchange): POP3

Maybe swipe left to archive, as well.

The Gmail app also has this feature; I find it very useful to quickly sort out messages I don't care about.
NO.
That is the stupidest thing that could possibly ever be added.
I don't need any email to be randomly deleted by an unintentional sideways slip.

And as it happens, that motion IS mapped to a function in k9mail -- SELECT.
The gmail app handles the occasional accidental swipes via an "undo". Both desires could be satisfied by making it an optional feature.
Having this configurable could be a good option
@lbdroid would something like this be fairly acceptable in terms of functionality? Being opt-in, it should not disrupt the current UX too much.

As a side note, i just quickly put this together, and I'm by no means an android developer myself, so it would need a thorough review before i consider making a pull request out of that.

@jszwedko thoughts?
To just add some concerns from my side:
 - Current code does not do select/deselect as it states around, it actually does toggle-select regardless of the swipe direction. Git history did not show anything on a quick scan.
 - There's no feedback for the actions taken, unlike some other apps like gmail, whcih means it's hard to be sure if you deleted or archived emails. Better feedback (eg transitions or bubble) could be helpful, but I feel that's a completely separate feature anyway.
 - Other actions could be useful (flagging? starring? mark as spam?)
 - I'm not completely happy with the naming/namespacing of some of the bits in my commit :)
Also relevant: #2807
This is implemented by AquaMail by displaying an icon of the action that will happen upon completing the swipe and an undo popup. It is extremely comfortable when you go for the inbox-0 strategy of archiving processed mail.

Do you accept bounties?
Hello
I stumbled on this thread via Google.
I switched from iPhone to Android 2 weeks ago and tried several email apps.
K-9 and aqua remained on my "desktop". I cannot delete aqua because it has this really efficient swiping feature...
I'd be glad if it was added to k-9, too!
BTW. : some iPhone app (mail?) even featured three actions with swipe. But that was quite error prone for me.
#Same here. It is on the default app on my Sony Xperia. I also was perplexed by the possibility of accidentally deleting emails at first but it turns out it doesn't happen and I use the function a lot.
Here is how it is configured in my app :
- Swipe left : sort/archive
- Swipe right : delete
- Long touch : select

It is really handy that way.
I would love that feature. Just moved from a Samsung to a Nokia phone und thus missing this standard of Samsung mail.
+1. helps for fast cleanup of inbox/folders!
Really useful feature, make it configurable, pick a sensible default.
Who the heck is marking all supporting comments as spam or off-topic? Pls provide other means to vote if you don't want that in the bugtracker!
> Pls provide other means to vote

@ikcalB the comment reactions (👍) are precisely such an alternative.
Hi, a issue Ability to quickly delete messages from the message list: #623 has been closed and Design Overhaul moved this from To Do to Done, but the feature doesn't work. When I try to swipe a mail nothing happens. It's it normal?
I'm using k-9 v 5.6 on Android 9.0
Hi! All email apps I tested have this feature (Gmail, aquamail, BlueMail, maildroid, edison, fairEmail, outlook...)
Any news on k9?
We're also looking forward to see this feature. But, as mentioned before, actions should be configurable anyway. It's impossible to predict which action the user would intuitively assume on swiping left right.
Is there any intention to add the SWIPE functionality? This is the fastest possibility to delete / archive messages.
To be honest I went already multiple times away from K-9 because it is missing this possibility. I receive many mails per day where I just can swipe directly.
SWIPE is a base functionality on any smartphone. Everybody is used to it. In K-9 you first have to click and then you have to select the delete button. This is just not user friendly! 100 mails 200 klicks **vs.** 100 swipes.

I like to keep my inbox "clean" so for me the use-case of "selecting" many messages and deleting all at once is just not a real use-case.
Thanks for all your efforts.

./gradlew :app:ui:legacy:testDebugUnitTest --tests "com.fsck.k9.ui.messagelist.MessageListAdapterTest"

diff --git a/app/core/src/main/java/com/fsck/k9/K9.kt b/app/core/src/main/java/com/fsck/k9/K9.kt
index efcf8616e0a..d52f49dc84b 100644
--- a/app/core/src/main/java/com/fsck/k9/K9.kt
+++ b/app/core/src/main/java/com/fsck/k9/K9.kt
@@ -250,6 +250,12 @@ object K9 : EarlyInit {
     @JvmStatic
     var pgpSignOnlyDialogCounter: Int = 0

+    @JvmStatic
+    var swipeRightAction: SwipeAction = SwipeAction.ToggleSelection
+
+    @JvmStatic
+    var swipeLeftAction: SwipeAction = SwipeAction.ToggleRead
+
     val isQuietTime: Boolean
         get() {
             if (!isQuietTimeEnabled) {
@@ -358,6 +364,9 @@ object K9 : EarlyInit {
         pgpSignOnlyDialogCounter = storage.getInt("pgpSignOnlyDialogCounter", 0)

         k9Language = storage.getString("language", "")
+
+        swipeRightAction = storage.getEnum("swipeRightAction", SwipeAction.ToggleSelection)
+        swipeLeftAction = storage.getEnum("swipeLeftAction", SwipeAction.ToggleRead)
     }

     internal fun save(editor: StorageEditor) {
@@ -417,6 +426,9 @@ object K9 : EarlyInit {
         editor.putInt("pgpInlineDialogCounter", pgpInlineDialogCounter)
         editor.putInt("pgpSignOnlyDialogCounter", pgpSignOnlyDialogCounter)

+        editor.putEnum("swipeRightAction", swipeRightAction)
+        editor.putEnum("swipeLeftAction", swipeLeftAction)
+
         fontSizes.save(editor)
     }

diff --git a/app/core/src/main/java/com/fsck/k9/SwipeAction.kt b/app/core/src/main/java/com/fsck/k9/SwipeAction.kt
new file mode 100644
index 00000000000..5d4fc943415
--- /dev/null
+++ b/app/core/src/main/java/com/fsck/k9/SwipeAction.kt
@@ -0,0 +1,12 @@
+package com.fsck.k9
+
+enum class SwipeAction {
+    None,
+    ToggleSelection,
+    ToggleRead,
+    ToggleStar,
+    Archive,
+    Delete,
+    Spam,
+    Move
+}
diff --git a/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsDescriptions.java b/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsDescriptions.java
index ce5fa8e1e9f..93dd1d23ca0 100644
--- a/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsDescriptions.java
+++ b/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsDescriptions.java
@@ -10,7 +10,6 @@
 import java.util.TreeMap;

 import android.content.Context;
-import android.graphics.Color;

 import com.fsck.k9.Account;
 import com.fsck.k9.Account.SortType;
@@ -20,6 +19,7 @@
 import com.fsck.k9.K9.BACKGROUND_OPS;
 import com.fsck.k9.K9.NotificationQuickDelete;
 import com.fsck.k9.K9.SplitViewMode;
+import com.fsck.k9.SwipeAction;
 import com.fsck.k9.core.R;
 import com.fsck.k9.preferences.Settings.BooleanSetting;
 import com.fsck.k9.preferences.Settings.ColorSetting;
@@ -277,6 +277,12 @@ public class GeneralSettingsDescriptions {
         s.put("showStarredCount", Settings.versions(
                 new V(75, new BooleanSetting(false))
         ));
+        s.put("swipeRightAction", Settings.versions(
+                new V(83, new EnumSetting<>(SwipeAction.class, SwipeAction.ToggleSelection))
+        ));
+        s.put("swipeLeftAction", Settings.versions(
+                new V(83, new EnumSetting<>(SwipeAction.class, SwipeAction.ToggleRead))
+        ));

         SETTINGS = Collections.unmodifiableMap(s);

diff --git a/app/core/src/main/res/values/arrays_general_settings_values.xml b/app/core/src/main/res/values/arrays_general_settings_values.xml
index b5b0f49f67a..95fca883f9a 100644
--- a/app/core/src/main/res/values/arrays_general_settings_values.xml
+++ b/app/core/src/main/res/values/arrays_general_settings_values.xml
@@ -210,4 +210,15 @@
         <item>spam</item>
     </string-array>

+    <string-array name="swipe_action_values" translatable="false">
+        <item>none</item>
+        <item>toggle_selection</item>
+        <item>toggle_read</item>
+        <item>toggle_star</item>
+        <item>archive</item>
+        <item>delete</item>
+        <item>spam</item>
+        <item>move</item>
+    </string-array>
+
 </resources>
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/UiKoinModules.kt b/app/ui/legacy/src/main/java/com/fsck/k9/UiKoinModules.kt
index 0a18249e8bd..79f928ec363 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/UiKoinModules.kt
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/UiKoinModules.kt
@@ -4,7 +4,6 @@ import com.fsck.k9.account.accountModule
 import com.fsck.k9.activity.activityModule
 import com.fsck.k9.autodiscovery.providersxml.autodiscoveryProvidersXmlModule
 import com.fsck.k9.contacts.contactsModule
-import com.fsck.k9.fragment.fragmentModule
 import com.fsck.k9.ui.account.accountUiModule
 import com.fsck.k9.ui.base.uiBaseModule
 import com.fsck.k9.ui.changelog.changelogUiModule
@@ -28,7 +27,6 @@ val uiModules = listOf(
     messageListUiModule,
     manageFoldersUiModule,
     chooseFolderUiModule,
-    fragmentModule,
     contactsModule,
     accountModule,
     autodiscoveryProvidersXmlModule,
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 e5d9dbcf726..dfdcd8fad6a 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
@@ -34,8 +34,6 @@ import com.fsck.k9.account.BackgroundAccountRemover
 import com.fsck.k9.activity.compose.MessageActions
 import com.fsck.k9.controller.MessageReference
 import com.fsck.k9.controller.MessagingController
-import com.fsck.k9.fragment.MessageListFragment
-import com.fsck.k9.fragment.MessageListFragment.MessageListFragmentListener
 import com.fsck.k9.helper.Contacts
 import com.fsck.k9.helper.ParcelableUtil
 import com.fsck.k9.mailstore.SearchStatusManager
@@ -54,6 +52,8 @@ import com.fsck.k9.ui.changelog.RecentChangesActivity
 import com.fsck.k9.ui.changelog.RecentChangesViewModel
 import com.fsck.k9.ui.managefolders.ManageFoldersActivity
 import com.fsck.k9.ui.messagelist.DefaultFolderProvider
+import com.fsck.k9.ui.messagelist.MessageListFragment
+import com.fsck.k9.ui.messagelist.MessageListFragment.MessageListFragmentListener
 import com.fsck.k9.ui.messageview.Direction
 import com.fsck.k9.ui.messageview.MessageViewContainerFragment
 import com.fsck.k9.ui.messageview.MessageViewContainerFragment.MessageViewContainerListener
@@ -119,7 +119,7 @@ open class MessageList :
                 ?: if (K9.isMessageViewShowNext) Direction.NEXT else Direction.PREVIOUS
         }

-    private var messageListActivityAppearance: MessageListActivityAppearance? = null
+    private var messageListActivityConfig: MessageListActivityConfig? = null

     /**
      * `true` if the message list should be displayed as flat list (i.e. no threading)
@@ -548,9 +548,9 @@ open class MessageList :
     public override fun onResume() {
         super.onResume()

-        if (messageListActivityAppearance == null) {
-            messageListActivityAppearance = MessageListActivityAppearance.create(generalSettingsManager)
-        } else if (messageListActivityAppearance != MessageListActivityAppearance.create(generalSettingsManager)) {
+        if (messageListActivityConfig == null) {
+            messageListActivityConfig = MessageListActivityConfig.create(generalSettingsManager)
+        } else if (messageListActivityConfig != MessageListActivityConfig.create(generalSettingsManager)) {
             recreateCompat()
         }

diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageListActivityAppearance.kt b/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageListActivityConfig.kt
similarity index 88%
rename from app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageListActivityAppearance.kt
rename to app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageListActivityConfig.kt
index fac66de81fa..fc2ebd1ec5f 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageListActivityAppearance.kt
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageListActivityConfig.kt
@@ -1,11 +1,12 @@
 package com.fsck.k9.activity

 import com.fsck.k9.K9
+import com.fsck.k9.SwipeAction
 import com.fsck.k9.preferences.AppTheme
 import com.fsck.k9.preferences.GeneralSettingsManager
 import com.fsck.k9.preferences.SubTheme

-data class MessageListActivityAppearance(
+data class MessageListActivityConfig(
     val appTheme: AppTheme,
     val isShowUnifiedInbox: Boolean,
     val isShowMessageListStars: Boolean,
@@ -31,13 +32,15 @@ data class MessageListActivityAppearance(
     val fontSizeMessageViewAdditionalHeaders: Int,
     val fontSizeMessageViewSubject: Int,
     val fontSizeMessageViewDate: Int,
-    val fontSizeMessageViewContentAsPercent: Int
+    val fontSizeMessageViewContentAsPercent: Int,
+    val swipeRightAction: SwipeAction,
+    val swipeLeftAction: SwipeAction,
 ) {

     companion object {
-        fun create(generalSettingsManager: GeneralSettingsManager): MessageListActivityAppearance {
+        fun create(generalSettingsManager: GeneralSettingsManager): MessageListActivityConfig {
             val settings = generalSettingsManager.getSettings()
-            return MessageListActivityAppearance(
+            return MessageListActivityConfig(
                 appTheme = settings.appTheme,
                 isShowUnifiedInbox = K9.isShowUnifiedInbox,
                 isShowMessageListStars = K9.isShowMessageListStars,
@@ -63,7 +66,9 @@ data class MessageListActivityAppearance(
                 fontSizeMessageViewAdditionalHeaders = K9.fontSizes.messageViewAdditionalHeaders,
                 fontSizeMessageViewSubject = K9.fontSizes.messageViewSubject,
                 fontSizeMessageViewDate = K9.fontSizes.messageViewDate,
-                fontSizeMessageViewContentAsPercent = K9.fontSizes.messageViewContentAsPercent
+                fontSizeMessageViewContentAsPercent = K9.fontSizes.messageViewContentAsPercent,
+                swipeRightAction = K9.swipeRightAction,
+                swipeLeftAction = K9.swipeLeftAction,
             )
         }
     }
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/KoinModule.kt b/app/ui/legacy/src/main/java/com/fsck/k9/fragment/KoinModule.kt
deleted file mode 100644
index 7fe88ce2f52..00000000000
--- a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/KoinModule.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.fsck.k9.fragment
-
-import org.koin.dsl.module
-
-val fragmentModule = module {
-    single { SortTypeToastProvider() }
-}
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/KoinModule.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/KoinModule.kt
index 16be0694ae9..487fcea1c55 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/KoinModule.kt
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/KoinModule.kt
@@ -17,4 +17,5 @@ val messageListUiModule = module {
     factory {
         MessageListLiveDataFactory(messageListLoader = get(), preferences = get(), messageListRepository = get())
     }
+    single { SortTypeToastProvider() }
 }
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListAdapter.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt
similarity index 98%
rename from app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListAdapter.kt
rename to app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt
index f9ee7cf1e44..afd7661ef70 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListAdapter.kt
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt
@@ -1,4 +1,4 @@
-package com.fsck.k9.fragment
+package com.fsck.k9.ui.messagelist

 import android.annotation.SuppressLint
 import android.content.res.Resources
@@ -28,8 +28,6 @@ import com.fsck.k9.controller.MessageReference
 import com.fsck.k9.mail.Address
 import com.fsck.k9.ui.R
 import com.fsck.k9.ui.helper.RelativeDateTimeFormatter
-import com.fsck.k9.ui.messagelist.MessageListAppearance
-import com.fsck.k9.ui.messagelist.MessageListItem
 import com.fsck.k9.ui.resolveColorAttribute
 import com.fsck.k9.ui.resolveDrawableAttribute
 import kotlin.math.max
@@ -221,8 +219,8 @@ class MessageListAdapter internal constructor(

     private fun getItem(position: Int): MessageListItem = messages[position]

-    fun getItemById(uniqueId: Long): MessageListItem {
-        return messagesMap[uniqueId]!!
+    fun getItemById(uniqueId: Long): MessageListItem? {
+        return messagesMap[uniqueId]
     }

     fun getItem(messageReference: MessageReference): MessageListItem? {
@@ -526,7 +524,7 @@ class MessageListAdapter internal constructor(

     private fun getItemFromView(view: View): MessageListItem {
         val messageViewHolder = view.tag as MessageViewHolder
-        return getItemById(messageViewHolder.uniqueId)
+        return getItemById(messageViewHolder.uniqueId) ?: error("Couldn't find MessageListItem by View")
     }
 }

diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt
similarity index 95%
rename from app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListFragment.kt
rename to app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt
index e4e68904ef5..5173e52e4a7 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListFragment.kt
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt
@@ -1,4 +1,4 @@
-package com.fsck.k9.fragment
+package com.fsck.k9.ui.messagelist

 import android.app.Activity
 import android.app.SearchManager
@@ -16,7 +16,7 @@ import android.widget.Toast
 import androidx.appcompat.view.ActionMode
 import androidx.core.os.bundleOf
 import androidx.fragment.app.Fragment
-import androidx.recyclerview.widget.DefaultItemAnimator
+import androidx.recyclerview.widget.ItemTouchHelper
 import androidx.recyclerview.widget.LinearLayoutManager
 import androidx.recyclerview.widget.RecyclerView
 import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
@@ -26,14 +26,15 @@ import com.fsck.k9.Account.SortType
 import com.fsck.k9.Clock
 import com.fsck.k9.K9
 import com.fsck.k9.Preferences
+import com.fsck.k9.SwipeAction
 import com.fsck.k9.activity.FolderInfoHolder
 import com.fsck.k9.activity.Search
 import com.fsck.k9.activity.misc.ContactPicture
 import com.fsck.k9.controller.MessageReference
 import com.fsck.k9.controller.MessagingController
 import com.fsck.k9.controller.SimpleMessagingListener
+import com.fsck.k9.fragment.ConfirmationDialogFragment
 import com.fsck.k9.fragment.ConfirmationDialogFragment.ConfirmationDialogFragmentListener
-import com.fsck.k9.fragment.MessageListFragment.MessageListFragmentListener.Companion.MAX_PROGRESS
 import com.fsck.k9.helper.Utility
 import com.fsck.k9.helper.mapToSet
 import com.fsck.k9.mail.Flag
@@ -46,13 +47,7 @@ import com.fsck.k9.ui.choosefolder.ChooseFolderActivity
 import com.fsck.k9.ui.folders.FolderNameFormatter
 import com.fsck.k9.ui.folders.FolderNameFormatterFactory
 import com.fsck.k9.ui.helper.RelativeDateTimeFormatter
-import com.fsck.k9.ui.messagelist.MessageListAppearance
-import com.fsck.k9.ui.messagelist.MessageListConfig
-import com.fsck.k9.ui.messagelist.MessageListInfo
-import com.fsck.k9.ui.messagelist.MessageListItem
-import com.fsck.k9.ui.messagelist.MessageListItemDecoration
-import com.fsck.k9.ui.messagelist.MessageListViewModel
-import com.fsck.k9.ui.messagelist.MessageSortOverride
+import com.fsck.k9.ui.messagelist.MessageListFragment.MessageListFragmentListener.Companion.MAX_PROGRESS
 import java.util.concurrent.Future
 import net.jcip.annotations.GuardedBy
 import org.koin.android.ext.android.inject
@@ -242,9 +237,8 @@ class MessageListFragment :

         val itemDecoration = MessageListItemDecoration(requireContext())
         recyclerView.addItemDecoration(itemDecoration)
-        recyclerView.itemAnimator = DefaultItemAnimator().apply {
-            supportsChangeAnimations = false
-        }
+
+        recyclerView.itemAnimator = MessageListItemAnimator()
     }

     override fun onActivityCreated(savedInstanceState: Bundle?) {
@@ -258,8 +252,10 @@ class MessageListFragment :
     }

     private fun initializeMessageList() {
+        val theme = requireActivity().theme
+
         adapter = MessageListAdapter(
-            theme = requireActivity().theme,
+            theme = theme,
             res = resources,
             layoutInflater = layoutInflater,
             contactsPictureLoader = ContactPicture.getContactPictureLoader(),
@@ -271,6 +267,19 @@ class MessageListFragment :
         adapter.activeMessage = activeMessage

         recyclerView.adapter = adapter
+
+        val itemTouchHelper = ItemTouchHelper(
+            MessageListSwipeCallback(
+                resources,
+                resourceProvider = SwipeResourceProvider(theme),
+                swipeActionSupportProvider,
+                swipeRightAction = K9.swipeRightAction,
+                swipeLeftAction = K9.swipeLeftAction,
+                adapter,
+                swipeListener
+            )
+        )
+        itemTouchHelper.attachToRecyclerView(recyclerView)
     }

     private fun initializeSortSettings() {
@@ -1420,6 +1429,58 @@ class MessageListFragment :
     private val isPullToRefreshAllowed: Boolean
         get() = isRemoteSearchAllowed || isCheckMailAllowed

+    private val swipeListener = MessageListSwipeListener { item, action ->
+        when (action) {
+            SwipeAction.None -> Unit
+            SwipeAction.ToggleSelection -> {
+                toggleMessageSelect(item)
+            }
+            SwipeAction.ToggleRead -> {
+                setFlag(item, Flag.SEEN, !item.isRead)
+            }
+            SwipeAction.ToggleStar -> {
+                setFlag(item, Flag.FLAGGED, !item.isStarred)
+            }
+            SwipeAction.Archive -> {
+                onArchive(item.messageReference)
+            }
+            SwipeAction.Delete -> {
+                if (K9.isConfirmDelete) {
+                    notifyItemChanged(item)
+                }
+                onDelete(listOf(item.messageReference))
+            }
+            SwipeAction.Spam -> {
+                if (K9.isConfirmSpam) {
+                    notifyItemChanged(item)
+                }
+                onSpam(listOf(item.messageReference))
+            }
+            SwipeAction.Move -> {
+                notifyItemChanged(item)
+                onMove(item.messageReference)
+            }
+        }
+    }
+
+    private fun notifyItemChanged(item: MessageListItem) {
+        val position = adapter.getPosition(item) ?: return
+        adapter.notifyItemChanged(position)
+    }
+
+    private val swipeActionSupportProvider = SwipeActionSupportProvider { item, action ->
+        when (action) {
+            SwipeAction.None -> false
+            SwipeAction.ToggleSelection -> true
+            SwipeAction.ToggleRead -> !isOutbox
+            SwipeAction.ToggleStar -> !isOutbox
+            SwipeAction.Archive -> !isOutbox && item.account.hasArchiveFolder()
+            SwipeAction.Delete -> true
+            SwipeAction.Move -> !isOutbox && messagingController.isMoveCapable(item.account)
+            SwipeAction.Spam -> !isOutbox && item.account.hasSpamFolder() && item.folderId != item.account.spamFolderId
+        }
+    }
+
     internal inner class MessageListActivityListener : SimpleMessagingListener() {
         private val lock = Any()

diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListHandler.java b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListHandler.java
similarity index 99%
rename from app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListHandler.java
rename to app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListHandler.java
index 72252cf15fb..22a7a29826f 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListHandler.java
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListHandler.java
@@ -1,4 +1,4 @@
-package com.fsck.k9.fragment;
+package com.fsck.k9.ui.messagelist;

 import java.lang.ref.WeakReference;
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItemAnimator.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItemAnimator.kt
new file mode 100644
index 00000000000..ad9ec900cdf
--- /dev/null
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItemAnimator.kt
@@ -0,0 +1,17 @@
+package com.fsck.k9.ui.messagelist
+
+import androidx.recyclerview.widget.DefaultItemAnimator
+import androidx.recyclerview.widget.RecyclerView.ViewHolder
+
+class MessageListItemAnimator : DefaultItemAnimator() {
+    init {
+        supportsChangeAnimations = false
+        changeDuration = 120
+    }
+
+    override fun canReuseUpdatedViewHolder(viewHolder: ViewHolder, payloads: MutableList<Any>): Boolean {
+        // ItemTouchHelper expects swiped views to be removed from the view hierarchy. So we don't reuse views that are
+        // marked as having been swiped.
+        return !viewHolder.wasSwiped && super.canReuseUpdatedViewHolder(viewHolder, payloads)
+    }
+}
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt
new file mode 100644
index 00000000000..20e665fe692
--- /dev/null
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt
@@ -0,0 +1,172 @@
+package com.fsck.k9.ui.messagelist
+
+import android.content.res.Resources
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.view.View
+import androidx.core.graphics.withSave
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.RecyclerView.ViewHolder
+import com.fsck.k9.SwipeAction
+import com.fsck.k9.ui.R
+import kotlin.math.abs
+
+class MessageListSwipeCallback(
+    resources: Resources,
+    private val resourceProvider: SwipeResourceProvider,
+    private val swipeActionSupportProvider: SwipeActionSupportProvider,
+    private val swipeRightAction: SwipeAction,
+    private val swipeLeftAction: SwipeAction,
+    private val adapter: MessageListAdapter,
+    private val listener: MessageListSwipeListener
+) : ItemTouchHelper.Callback() {
+    private val iconPadding = resources.getDimension(R.dimen.messageListSwipeIconPadding).toInt()
+    private val swipeThreshold = resources.getDimension(R.dimen.messageListSwipeThreshold)
+    private val backgroundColorPaint = Paint()
+
+    override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: ViewHolder): Int {
+        if (viewHolder !is MessageViewHolder) return 0
+
+        val item = adapter.getItemById(viewHolder.uniqueId) ?: return 0
+
+        var swipeFlags = 0
+        if (swipeActionSupportProvider.isActionSupported(item, swipeRightAction)) {
+            swipeFlags = swipeFlags or ItemTouchHelper.RIGHT
+        }
+        if (swipeActionSupportProvider.isActionSupported(item, swipeLeftAction)) {
+            swipeFlags = swipeFlags or ItemTouchHelper.LEFT
+        }
+
+        return makeMovementFlags(0, swipeFlags)
+    }
+
+    override fun onMove(
+        recyclerView: RecyclerView,
+        viewHolder: ViewHolder,
+        target: ViewHolder
+    ): Boolean {
+        throw UnsupportedOperationException("not implemented")
+    }
+
+    override fun onSwiped(viewHolder: ViewHolder, direction: Int) {
+        val holder = viewHolder as MessageViewHolder
+        val item = adapter.getItemById(holder.uniqueId) ?: error("Couldn't find MessageListItem")
+
+        // ItemTouchHelper expects swiped views to be removed from the view hierarchy. We mark this ViewHolder so that
+        // MessageListItemAnimator knows not to reuse it during an animation.
+        viewHolder.markAsSwiped(true)
+
+        when (direction) {
+            ItemTouchHelper.RIGHT -> listener.onSwipeAction(item, swipeRightAction)
+            ItemTouchHelper.LEFT -> listener.onSwipeAction(item, swipeLeftAction)
+            else -> error("Unsupported direction: $direction")
+        }
+    }
+
+    override fun clearView(recyclerView: RecyclerView, viewHolder: ViewHolder) {
+        super.clearView(recyclerView, viewHolder)
+        viewHolder.markAsSwiped(false)
+    }
+
+    override fun getSwipeThreshold(viewHolder: ViewHolder): Float {
+        return swipeThreshold / viewHolder.itemView.width
+    }
+
+    override fun onChildDraw(
+        canvas: Canvas,
+        recyclerView: RecyclerView,
+        viewHolder: ViewHolder,
+        dX: Float,
+        dY: Float,
+        actionState: Int,
+        isCurrentlyActive: Boolean
+    ) {
+        canvas.withSave {
+            val view = viewHolder.itemView
+
+            val holder = viewHolder as MessageViewHolder
+            val item = adapter.getItemById(holder.uniqueId) ?: return@withSave
+
+            val swipeThreshold = recyclerView.width * getSwipeThreshold(holder)
+            val swipeThresholdReached = abs(dX) > swipeThreshold
+            if (swipeThresholdReached) {
+                val action = if (dX > 0) swipeRightAction else swipeLeftAction
+                val backgroundColor = resourceProvider.getBackgroundColor(item, action)
+                drawBackground(view, backgroundColor)
+            } else {
+                val backgroundColor = resourceProvider.getBackgroundColor(item, SwipeAction.None)
+                drawBackground(view, backgroundColor)
+            }
+
+            // Stop drawing the icon when the view has been animated all the way off the screen by ItemTouchHelper.
+            // We do this so the icon doesn't switch state when RecyclerView's ItemAnimator animates the view back after
+            // a toggle action (mark as read/unread, add/remove star) was used.
+            if (isCurrentlyActive || abs(dX).toInt() < view.width) {
+                drawIcon(dX, view, item, swipeThresholdReached)
+            }
+        }
+
+        super.onChildDraw(canvas, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
+    }
+
+    private fun Canvas.drawBackground(view: View, color: Int) {
+        backgroundColorPaint.color = color
+        drawRect(
+            view.left.toFloat(),
+            view.top.toFloat(),
+            view.right.toFloat(),
+            view.bottom.toFloat(),
+            backgroundColorPaint
+        )
+    }
+
+    private fun Canvas.drawIcon(dX: Float, view: View, item: MessageListItem, swipeThresholdReached: Boolean) {
+        if (dX > 0) {
+            drawSwipeRightIcon(view, item, swipeThresholdReached)
+        } else {
+            drawSwipeLeftIcon(view, item, swipeThresholdReached)
+        }
+    }
+
+    private fun Canvas.drawSwipeRightIcon(view: View, item: MessageListItem, swipeThresholdReached: Boolean) {
+        resourceProvider.getIcon(item, swipeRightAction)?.let { icon ->
+            val iconLeft = iconPadding
+            val iconTop = view.top + ((view.height - icon.intrinsicHeight) / 2)
+            val iconRight = iconLeft + icon.intrinsicWidth
+            val iconBottom = iconTop + icon.intrinsicHeight
+            icon.setBounds(iconLeft, iconTop, iconRight, iconBottom)
+
+            icon.setTint(resourceProvider.getIconTint(item, swipeRightAction, swipeThresholdReached))
+            icon.draw(this)
+        }
+    }
+
+    private fun Canvas.drawSwipeLeftIcon(view: View, item: MessageListItem, swipeThresholdReached: Boolean) {
+        resourceProvider.getIcon(item, swipeLeftAction)?.let { icon ->
+            val iconRight = view.right - iconPadding
+            val iconLeft = iconRight - icon.intrinsicWidth
+            val iconTop = view.top + ((view.height - icon.intrinsicHeight) / 2)
+            val iconBottom = iconTop + icon.intrinsicHeight
+            icon.setBounds(iconLeft, iconTop, iconRight, iconBottom)
+
+            icon.setTint(resourceProvider.getIconTint(item, swipeLeftAction, swipeThresholdReached))
+            icon.draw(this)
+        }
+    }
+}
+
+fun interface SwipeActionSupportProvider {
+    fun isActionSupported(item: MessageListItem, action: SwipeAction): Boolean
+}
+
+fun interface MessageListSwipeListener {
+    fun onSwipeAction(item: MessageListItem, action: SwipeAction)
+}
+
+private fun ViewHolder.markAsSwiped(value: Boolean) {
+    itemView.setTag(R.id.message_list_swipe_tag, if (value) true else null)
+}
+
+val ViewHolder.wasSwiped
+    get() = itemView.getTag(R.id.message_list_swipe_tag) == true
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageViewHolder.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListViewHolder.kt
similarity index 96%
rename from app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageViewHolder.kt
rename to app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListViewHolder.kt
index 5e436a3c73e..803d08c3d1f 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageViewHolder.kt
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListViewHolder.kt
@@ -1,4 +1,4 @@
-package com.fsck.k9.fragment
+package com.fsck.k9.ui.messagelist

 import android.view.View
 import android.widget.CheckBox
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MlfUtils.java b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MlfUtils.java
similarity index 97%
rename from app/ui/legacy/src/main/java/com/fsck/k9/fragment/MlfUtils.java
rename to app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MlfUtils.java
index eb35a43e3bf..9c9522b42cd 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MlfUtils.java
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MlfUtils.java
@@ -1,4 +1,4 @@
-package com.fsck.k9.fragment;
+package com.fsck.k9.ui.messagelist;

 import java.util.List;
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/SortTypeToastProvider.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/SortTypeToastProvider.kt
similarity index 97%
rename from app/ui/legacy/src/main/java/com/fsck/k9/fragment/SortTypeToastProvider.kt
rename to app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/SortTypeToastProvider.kt
index 998c9ed6235..b4774b2df18 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/SortTypeToastProvider.kt
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/SortTypeToastProvider.kt
@@ -1,4 +1,4 @@
-package com.fsck.k9.fragment
+package com.fsck.k9.ui.messagelist

 import com.fsck.k9.Account.SortType
 import com.fsck.k9.Account.SortType.SORT_ARRIVAL
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/SwipeResourceProvider.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/SwipeResourceProvider.kt
new file mode 100644
index 00000000000..d61064c9d2d
--- /dev/null
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/SwipeResourceProvider.kt
@@ -0,0 +1,72 @@
+package com.fsck.k9.ui.messagelist
+
+import android.content.res.Resources.Theme
+import android.graphics.drawable.Drawable
+import androidx.annotation.AttrRes
+import com.fsck.k9.SwipeAction
+import com.fsck.k9.ui.R
+import com.fsck.k9.ui.resolveColorAttribute
+import com.fsck.k9.ui.resolveDrawableAttribute
+
+class SwipeResourceProvider(val theme: Theme) {
+    private val iconTint = theme.resolveColorAttribute(R.attr.messageListSwipeIconTint)
+
+    private val selectIcon = theme.loadDrawable(R.attr.messageListSwipeSelectIcon)
+    private val markAsReadIcon = theme.loadDrawable(R.attr.messageListSwipeMarkAsReadIcon)
+    private val markAsUnreadIcon = theme.loadDrawable(R.attr.messageListSwipeMarkAsUnreadIcon)
+    private val addStarIcon = theme.loadDrawable(R.attr.messageListSwipeAddStarIcon)
+    private val removeStarIcon = theme.loadDrawable(R.attr.messageListSwipeRemoveStarIcon)
+    private val archiveIcon = theme.loadDrawable(R.attr.messageListSwipeArchiveIcon)
+    private val deleteIcon = theme.loadDrawable(R.attr.messageListSwipeDeleteIcon)
+    private val spamIcon = theme.loadDrawable(R.attr.messageListSwipeSpamIcon)
+    private val moveIcon = theme.loadDrawable(R.attr.messageListSwipeMoveIcon)
+
+    private val noActionColor = theme.resolveColorAttribute(R.attr.messageListSwipeDisabledBackgroundColor)
+    private val selectColor = theme.resolveColorAttribute(R.attr.messageListSwipeSelectBackgroundColor)
+    private val markAsReadColor = theme.resolveColorAttribute(R.attr.messageListSwipeMarkAsReadBackgroundColor)
+    private val markAsUnreadColor = theme.resolveColorAttribute(R.attr.messageListSwipeMarkAsUnreadBackgroundColor)
+    private val addStarColor = theme.resolveColorAttribute(R.attr.messageListSwipeAddStarBackgroundColor)
+    private val removeStarColor = theme.resolveColorAttribute(R.attr.messageListSwipeRemoveStarBackgroundColor)
+    private val archiveColor = theme.resolveColorAttribute(R.attr.messageListSwipeArchiveBackgroundColor)
+    private val deleteColor = theme.resolveColorAttribute(R.attr.messageListSwipeDeleteBackgroundColor)
+    private val spamColor = theme.resolveColorAttribute(R.attr.messageListSwipeSpamBackgroundColor)
+    private val moveColor = theme.resolveColorAttribute(R.attr.messageListSwipeMoveBackgroundColor)
+
+    fun getIconTint(item: MessageListItem, action: SwipeAction, swipeThresholdReached: Boolean): Int {
+        return if (swipeThresholdReached) {
+            iconTint
+        } else {
+            getBackgroundColor(item, action)
+        }
+    }
+
+    fun getIcon(item: MessageListItem, action: SwipeAction): Drawable? {
+        return when (action) {
+            SwipeAction.None -> null
+            SwipeAction.ToggleSelection -> selectIcon
+            SwipeAction.ToggleRead -> if (item.isRead) markAsUnreadIcon else markAsReadIcon
+            SwipeAction.ToggleStar -> if (item.isStarred) removeStarIcon else addStarIcon
+            SwipeAction.Archive -> archiveIcon
+            SwipeAction.Delete -> deleteIcon
+            SwipeAction.Spam -> spamIcon
+            SwipeAction.Move -> moveIcon
+        }
+    }
+
+    fun getBackgroundColor(item: MessageListItem, action: SwipeAction): Int {
+        return when (action) {
+            SwipeAction.None -> noActionColor
+            SwipeAction.ToggleSelection -> selectColor
+            SwipeAction.ToggleRead -> if (item.isRead) markAsUnreadColor else markAsReadColor
+            SwipeAction.ToggleStar -> if (item.isStarred) removeStarColor else addStarColor
+            SwipeAction.Archive -> archiveColor
+            SwipeAction.Delete -> deleteColor
+            SwipeAction.Spam -> spamColor
+            SwipeAction.Move -> moveColor
+        }
+    }
+}
+
+private fun Theme.loadDrawable(@AttrRes attributeId: Int): Drawable {
+    return resolveDrawableAttribute(attributeId).mutate()
+}
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsDataStore.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsDataStore.kt
index d712a8cde7c..6e1ae5a9c49 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsDataStore.kt
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsDataStore.kt
@@ -2,6 +2,7 @@ package com.fsck.k9.ui.settings.general

 import androidx.preference.PreferenceDataStore
 import com.fsck.k9.K9
+import com.fsck.k9.SwipeAction
 import com.fsck.k9.job.K9JobManager
 import com.fsck.k9.preferences.AppTheme
 import com.fsck.k9.preferences.GeneralSettingsManager
@@ -125,6 +126,8 @@ class GeneralSettingsDataStore(
             "message_view_date_font" -> K9.fontSizes.messageViewDate.toString()
             "message_view_additional_headers_font" -> K9.fontSizes.messageViewAdditionalHeaders.toString()
             "message_compose_input_font" -> K9.fontSizes.messageComposeInput.toString()
+            "swipe_action_right" -> swipeActionToString(K9.swipeRightAction)
+            "swipe_action_left" -> swipeActionToString(K9.swipeLeftAction)
             else -> defValue
         }
     }
@@ -164,6 +167,8 @@ class GeneralSettingsDataStore(
             "message_view_date_font" -> K9.fontSizes.messageViewDate = value.toInt()
             "message_view_additional_headers_font" -> K9.fontSizes.messageViewAdditionalHeaders = value.toInt()
             "message_compose_input_font" -> K9.fontSizes.messageComposeInput = value.toInt()
+            "swipe_action_right" -> K9.swipeRightAction = stringToSwipeAction(value)
+            "swipe_action_left" -> K9.swipeLeftAction = stringToSwipeAction(value)
             else -> return
         }

@@ -280,4 +285,27 @@ class GeneralSettingsDataStore(
             jobManager.scheduleAllMailJobs()
         }
     }
+
+    private fun swipeActionToString(action: SwipeAction) = when (action) {
+        SwipeAction.None -> "none"
+        SwipeAction.ToggleSelection -> "toggle_selection"
+        SwipeAction.ToggleRead -> "toggle_read"
+        SwipeAction.ToggleStar -> "toggle_star"
+        SwipeAction.Archive -> "archive"
+        SwipeAction.Delete -> "delete"
+        SwipeAction.Spam -> "spam"
+        SwipeAction.Move -> "move"
+    }
+
+    private fun stringToSwipeAction(action: String) = when (action) {
+        "none" -> SwipeAction.None
+        "toggle_selection" -> SwipeAction.ToggleSelection
+        "toggle_read" -> SwipeAction.ToggleRead
+        "toggle_star" -> SwipeAction.ToggleStar
+        "archive" -> SwipeAction.Archive
+        "delete" -> SwipeAction.Delete
+        "spam" -> SwipeAction.Spam
+        "move" -> SwipeAction.Move
+        else -> throw AssertionError()
+    }
 }
diff --git a/app/ui/legacy/src/main/res/drawable/ic_move_to_folder.xml b/app/ui/legacy/src/main/res/drawable/ic_move_to_folder.xml
new file mode 100644
index 00000000000..9b88184787c
--- /dev/null
+++ b/app/ui/legacy/src/main/res/drawable/ic_move_to_folder.xml
@@ -0,0 +1,11 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:autoMirrored="true"
+    android:tint="?attr/colorControlNormal"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M20,6h-8l-2,-2L4,4c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,8c0,-1.1 -0.9,-2 -2,-2zM14,18v-3h-4v-4h4L14,8l5,5 -5,5z" />
+</vector>
diff --git a/app/ui/legacy/src/main/res/layout/message_list_fragment.xml b/app/ui/legacy/src/main/res/layout/message_list_fragment.xml
index d5329bcc3ac..c0c6b931bdb 100644
--- a/app/ui/legacy/src/main/res/layout/message_list_fragment.xml
+++ b/app/ui/legacy/src/main/res/layout/message_list_fragment.xml
@@ -6,7 +6,7 @@
     android:id="@+id/swiperefresh"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
-    tools:context="com.fsck.k9.fragment.MessageListFragment">
+    tools:context=".messagelist.MessageListFragment">

     <androidx.recyclerview.widget.RecyclerView
         android:id="@+id/message_list"
diff --git a/app/ui/legacy/src/main/res/values/arrays_general_settings_strings.xml b/app/ui/legacy/src/main/res/values/arrays_general_settings_strings.xml
index 45bf2174c22..b81a82e8a33 100644
--- a/app/ui/legacy/src/main/res/values/arrays_general_settings_strings.xml
+++ b/app/ui/legacy/src/main/res/values/arrays_general_settings_strings.xml
@@ -157,4 +157,15 @@
         <item>@string/spam_action</item>
     </string-array>

+    <string-array name="swipe_action_names">
+        <item>@string/general_settings_swipe_action_none</item>
+        <item>@string/general_settings_swipe_action_toggle_selection</item>
+        <item>@string/general_settings_swipe_action_toggle_read</item>
+        <item>@string/general_settings_swipe_action_toggle_star</item>
+        <item>@string/general_settings_swipe_action_archive</item>
+        <item>@string/general_settings_swipe_action_delete</item>
+        <item>@string/general_settings_swipe_action_spam</item>
+        <item>@string/general_settings_swipe_action_move</item>
+    </string-array>
+
 </resources>
diff --git a/app/ui/legacy/src/main/res/values/attrs.xml b/app/ui/legacy/src/main/res/values/attrs.xml
index fd4a8fbd380..7d93616e923 100644
--- a/app/ui/legacy/src/main/res/values/attrs.xml
+++ b/app/ui/legacy/src/main/res/values/attrs.xml
@@ -78,6 +78,26 @@
         <attr name="messageListAnswered" format="reference"/>
         <attr name="messageListForwarded" format="reference"/>
         <attr name="messageListAnsweredForwarded" format="reference"/>
+        <attr name="messageListSwipeIconTint" format="reference|color"/>
+        <attr name="messageListSwipeDisabledBackgroundColor" format="reference|color"/>
+        <attr name="messageListSwipeSelectIcon" format="reference"/>
+        <attr name="messageListSwipeSelectBackgroundColor" format="reference|color"/>
+        <attr name="messageListSwipeMarkAsReadIcon" format="reference"/>
+        <attr name="messageListSwipeMarkAsReadBackgroundColor" format="reference|color"/>
+        <attr name="messageListSwipeMarkAsUnreadIcon" format="reference"/>
+        <attr name="messageListSwipeMarkAsUnreadBackgroundColor" format="reference|color"/>
+        <attr name="messageListSwipeAddStarIcon" format="reference"/>
+        <attr name="messageListSwipeAddStarBackgroundColor" format="reference|color"/>
+        <attr name="messageListSwipeRemoveStarIcon" format="reference"/>
+        <attr name="messageListSwipeRemoveStarBackgroundColor" format="reference|color"/>
+        <attr name="messageListSwipeArchiveIcon" format="reference"/>
+        <attr name="messageListSwipeArchiveBackgroundColor" format="reference|color"/>
+        <attr name="messageListSwipeDeleteIcon" format="reference"/>
+        <attr name="messageListSwipeDeleteBackgroundColor" format="reference|color"/>
+        <attr name="messageListSwipeSpamIcon" format="reference"/>
+        <attr name="messageListSwipeSpamBackgroundColor" format="reference|color"/>
+        <attr name="messageListSwipeMoveIcon" format="reference"/>
+        <attr name="messageListSwipeMoveBackgroundColor" format="reference|color"/>
         <attr name="messageStar" format="reference"/>
         <attr name="messageStarColor" format="color"/>
         <attr name="messageViewBackgroundColor" format="reference|color"/>
diff --git a/app/ui/legacy/src/main/res/values/dimensions.xml b/app/ui/legacy/src/main/res/values/dimensions.xml
index b57ef027f00..c46128602b2 100644
--- a/app/ui/legacy/src/main/res/values/dimensions.xml
+++ b/app/ui/legacy/src/main/res/values/dimensions.xml
@@ -9,4 +9,6 @@
     <dimen name="account_setup_margin_between_items_incoming_and_outgoing">12dp</dimen>

     <dimen name="message_view_pager_page_margin">16dp</dimen>
+    <dimen name="messageListSwipeThreshold">72dp</dimen>
+    <dimen name="messageListSwipeIconPadding">24dp</dimen>
 </resources>
diff --git a/app/ui/legacy/src/main/res/values/ids.xml b/app/ui/legacy/src/main/res/values/ids.xml
index e308d596bde..679f0e73466 100644
--- a/app/ui/legacy/src/main/res/values/ids.xml
+++ b/app/ui/legacy/src/main/res/values/ids.xml
@@ -19,4 +19,6 @@
     <item type="id" name="settings_list_url_item"/>

     <item type="id" name="drawer_list_folder_item"/>
+
+    <item type="id" name="message_list_swipe_tag"/>
 </resources>
diff --git a/app/ui/legacy/src/main/res/values/strings.xml b/app/ui/legacy/src/main/res/values/strings.xml
index cf40aff93fc..3b52fa01b14 100644
--- a/app/ui/legacy/src/main/res/values/strings.xml
+++ b/app/ui/legacy/src/main/res/values/strings.xml
@@ -308,6 +308,30 @@ Please submit bug reports, contribute new features and ask questions at
     <string name="global_settings_confirm_menu_mark_all_read">Mark all messages as read</string>
     <string name="global_settings_confirm_action_delete_notif">Delete (from notification)</string>

+    <!-- Title of the screen to configure swipe actions -->
+    <string name="general_settings_swipe_actions_title">Swipe actions</string>
+    <!-- Name of the setting to configure the right swipe action -->
+    <string name="general_settings_right_swipe_title">Right swipe</string>
+    <!-- Name of the setting to configure the left swipe action -->
+    <string name="general_settings_left_swipe_title">Left swipe</string>
+
+    <!-- The list item users select to not use a swipe action for a particular direction -->
+    <string name="general_settings_swipe_action_none">None</string>
+    <!-- Name of the swipe action to select/deselect a message -->
+    <string name="general_settings_swipe_action_toggle_selection">Toggle selection</string>
+    <!-- Name of the swipe action to mark as message as read/unread -->
+    <string name="general_settings_swipe_action_toggle_read">Mark as read/unread</string>
+    <!-- Name of the swipe action to add/remove a star -->
+    <string name="general_settings_swipe_action_toggle_star">Add/remove star</string>
+    <!-- Name of the swipe action to archive a message -->
+    <string name="general_settings_swipe_action_archive">Archive</string>
+    <!-- Name of the swipe action to delete a message -->
+    <string name="general_settings_swipe_action_delete">Delete</string>
+    <!-- Name of the swipe action to move a message to the spam folder -->
+    <string name="general_settings_swipe_action_spam">Spam</string>
+    <!-- Name of the swipe action to move a message to another folder -->
+    <string name="general_settings_swipe_action_move">Move</string>
+
     <string name="global_settings_privacy_hide_useragent">Hide mail client</string>
     <string name="global_settings_privacy_hide_useragent_detail">Remove K-9 User-Agent from mail headers</string>
     <string name="global_settings_privacy_hide_timezone">Hide timezone</string>
diff --git a/app/ui/legacy/src/main/res/values/themes.xml b/app/ui/legacy/src/main/res/values/themes.xml
index 9ad50f6d880..9488b7f8f20 100644
--- a/app/ui/legacy/src/main/res/values/themes.xml
+++ b/app/ui/legacy/src/main/res/values/themes.xml
@@ -30,7 +30,7 @@
         <item name="iconActionArchive">@drawable/ic_archive</item>
         <item name="iconActionCompose">@drawable/ic_pencil</item>
         <item name="iconActionDelete">@drawable/ic_trash_can</item>
-        <item name="iconActionMove">@drawable/ic_folder</item>
+        <item name="iconActionMove">@drawable/ic_move_to_folder</item>
         <item name="iconActionCopy">@drawable/ic_content_copy</item>
         <item name="iconActionNextStatus">@drawable/ic_chevron_right</item>
         <item name="iconActionRefresh">@drawable/ic_refresh</item>
@@ -91,6 +91,28 @@
         <item name="messageListAnswered">@drawable/ic_messagelist_answered</item>
         <item name="messageListForwarded">@drawable/ic_messagelist_forwarded</item>
         <item name="messageListAnsweredForwarded">@drawable/ic_messagelist_answered_forwarded</item>
+
+        <item name="messageListSwipeIconTint">#ffffff</item>
+        <item name="messageListSwipeDisabledBackgroundColor">@color/material_gray_200</item>
+        <item name="messageListSwipeSelectIcon">@drawable/ic_import_status</item>
+        <item name="messageListSwipeSelectBackgroundColor">@color/material_blue_600</item>
+        <item name="messageListSwipeMarkAsReadIcon">?attr/iconActionMarkAsRead</item>
+        <item name="messageListSwipeMarkAsReadBackgroundColor">@color/material_blue_600</item>
+        <item name="messageListSwipeMarkAsUnreadIcon">?attr/iconActionMarkAsUnread</item>
+        <item name="messageListSwipeMarkAsUnreadBackgroundColor">@color/material_blue_600</item>
+        <item name="messageListSwipeAddStarIcon">?attr/iconActionFlag</item>
+        <item name="messageListSwipeAddStarBackgroundColor">@color/material_orange_600</item>
+        <item name="messageListSwipeRemoveStarIcon">?attr/iconActionUnflag</item>
+        <item name="messageListSwipeRemoveStarBackgroundColor">@color/material_orange_600</item>
+        <item name="messageListSwipeArchiveIcon">?attr/iconActionArchive</item>
+        <item name="messageListSwipeArchiveBackgroundColor">@color/material_green_600</item>
+        <item name="messageListSwipeDeleteIcon">?attr/iconActionDelete</item>
+        <item name="messageListSwipeDeleteBackgroundColor">@color/material_red_600</item>
+        <item name="messageListSwipeSpamIcon">?attr/iconActionSpam</item>
+        <item name="messageListSwipeSpamBackgroundColor">@color/material_red_700</item>
+        <item name="messageListSwipeMoveIcon">?attr/iconActionMove</item>
+        <item name="messageListSwipeMoveBackgroundColor">@color/material_purple_500</item>
+
         <item name="messageStar">@drawable/btn_check_star</item>
         <item name="messageStarColor">#fbbc04</item>
         <item name="messageViewBackgroundColor">#ffffffff</item>
@@ -129,6 +151,9 @@
         <item name="behindRecyclerView">#F0F0F0</item>
     </style>

+    <style name="Theme.K9.Light" parent="Theme.K9.Light.Common" />
+
+    <style name="Theme.K9.Dark" parent="Theme.K9.Dark.Common" />
     <style name="Theme.K9.Dark.Common" parent="Theme.K9.Dark.Base">
         <item name="android:navigationBarColor">#000000</item>
         <item name="windowActionModeOverlay">true</item>
@@ -212,6 +237,28 @@
         <item name="messageListAnswered">@drawable/ic_messagelist_answered</item>
         <item name="messageListForwarded">@drawable/ic_messagelist_forwarded</item>
         <item name="messageListAnsweredForwarded">@drawable/ic_messagelist_answered_forwarded</item>
+
+        <item name="messageListSwipeIconTint">#ffffff</item>
+        <item name="messageListSwipeDisabledBackgroundColor">@color/material_gray_900</item>
+        <item name="messageListSwipeSelectIcon">@drawable/ic_import_status</item>
+        <item name="messageListSwipeSelectBackgroundColor">@color/material_blue_700</item>
+        <item name="messageListSwipeMarkAsReadIcon">?attr/iconActionMarkAsRead</item>
+        <item name="messageListSwipeMarkAsReadBackgroundColor">@color/material_blue_700</item>
+        <item name="messageListSwipeMarkAsUnreadIcon">?attr/iconActionMarkAsUnread</item>
+        <item name="messageListSwipeMarkAsUnreadBackgroundColor">@color/material_blue_700</item>
+        <item name="messageListSwipeAddStarIcon">?attr/iconActionFlag</item>
+        <item name="messageListSwipeAddStarBackgroundColor">@color/material_orange_700</item>
+        <item name="messageListSwipeRemoveStarIcon">?attr/iconActionUnflag</item>
+        <item name="messageListSwipeRemoveStarBackgroundColor">@color/material_orange_700</item>
+        <item name="messageListSwipeArchiveIcon">?attr/iconActionArchive</item>
+        <item name="messageListSwipeArchiveBackgroundColor">@color/material_green_700</item>
+        <item name="messageListSwipeDeleteIcon">?attr/iconActionDelete</item>
+        <item name="messageListSwipeDeleteBackgroundColor">@color/material_red_700</item>
+        <item name="messageListSwipeSpamIcon">?attr/iconActionSpam</item>
+        <item name="messageListSwipeSpamBackgroundColor">@color/material_red_800</item>
+        <item name="messageListSwipeMoveIcon">?attr/iconActionMove</item>
+        <item name="messageListSwipeMoveBackgroundColor">@color/material_purple_600</item>
+
         <item name="messageStar">@drawable/btn_check_star</item>
         <item name="messageStarColor">#fdd663</item>
         <item name="messageViewBackgroundColor">#000000</item>
@@ -251,9 +298,6 @@
         <item name="behindRecyclerView">#202020</item>
     </style>

-    <style name="Theme.K9.Light" parent="Theme.K9.Light.Common" />
-    <style name="Theme.K9.Dark" parent="Theme.K9.Dark.Common" />
-
     <style name="Theme.K9.Dialog.Light" parent="Theme.K9.Light">
         <item name="backgroundColorChooseAccountHeader">#cccccc</item>
     </style>
diff --git a/app/ui/legacy/src/main/res/xml/general_settings.xml b/app/ui/legacy/src/main/res/xml/general_settings.xml
index cf6933a72c2..ebc9be2bd3d 100644
--- a/app/ui/legacy/src/main/res/xml/general_settings.xml
+++ b/app/ui/legacy/src/main/res/xml/general_settings.xml
@@ -382,6 +382,29 @@
             android:summary="@string/global_settings_confirm_actions_summary"
             android:title="@string/global_settings_confirm_actions_title" />

+        <PreferenceScreen
+            android:key="swipe_actions"
+            android:title="@string/general_settings_swipe_actions_title"
+            search:ignore="true">
+
+            <ListPreference
+                android:dialogTitle="@string/general_settings_right_swipe_title"
+                android:entries="@array/swipe_action_names"
+                android:entryValues="@array/swipe_action_values"
+                android:key="swipe_action_right"
+                app:useSimpleSummaryProvider="true"
+                android:title="@string/general_settings_right_swipe_title" />
+
+            <ListPreference
+                android:dialogTitle="@string/general_settings_left_swipe_title"
+                android:entries="@array/swipe_action_names"
+                android:entryValues="@array/swipe_action_values"
+                android:key="swipe_action_left"
+                app:useSimpleSummaryProvider="true"
+                android:title="@string/general_settings_left_swipe_title" />
+
+        </PreferenceScreen>
+
     </PreferenceScreen>

     <PreferenceScreen

diff --git a/app/ui/legacy/src/test/java/com/fsck/k9/fragment/MessageListAdapterTest.kt b/app/ui/legacy/src/test/java/com/fsck/k9/ui/messagelist/MessageListAdapterTest.kt
similarity index 99%
rename from app/ui/legacy/src/test/java/com/fsck/k9/fragment/MessageListAdapterTest.kt
rename to app/ui/legacy/src/test/java/com/fsck/k9/ui/messagelist/MessageListAdapterTest.kt
index 095eb681595..c65312fddfc 100644
--- a/app/ui/legacy/src/test/java/com/fsck/k9/fragment/MessageListAdapterTest.kt
+++ b/app/ui/legacy/src/test/java/com/fsck/k9/ui/messagelist/MessageListAdapterTest.kt
@@ -1,4 +1,4 @@
-package com.fsck.k9.fragment
+package com.fsck.k9.ui.messagelist

 import android.content.Context
 import android.text.Spannable
@@ -23,8 +23,6 @@ import com.fsck.k9.mail.Address
 import com.fsck.k9.textString
 import com.fsck.k9.ui.R
 import com.fsck.k9.ui.helper.RelativeDateTimeFormatter
-import com.fsck.k9.ui.messagelist.MessageListAppearance
-import com.fsck.k9.ui.messagelist.MessageListItem
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
 import org.junit.Assert.assertNull


Fix `HtmlSignatureRemover`

Detecting an email signature in HTML is quite hard, even when the default delimiter `-- ` (dash, dash, space) is used. With these changes we should support more signature formats, including the one currently used by K-9 Mail :see_no_evil:

Fixes #6279

"Strip signatures on reply" does not work
### 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?

Google Play

### Android version

12

### Device model

Samsung Galaxy S22

### Steps to reproduce

1. Add outlook account
2. Edit identity
3. Check "Use Signature"
4. Write a signature
5. Check the default setting of "Strip signatures on reply". It is checked.
6. Write an email to myself
7. Reply to myself
8. Reply again

### Expected behavior

Signatures will be removed from quoted messages

### Actual behavior

```text
Hi,

This is the second reply.

On September 7, 2022 10:41:26 AM GMT+02:00, "User" <user@outlook.com> wrote:
Hi,

This is the first reply.

On September 7, 2022 10:40:11 AM GMT+02:00, "User" <user@outlook.com> wrote:
Hi,

This is it.
--
Regards,
User
--
Regards,
User
--
Regards,
User
```

### Logs

![image](https://user-images.githubusercontent.com/95375431/188836344-282793bf-a3b1-4630-ab51-ccea738d191a.png)

K-9 Mail only detects the standard signature delimiter. That's two dashes followed by a space. Such signatures will also be displayed slightly differently when viewing a message.

![image](https://user-images.githubusercontent.com/218061/188838300-7ce56654-9c33-4b86-a81d-1048ffa7f957.png)

Hi,

I repeated the test with the standard signature delimiter, and it is slightly different, however still not removed.

![image](https://user-images.githubusercontent.com/95375431/188840604-f74c54ff-61e9-4f6b-b124-8ad13cba90ba.png)

That's unexpected. The signature is detected when displaying the message, but not stripped when replying to the message. It seems to work fine for `text/plain` parts, but not for `text/html` parts.

Anyway, I can reproduce this. That should be enough to find and fix the bug. Thanks for reporting it :+1:
Thanks for the fix! 👍🏻

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

diff --git a/app/core/src/main/java/com/fsck/k9/helper/jsoup/AdvancedNodeTraversor.java b/app/core/src/main/java/com/fsck/k9/helper/jsoup/AdvancedNodeTraversor.java
index cfcab37a4cc..41cd5e245ff 100644
--- a/app/core/src/main/java/com/fsck/k9/helper/jsoup/AdvancedNodeTraversor.java
+++ b/app/core/src/main/java/com/fsck/k9/helper/jsoup/AdvancedNodeTraversor.java
@@ -125,7 +125,7 @@ public FilterResult filter(Node root) {
             Node prev = node;
             node = node.nextSibling();

-            if (headResult == HeadFilterDecision.REMOVE) {
+            if (headResult == HeadFilterDecision.REMOVE || tailResult == TailFilterDecision.REMOVE) {
                 prev.remove();
             }

diff --git a/app/core/src/main/java/com/fsck/k9/message/signature/HtmlSignatureRemover.java b/app/core/src/main/java/com/fsck/k9/message/signature/HtmlSignatureRemover.java
deleted file mode 100644
index 175623b05d2..00000000000
--- a/app/core/src/main/java/com/fsck/k9/message/signature/HtmlSignatureRemover.java
+++ /dev/null
@@ -1,106 +0,0 @@
-package com.fsck.k9.message.signature;
-
-
-import java.util.regex.Pattern;
-
-import androidx.annotation.NonNull;
-import com.fsck.k9.helper.jsoup.AdvancedNodeTraversor;
-import com.fsck.k9.helper.jsoup.NodeFilter;
-import org.jsoup.Jsoup;
-import org.jsoup.nodes.Document;
-import org.jsoup.nodes.Element;
-import org.jsoup.nodes.Node;
-import org.jsoup.nodes.TextNode;
-import org.jsoup.parser.Tag;
-
-
-public class HtmlSignatureRemover {
-    public static String stripSignature(String content) {
-        return new HtmlSignatureRemover().stripSignatureInternal(content);
-    }
-
-    private String stripSignatureInternal(String content) {
-        Document document = Jsoup.parse(content);
-
-        AdvancedNodeTraversor nodeTraversor = new AdvancedNodeTraversor(new StripSignatureFilter());
-        nodeTraversor.filter(document.body());
-
-        return toCompactString(document);
-    }
-
-    private String toCompactString(Document document) {
-        document.outputSettings()
-                .prettyPrint(false)
-                .indentAmount(0);
-
-        return document.html();
-    }
-
-
-    static class StripSignatureFilter implements NodeFilter {
-        private static final Pattern DASH_SIGNATURE_HTML = Pattern.compile("\\s*-- \\s*", Pattern.CASE_INSENSITIVE);
-        private static final Tag BLOCKQUOTE = Tag.valueOf("blockquote");
-        private static final Tag BR = Tag.valueOf("br");
-        private static final Tag P = Tag.valueOf("p");
-
-
-        private boolean signatureFound = false;
-        private boolean lastElementCausedLineBreak = false;
-        private Element brElementPrecedingDashes;
-
-
-        @NonNull
-        @Override
-        public HeadFilterDecision head(Node node, int depth) {
-            if (signatureFound) {
-                return HeadFilterDecision.REMOVE;
-            }
-
-            if (node instanceof Element) {
-                lastElementCausedLineBreak = false;
-
-                Element element = (Element) node;
-                if (element.tag().equals(BLOCKQUOTE)) {
-                    return HeadFilterDecision.SKIP_ENTIRELY;
-                }
-            } else if (node instanceof TextNode) {
-                TextNode textNode = (TextNode) node;
-                if (lastElementCausedLineBreak && DASH_SIGNATURE_HTML.matcher(textNode.getWholeText()).matches()) {
-                    Node nextNode = node.nextSibling();
-                    if (nextNode instanceof Element && ((Element) nextNode).tag().equals(BR)) {
-                        signatureFound = true;
-                        if (brElementPrecedingDashes != null) {
-                            brElementPrecedingDashes.remove();
-                            brElementPrecedingDashes = null;
-                        }
-
-                        return HeadFilterDecision.REMOVE;
-                    }
-                }
-            }
-
-            return HeadFilterDecision.CONTINUE;
-        }
-
-        @NonNull
-        @Override
-        public TailFilterDecision tail(Node node, int depth) {
-            if (signatureFound) {
-                return TailFilterDecision.CONTINUE;
-            }
-
-            if (node instanceof Element) {
-                Element element = (Element) node;
-                boolean elementIsBr = element.tag().equals(BR);
-                if (elementIsBr || element.tag().equals(P)) {
-                    lastElementCausedLineBreak = true;
-                    brElementPrecedingDashes = elementIsBr ? element : null;
-                    return TailFilterDecision.CONTINUE;
-                }
-            }
-
-            lastElementCausedLineBreak = false;
-            return TailFilterDecision.CONTINUE;
-        }
-    }
-}
diff --git a/app/core/src/main/java/com/fsck/k9/message/signature/HtmlSignatureRemover.kt b/app/core/src/main/java/com/fsck/k9/message/signature/HtmlSignatureRemover.kt
new file mode 100644
index 00000000000..07746c20dcb
--- /dev/null
+++ b/app/core/src/main/java/com/fsck/k9/message/signature/HtmlSignatureRemover.kt
@@ -0,0 +1,142 @@
+package com.fsck.k9.message.signature
+
+import com.fsck.k9.helper.jsoup.AdvancedNodeTraversor
+import com.fsck.k9.helper.jsoup.NodeFilter
+import com.fsck.k9.helper.jsoup.NodeFilter.HeadFilterDecision
+import com.fsck.k9.helper.jsoup.NodeFilter.TailFilterDecision
+import java.util.Stack
+import java.util.regex.Pattern
+import org.jsoup.Jsoup
+import org.jsoup.nodes.Document
+import org.jsoup.nodes.Element
+import org.jsoup.nodes.Node
+import org.jsoup.nodes.TextNode
+import org.jsoup.parser.Tag
+
+class HtmlSignatureRemover {
+    private fun stripSignatureInternal(content: String): String {
+        val document = Jsoup.parse(content)
+
+        val nodeTraversor = AdvancedNodeTraversor(StripSignatureFilter())
+        nodeTraversor.filter(document.body())
+
+        return toCompactString(document)
+    }
+
+    private fun toCompactString(document: Document): String {
+        document.outputSettings()
+            .prettyPrint(false)
+            .indentAmount(0)
+
+        return document.html()
+    }
+
+    private class StripSignatureFilter : NodeFilter {
+        private var signatureFound = false
+        private var signatureParentNode: Node? = null
+
+        override fun head(node: Node, depth: Int): HeadFilterDecision {
+            if (signatureFound) return HeadFilterDecision.REMOVE
+
+            if (node.isBlockquote()) {
+                return HeadFilterDecision.SKIP_ENTIRELY
+            } else if (node.isSignatureDelimiter()) {
+                val precedingLineBreak = node.findPrecedingLineBreak()
+                if (precedingLineBreak != null && node.isFollowedByLineBreak()) {
+                    signatureFound = true
+                    signatureParentNode = node.parent()
+                    precedingLineBreak.takeIf { it.isBR() }?.remove()
+
+                    return HeadFilterDecision.REMOVE
+                }
+            }
+
+            return HeadFilterDecision.CONTINUE
+        }
+
+        override fun tail(node: Node, depth: Int): TailFilterDecision {
+            if (signatureFound) {
+                val signatureParentNode = this.signatureParentNode
+                if (node == signatureParentNode) {
+                    return if (signatureParentNode.isEmpty()) {
+                        this.signatureParentNode = signatureParentNode.parent()
+                        TailFilterDecision.REMOVE
+                    } else {
+                        TailFilterDecision.STOP
+                    }
+                }
+            }
+
+            return TailFilterDecision.CONTINUE
+        }
+
+        private fun Node.isBlockquote(): Boolean {
+            return this is Element && tag() == BLOCKQUOTE
+        }
+
+        private fun Node.isSignatureDelimiter(): Boolean {
+            return this is TextNode && DASH_SIGNATURE_HTML.matcher(wholeText).matches()
+        }
+
+        private fun Node.findPrecedingLineBreak(): Node? {
+            val stack = Stack<Node>()
+            stack.push(this)
+
+            while (stack.isNotEmpty()) {
+                val node = stack.pop()
+                val previousSibling = node.previousSibling()
+                if (previousSibling == null) {
+                    val parent = node.parent()
+                    if (parent is Element && parent.isBlock) {
+                        return parent
+                    } else {
+                        stack.push(parent)
+                    }
+                } else if (previousSibling.isLineBreak()) {
+                    return previousSibling
+                }
+            }
+
+            return null
+        }
+
+        private fun Node.isFollowedByLineBreak(): Boolean {
+            val stack = Stack<Node>()
+            stack.push(this)
+
+            while (stack.isNotEmpty()) {
+                val node = stack.pop()
+                val nextSibling = node.nextSibling()
+                if (nextSibling == null) {
+                    val parent = node.parent()
+                    if (parent is Element && parent.isBlock) {
+                        return true
+                    } else {
+                        stack.push(parent)
+                    }
+                } else if (nextSibling.isLineBreak()) {
+                    return true
+                }
+            }
+
+            return false
+        }
+
+        private fun Node?.isBR() = this is Element && tag() == BR
+
+        private fun Node?.isLineBreak() = isBR() || (this is Element && this.isBlock)
+
+        private fun Node.isEmpty(): Boolean = childNodeSize() == 0
+    }
+
+    companion object {
+        private val DASH_SIGNATURE_HTML = Pattern.compile("\\s*-- \\s*", Pattern.CASE_INSENSITIVE)
+        private val BLOCKQUOTE = Tag.valueOf("blockquote")
+        private val BR = Tag.valueOf("br")
+
+        @JvmStatic
+        fun stripSignature(content: String): String {
+            return HtmlSignatureRemover().stripSignatureInternal(content)
+        }
+    }
+}

diff --git a/app/core/src/test/java/com/fsck/k9/message/signature/HtmlSignatureRemoverTest.java b/app/core/src/test/java/com/fsck/k9/message/signature/HtmlSignatureRemoverTest.java
deleted file mode 100644
index bf5f6068609..00000000000
--- a/app/core/src/test/java/com/fsck/k9/message/signature/HtmlSignatureRemoverTest.java
+++ /dev/null
@@ -1,141 +0,0 @@
-package com.fsck.k9.message.signature;
-
-
-import org.junit.Test;
-
-import static com.fsck.k9.message.html.HtmlHelper.extractText;
-import static org.junit.Assert.assertEquals;
-
-
-public class HtmlSignatureRemoverTest {
-    @Test
-    public void shouldStripSignatureFromK9StyleHtml() throws Exception {
-        String html = "This is the body text" +
-                "
" +
-                "--
" +
-                "Sent from my Android device with K-9 Mail. Please excuse my brevity.";
-
-        String withoutSignature = HtmlSignatureRemover.stripSignature(html);
-
-        assertEquals("This is the body text", extractText(withoutSignature));
-    }
-
-    @Test
-    public void shouldStripSignatureFromThunderbirdStyleHtml() throws Exception {
-        String html = "\r\n" +
-                "  \r\n" +
-                "    <meta http-equiv=\"content-type\" content=\"text/html; charset=utf-8\">\r\n" +
-                "  \r\n" +
-                "  \r\n" +
-                "
This is the body text
\r\n" +
-                "
\r\n" +
-                "    --
\r\n" +
-                "
Sent from my Android device with K-9 Mail." +
-                " Please excuse my brevity.
\r\n" +
-                "  \r\n" +
-                "";
-
-        String withoutSignature = HtmlSignatureRemover.stripSignature(html);
-
-        assertEquals("This is the body text", extractText(withoutSignature));
-    }
-
-    @Test
-    public void shouldStripSignatureBeforeBlockquoteTag() throws Exception {
-        String html = "" +
-                "
" +
-                "This is the body text" +
-                "
" +
-                "--
" +
-                "
" +
-                "Sent from my Android device with K-9 Mail. Please excuse my brevity." +
-                "
" +
-                "
" +
-                "";
-
-        String withoutSignature = HtmlSignatureRemover.stripSignature(html);
-
-        assertEquals("" +
-                        "
This is the body text
" +
-                        "",
-                withoutSignature);
-    }
-
-    @Test
-    public void shouldNotStripSignatureInsideBlockquoteTags() throws Exception {
-        String html = "" +
-                "
" +
-                "This is some quoted text" +
-                "
" +
-                "--
" +
-                "Inner signature" +
-                "
" +
-                "
" +
-                "This is the body text" +
-                "
" +
-                "";
-
-        String withoutSignature = HtmlSignatureRemover.stripSignature(html);
-
-        assertEquals("" +
-                        "
" +
-                        "This is some quoted text" +
-                        "
" +
-                        "--
" +
-                        "Inner signature" +
-                        "
" +
-                        "
This is the body text
" +
-                        "",
-                withoutSignature);
-    }
-
-    @Test
-    public void shouldStripSignatureBetweenBlockquoteTags() throws Exception {
-        String html = "" +
-                "
" +
-                "Some quote" +
-                "
" +
-                "
" +
-                "This is the body text" +
-                "
" +
-                "--
" +
-                "
" +
-                "Sent from my Android device with K-9 Mail. Please excuse my brevity." +
-                "
" +
-                "
" +
-                "--
" +
-                "Signature inside signature" +
-                "
" +
-                "";
-
-        String withoutSignature = HtmlSignatureRemover.stripSignature(html);
-
-        assertEquals("" +
-                        "
Some quote
" +
-                        "
This is the body text
" +
-                        "",
-                withoutSignature);
-    }
-
-    @Test
-    public void shouldStripSignatureAfterLastBlockquoteTags() throws Exception {
-        String html = "" +
-                "This is the body text" +
-                "
" +
-                "
" +
-                "Some quote" +
-                "
" +
-                "
" +
-                "--
" +
-                "Sent from my Android device with K-9 Mail. Please excuse my brevity." +
-                "";
-
-        String withoutSignature = HtmlSignatureRemover.stripSignature(html);
-
-        assertEquals("" +
-                        "This is the body text
" +
-                        "
Some quote
" +
-                        "",
-                withoutSignature);
-    }
-}
diff --git a/app/core/src/test/java/com/fsck/k9/message/signature/HtmlSignatureRemoverTest.kt b/app/core/src/test/java/com/fsck/k9/message/signature/HtmlSignatureRemoverTest.kt
new file mode 100644
index 00000000000..f9ca1af7d4d
--- /dev/null
+++ b/app/core/src/test/java/com/fsck/k9/message/signature/HtmlSignatureRemoverTest.kt
@@ -0,0 +1,184 @@
+package com.fsck.k9.message.signature
+
+import com.fsck.k9.message.html.HtmlHelper.extractText
+import com.fsck.k9.message.signature.HtmlSignatureRemover.Companion.stripSignature
+import com.fsck.k9.testing.removeNewlines
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class HtmlSignatureRemoverTest {
+    @Test
+    fun `old K-9 Mail signature format`() {
+        val html =
+            """This is the body text
--
Sent from my Android device with K-9 Mail. Please excuse my brevity."""
+
+        val withoutSignature = stripSignature(html)
+
+        assertThat(extractText(withoutSignature)).isEqualTo("This is the body text")
+    }
+
+    @Test
+    fun `old Thunderbird signature format`() {
+        val html =
+            """
+
+
+                <meta http-equiv="content-type" content="text/html; charset=utf-8">
+
+
+
This is the body text

+

+                --

+
Sent from my Android device with K-9 Mail. Please excuse my brevity.

+
+
+            """.trimIndent()
+
+        val withoutSignature = stripSignature(html)
+
+        assertThat(extractText(withoutSignature)).isEqualTo("This is the body text")
+    }
+
+    @Test
+    fun `signature before blockquote tag`() {
+        val html =
+            """
+
+
+
+

+            This is the body text

+            --

+
Sent from my Android device with K-9 Mail. Please excuse my brevity.

+

+
+
+            """.trimIndent().removeNewlines()
+
+        val withoutSignature = stripSignature(html)
+
+        assertThat(withoutSignature).isEqualTo(
+            """
This is the body text
"""
+        )
+    }
+
+    @Test
+    fun `should not strip signature inside blockquote tag`() {
+        val html =
+            """
+
+
+
+

+            This is some quoted text

+            --

+            Inner signature
+

+

+            This is the body text
+

+
+
+            """.trimIndent().removeNewlines()
+
+        val withoutSignature = stripSignature(html)
+
+        assertThat(withoutSignature).isEqualTo(html)
+    }
+
+    @Test
+    fun `signature between blockquote tags`() {
+        val html =
+            """
+
+
+
+
Some quote

+
This is the body text

+            --

+
Sent from my Android device with K-9 Mail. Please excuse my brevity.

+
--
Signature inside signature
+

+
+
+            """.trimIndent().removeNewlines()
+
+        val withoutSignature = stripSignature(html)
+
+        assertThat(withoutSignature).isEqualTo(
+            """
+
+
+
+
Some quote

+
This is the body text

+
+
+            """.trimIndent().removeNewlines()
+        )
+    }
+
+    @Test
+    fun `signature after last blockquote tag`() {
+        val html =
+            """
+
+
+
+            This is the body text

+
Some quote

+

+            --

+            Sent from my Android device with K-9 Mail. Please excuse my brevity.
+
+
+            """.trimIndent().removeNewlines()
+
+        val withoutSignature = stripSignature(html)
+
+        assertThat(withoutSignature).isEqualTo(
+            """
+
+
+
+            This is the body text

+
Some quote

+
+
+            """.trimIndent().removeNewlines()
+        )
+    }
+
+    @Test
+    fun `K-9 Mail signature format`() {
+        val html =
+            """
+            <!DOCTYPE html>
+
+
+            This is the body text.

+

+

+            --

+            And this is the signature text.
+

+
+
+            """.trimIndent().removeNewlines()
+
+        val withoutSignature = stripSignature(html)
+
+        assertThat(withoutSignature).isEqualTo(
+            """
+            <!doctype html>
+
+
+
+            This is the body text.

+

+
+
+            """.trimIndent().removeNewlines()
+        )
+    }
+}
diff --git a/app/testing/src/main/java/com/fsck/k9/testing/StringExtensions.kt b/app/testing/src/main/java/com/fsck/k9/testing/StringExtensions.kt
new file mode 100644
index 00000000000..2e5a461c19c
--- /dev/null
+++ b/app/testing/src/main/java/com/fsck/k9/testing/StringExtensions.kt
@@ -0,0 +1,3 @@
+package com.fsck.k9.testing
+
+fun String.removeNewlines(): String = replace("([\\r\\n])".toRegex(), "")