Merge pull request #6051 from TheLastProject/feature/2943

Add support for List-Unsubscribe
This commit is contained in:
cketti 2022-05-28 14:28:35 +02:00 committed by GitHub
commit 7e5c6b05c4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 219 additions and 14 deletions

View file

@ -0,0 +1,55 @@
package com.fsck.k9.helper
import android.net.Uri
import com.fsck.k9.mail.Message
import java.util.regex.Pattern
object ListUnsubscribeHelper {
private const val LIST_UNSUBSCRIBE_HEADER = "List-Unsubscribe"
private val MAILTO_CONTAINER_PATTERN = Pattern.compile("<(mailto:.+?)>")
private val HTTPS_CONTAINER_PATTERN = Pattern.compile("<(https:.+?)>")
// As K-9 Mail is an email client, we prefer a mailto: unsubscribe method
// but if none is found, a https URL is acceptable too
fun getPreferredListUnsubscribeUri(message: Message): UnsubscribeUri? {
val headerValues = message.getHeader(LIST_UNSUBSCRIBE_HEADER)
if (headerValues.isEmpty()) {
return null
}
val listUnsubscribeUris = mutableListOf<Uri>()
for (headerValue in headerValues) {
val uri = extractUri(headerValue) ?: continue
if (uri.scheme == "mailto") {
return MailtoUnsubscribeUri(uri)
}
// If we got here it must be HTTPS
listUnsubscribeUris.add(uri)
}
if (listUnsubscribeUris.isNotEmpty()) {
return HttpsUnsubscribeUri(listUnsubscribeUris[0])
}
return null
}
private fun extractUri(headerValue: String?): Uri? {
if (headerValue == null || headerValue.isEmpty()) {
return null
}
var matcher = MAILTO_CONTAINER_PATTERN.matcher(headerValue)
if (matcher.find()) {
return Uri.parse(matcher.group(1))
}
matcher = HTTPS_CONTAINER_PATTERN.matcher(headerValue)
if (matcher.find()) {
return Uri.parse(matcher.group(1))
}
return null
}
}

View file

@ -0,0 +1,10 @@
package com.fsck.k9.helper
import android.net.Uri
sealed interface UnsubscribeUri {
val uri: Uri
}
data class MailtoUnsubscribeUri(override val uri: Uri) : UnsubscribeUri
data class HttpsUnsubscribeUri(override val uri: Uri) : UnsubscribeUri

View file

@ -4,6 +4,7 @@ package com.fsck.k9.mailstore;
import java.util.Collections;
import java.util.List;
import com.fsck.k9.helper.UnsubscribeUri;
import com.fsck.k9.mail.Body;
import com.fsck.k9.mail.Message;
import com.fsck.k9.mail.MessagingException;
@ -24,6 +25,7 @@ public class MessageViewInfo {
public final List<AttachmentViewInfo> attachments;
public final String extraText;
public final List<AttachmentViewInfo> extraAttachments;
public final UnsubscribeUri preferredUnsubscribeUri;
public MessageViewInfo(
@ -32,7 +34,8 @@ public class MessageViewInfo {
String text, List<AttachmentViewInfo> attachments,
CryptoResultAnnotation cryptoResultAnnotation,
AttachmentResolver attachmentResolver,
String extraText, List<AttachmentViewInfo> extraAttachments) {
String extraText, List<AttachmentViewInfo> extraAttachments,
UnsubscribeUri preferredUnsubscribeUri) {
this.message = message;
this.isMessageIncomplete = isMessageIncomplete;
this.rootPart = rootPart;
@ -44,13 +47,15 @@ public class MessageViewInfo {
this.attachments = attachments;
this.extraText = extraText;
this.extraAttachments = extraAttachments;
this.preferredUnsubscribeUri = preferredUnsubscribeUri;
}
static MessageViewInfo createWithExtractedContent(Message message, Part rootPart, boolean isMessageIncomplete,
String text, List<AttachmentViewInfo> attachments, AttachmentResolver attachmentResolver) {
String text, List<AttachmentViewInfo> attachments, AttachmentResolver attachmentResolver,
UnsubscribeUri preferredUnsubscribeUri) {
return new MessageViewInfo(
message, isMessageIncomplete, rootPart, null, false, text, attachments, null, attachmentResolver, null,
Collections.<AttachmentViewInfo>emptyList());
Collections.<AttachmentViewInfo>emptyList(), preferredUnsubscribeUri);
}
public static MessageViewInfo createWithErrorState(Message message, boolean isMessageIncomplete) {
@ -59,7 +64,7 @@ public class MessageViewInfo {
Part emptyPart = new MimeBodyPart(emptyBody, "text/plain");
String subject = message.getSubject();
return new MessageViewInfo(message, isMessageIncomplete, emptyPart, subject, false, null, null, null, null,
null, null);
null, null, null);
} catch (MessagingException e) {
throw new AssertionError(e);
}
@ -67,7 +72,7 @@ public class MessageViewInfo {
public static MessageViewInfo createForMetadataOnly(Message message, boolean isMessageIncomplete) {
String subject = message.getSubject();
return new MessageViewInfo(message, isMessageIncomplete, null, subject, false, null, null, null, null, null, null);
return new MessageViewInfo(message, isMessageIncomplete, null, subject, false, null, null, null, null, null, null, null);
}
MessageViewInfo withCryptoData(CryptoResultAnnotation rootPartAnnotation, String extraViewableText,
@ -76,14 +81,15 @@ public class MessageViewInfo {
message, isMessageIncomplete, rootPart, subject, isSubjectEncrypted, text, attachments,
rootPartAnnotation,
attachmentResolver,
extraViewableText, extraAttachmentInfos
extraViewableText, extraAttachmentInfos,
preferredUnsubscribeUri
);
}
MessageViewInfo withSubject(String subject, boolean isSubjectEncrypted) {
return new MessageViewInfo(
message, isMessageIncomplete, rootPart, subject, isSubjectEncrypted, text, attachments,
cryptoResultAnnotation, attachmentResolver, extraText, extraAttachments
cryptoResultAnnotation, attachmentResolver, extraText, extraAttachments, preferredUnsubscribeUri
);
}
}

View file

@ -12,6 +12,8 @@ import androidx.annotation.WorkerThread;
import com.fsck.k9.CoreResourceProvider;
import com.fsck.k9.crypto.MessageCryptoStructureDetector;
import com.fsck.k9.helper.ListUnsubscribeHelper;
import com.fsck.k9.helper.UnsubscribeUri;
import com.fsck.k9.mail.Address;
import com.fsck.k9.mail.Flag;
import com.fsck.k9.mail.Message;
@ -144,8 +146,11 @@ public class MessageViewInfoExtractor {
boolean isMessageIncomplete =
!message.isSet(Flag.X_DOWNLOADED_FULL) || MessageExtractor.hasMissingParts(message);
UnsubscribeUri preferredUnsubscribeUri = ListUnsubscribeHelper.INSTANCE.getPreferredListUnsubscribeUri(message);
return MessageViewInfo.createWithExtractedContent(
message, contentPart, isMessageIncomplete, viewable.html, attachmentInfos, attachmentResolver);
message, contentPart, isMessageIncomplete, viewable.html, attachmentInfos, attachmentResolver,
preferredUnsubscribeUri);
}
private ViewableExtractedText extractViewableAndAttachments(List<Part> parts,

View file

@ -0,0 +1,95 @@
package com.fsck.k9.helper
import androidx.core.net.toUri
import com.fsck.k9.RobolectricTest
import com.fsck.k9.mail.internet.MimeMessage
import com.google.common.truth.Truth.assertThat
import kotlin.test.assertNull
import org.junit.Test
class ListUnsubscribeHelperTest : RobolectricTest() {
@Test
fun `get list unsubscribe url - should accept mailto`() {
val message = buildMimeMessageWithListUnsubscribeValue(
"<mailto:unsubscribe@example.com>"
)
val result = ListUnsubscribeHelper.getPreferredListUnsubscribeUri(message)
assertThat(result).isEqualTo(MailtoUnsubscribeUri("mailto:unsubscribe@example.com".toUri()))
}
@Test
fun `get list unsubscribe url - should prefer mailto 1`() {
val message = buildMimeMessageWithListUnsubscribeValue(
"<mailto:unsubscribe@example.com>, <https://example.com/unsubscribe>"
)
val result = ListUnsubscribeHelper.getPreferredListUnsubscribeUri(message)
assertThat(result).isEqualTo(MailtoUnsubscribeUri("mailto:unsubscribe@example.com".toUri()))
}
@Test
fun `get list unsubscribe url - should prefer mailto 2`() {
val message = buildMimeMessageWithListUnsubscribeValue(
"<https://example.com/unsubscribe>, <mailto:unsubscribe@example.com>"
)
val result = ListUnsubscribeHelper.getPreferredListUnsubscribeUri(message)
assertThat(result).isEqualTo(MailtoUnsubscribeUri("mailto:unsubscribe@example.com".toUri()))
}
@Test
fun `get list unsubscribe url - should allow https if no mailto`() {
val message = buildMimeMessageWithListUnsubscribeValue(
"<https://example.com/unsubscribe>"
)
val result = ListUnsubscribeHelper.getPreferredListUnsubscribeUri(message)
assertThat(result).isEqualTo(HttpsUnsubscribeUri("https://example.com/unsubscribe".toUri()))
}
@Test
fun `get list unsubscribe url - should correctly parse uncommon urls`() {
val message = buildMimeMessageWithListUnsubscribeValue(
"<https://domain.example/one,two>"
)
val result = ListUnsubscribeHelper.getPreferredListUnsubscribeUri(message)
assertThat(result).isEqualTo(HttpsUnsubscribeUri("https://domain.example/one,two".toUri()))
}
@Test
fun `get list unsubscribe url - should ignore unsafe entries 1`() {
val message = buildMimeMessageWithListUnsubscribeValue(
"<http://example.com/unsubscribe>"
)
val result = ListUnsubscribeHelper.getPreferredListUnsubscribeUri(message)
assertNull(result)
}
@Test
fun `get list unsubscribe url - should ignore unsafe entries 2`() {
val message = buildMimeMessageWithListUnsubscribeValue(
"<http://example.com/unsubscribe>, <https://example.com/unsubscribe>"
)
val result = ListUnsubscribeHelper.getPreferredListUnsubscribeUri(message)
assertThat(result).isEqualTo(HttpsUnsubscribeUri("https://example.com/unsubscribe".toUri()))
}
@Test
fun `get list unsubscribe url - should ignore empty`() {
val message = buildMimeMessageWithListUnsubscribeValue(
""
)
val result = ListUnsubscribeHelper.getPreferredListUnsubscribeUri(message)
assertNull(result)
}
@Test
fun `get list unsubscribe url - should ignore missing header`() {
val message = MimeMessage()
val result = ListUnsubscribeHelper.getPreferredListUnsubscribeUri(message)
assertNull(result)
}
private fun buildMimeMessageWithListUnsubscribeValue(value: String): MimeMessage {
val message = MimeMessage()
message.addHeader("List-Unsubscribe", value)
return message
}
}

View file

@ -999,6 +999,9 @@ open class MessageList :
} else if (id == R.id.move_to_drafts) {
messageViewFragment!!.onMoveToDrafts()
return true
} else if (id == R.id.unsubscribe) {
messageViewFragment!!.onUnsubscribe()
return true
} else if (id == R.id.show_headers) {
startActivity(MessageSourceActivity.createLaunchIntent(this, messageViewFragment!!.messageReference))
return true
@ -1094,6 +1097,7 @@ open class MessageList :
menu.findItem(R.id.refile).isVisible = false
menu.findItem(R.id.toggle_unread).isVisible = false
menu.findItem(R.id.toggle_message_view_theme).isVisible = false
menu.findItem(R.id.unsubscribe).isVisible = false
menu.findItem(R.id.show_headers).isVisible = false
} else {
// hide prev/next buttons in split mode
@ -1176,6 +1180,8 @@ open class MessageList :
if (messageViewFragment!!.isOutbox) {
menu.findItem(R.id.move_to_drafts).isVisible = true
}
menu.findItem(R.id.unsubscribe).isVisible = messageViewFragment!!.canMessageBeUnsubscribed()
}
// Set visibility of menu items related to the message list

View file

@ -36,6 +36,9 @@ import com.fsck.k9.Account;
import com.fsck.k9.DI;
import com.fsck.k9.K9;
import com.fsck.k9.Preferences;
import com.fsck.k9.activity.MessageCompose;
import com.fsck.k9.helper.MailtoUnsubscribeUri;
import com.fsck.k9.helper.UnsubscribeUri;
import com.fsck.k9.ui.choosefolder.ChooseFolderActivity;
import com.fsck.k9.activity.MessageLoaderHelper;
import com.fsck.k9.activity.MessageLoaderHelper.MessageLoaderCallbacks;
@ -96,6 +99,7 @@ public class MessageViewFragment extends Fragment implements ConfirmationDialogF
private MessageLoaderHelper messageLoaderHelper;
private MessageCryptoPresenter messageCryptoPresenter;
private Long showProgressThreshold;
private UnsubscribeUri preferredUnsubscribeUri;
/**
* Used to temporarily store the destination folder for refile operations if a confirmation
@ -655,6 +659,23 @@ public class MessageViewFragment extends Fragment implements ConfirmationDialogF
return mMessageReference.getFolderId() != spamFolderId;
}
public boolean canMessageBeUnsubscribed() {
return preferredUnsubscribeUri != null;
}
public void onUnsubscribe() {
if (preferredUnsubscribeUri instanceof MailtoUnsubscribeUri) {
Intent intent = new Intent(mContext, MessageCompose.class);
intent.setAction(Intent.ACTION_VIEW);
intent.setData(preferredUnsubscribeUri.getUri());
intent.putExtra(MessageCompose.EXTRA_ACCOUNT, mMessageReference.getAccountUuid());
startActivity(intent);
} else {
Intent intent = new Intent(Intent.ACTION_VIEW, preferredUnsubscribeUri.getUri());
startActivity(intent);
}
}
public Context getApplicationContext() {
return mContext;
}
@ -779,12 +800,14 @@ public class MessageViewFragment extends Fragment implements ConfirmationDialogF
@Override
public void onMessageViewInfoLoadFinished(MessageViewInfo messageViewInfo) {
showMessage(messageViewInfo);
preferredUnsubscribeUri = messageViewInfo.preferredUnsubscribeUri;
showProgressThreshold = null;
}
@Override
public void onMessageViewInfoLoadFailed(MessageViewInfo messageViewInfo) {
showMessage(messageViewInfo);
preferredUnsubscribeUri = null;
showProgressThreshold = null;
}

View file

@ -147,6 +147,10 @@
</menu>
</item>
<item android:id="@+id/unsubscribe"
app:showAsAction="never"
android:title="@string/unsubscribe_action"/>
<item android:id="@+id/show_headers"
app:showAsAction="never"
android:title="@string/show_headers_action"/>

View file

@ -137,6 +137,7 @@ Please submit bug reports, contribute new features and ask questions at
<string name="flag_action">Add star</string>
<string name="unflag_action">Remove star</string>
<string name="copy_action">Copy</string>
<string name="unsubscribe_action">Unsubscribe</string>
<string name="show_headers_action">Show headers</string>
<plurals name="copy_address_to_clipboard">
<item quantity="one">Address copied to clipboard</item>

View file

@ -62,7 +62,7 @@ class AttachmentPresenterTest : K9RobolectricTest() {
)
val messageViewInfo = MessageViewInfo(
message, false, message, SUBJECT, false, TEXT, listOf(attachmentViewInfo), null, attachmentResolver,
EXTRA_TEXT, ArrayList()
EXTRA_TEXT, ArrayList(), null
)
mockLoaderManager({ attachmentPresenter.attachments.get(0) as Attachment })
@ -90,7 +90,7 @@ class AttachmentPresenterTest : K9RobolectricTest() {
)
val messageViewInfo = MessageViewInfo(
message, false, message, SUBJECT, false, TEXT, listOf(attachmentViewInfo), null, attachmentResolver,
EXTRA_TEXT, ArrayList()
EXTRA_TEXT, ArrayList(), null
)
val result = attachmentPresenter.loadAllAvailableAttachments(messageViewInfo)
@ -111,7 +111,7 @@ class AttachmentPresenterTest : K9RobolectricTest() {
val attachmentViewInfo = AttachmentViewInfo(MIME_TYPE, ATTACHMENT_NAME, size, URI, true, localBodyPart, true)
val messageViewInfo = MessageViewInfo(
message, false, message, SUBJECT, false, TEXT, listOf(attachmentViewInfo), null, attachmentResolver,
EXTRA_TEXT, ArrayList()
EXTRA_TEXT, ArrayList(), null
)
mockLoaderManager({ attachmentPresenter.inlineAttachments.get(contentId) as Attachment })
@ -139,7 +139,7 @@ class AttachmentPresenterTest : K9RobolectricTest() {
val attachmentViewInfo = AttachmentViewInfo(MIME_TYPE, ATTACHMENT_NAME, size, URI, true, localBodyPart, false)
val messageViewInfo = MessageViewInfo(
message, false, message, SUBJECT, false, TEXT, listOf(attachmentViewInfo), null, attachmentResolver,
EXTRA_TEXT, ArrayList()
EXTRA_TEXT, ArrayList(), null
)
val result = attachmentPresenter.loadAllAvailableAttachments(messageViewInfo)

View file

@ -538,7 +538,7 @@ internal class RealImapFolder(
fetchFields.add("RFC822.SIZE")
fetchFields.add(
"BODY.PEEK[HEADER.FIELDS (date subject from content-type to cc " +
"reply-to message-id references in-reply-to " +
"reply-to message-id references in-reply-to list-unsubscribe " +
K9MailLib.IDENTITY_HEADER + " " + K9MailLib.CHAT_HEADER + ")]"
)
}

View file

@ -697,7 +697,7 @@ class RealImapFolderTest {
verify(imapConnection).sendCommand(
"UID FETCH 1 (UID INTERNALDATE RFC822.SIZE BODY.PEEK[HEADER.FIELDS " +
"(date subject from content-type to cc reply-to message-id references in-reply-to " +
"(date subject from content-type to cc reply-to message-id references in-reply-to list-unsubscribe " +
"X-K9mail-Identity Chat-Version)])",
false
)