Merge pull request #6051 from TheLastProject/feature/2943
Add support for List-Unsubscribe
This commit is contained in:
commit
7e5c6b05c4
12 changed files with 219 additions and 14 deletions
|
@ -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
|
||||
}
|
||||
}
|
10
app/core/src/main/java/com/fsck/k9/helper/UnsubscribeUri.kt
Normal file
10
app/core/src/main/java/com/fsck/k9/helper/UnsubscribeUri.kt
Normal 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
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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"/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 + ")]"
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue