mirror of
https://github.com/tutao/tutanota.git
synced 2025-12-08 06:09:50 +00:00
This commit modifies the list-unsubscribe flow for unsubscription from newsletters, with the following scenarios: - Link with POST: The mail has a link and a List-Unsubscribe-Post header to allow one-click unsubscribe. In that case, we send the POST request from the client for the Desktop, iOS, and Android app. For the web app, we send the link to the server and do a ListUnsubscribeService request. - Link with GET: The mail has a link but does not allow one-click unsubscribe. In this case, we show a dialog which has the option to open the link in the browser. - Link with mailto: The mail does not have a http link in the list-unsubscribe header, but it has a mailto link for unsubscription. In this case, we show a dialog which has the option to open a new mail dialog which uses the mailto link. Co-authored-by: das <das@tutao.de> Co-authored-by: jomapp <17314077+jomapp@users.noreply.github.com> Co-authored-by: sug <sug@tutao.de> Co-authored-by: kib <104761667+kibibytium@users.noreply.github.com>
324 lines
14 KiB
TypeScript
324 lines
14 KiB
TypeScript
import o from "@tutao/otest"
|
|
import { MailViewerViewModel, UnsubscribeType } from "../../../../src/mail-app/mail/view/MailViewerViewModel.js"
|
|
import {
|
|
ConversationEntryTypeRef,
|
|
HeaderTypeRef,
|
|
Mail,
|
|
MailAddressTypeRef,
|
|
MailDetails,
|
|
MailDetailsTypeRef,
|
|
MailTypeRef,
|
|
RecipientsTypeRef,
|
|
} from "../../../../src/common/api/entities/tutanota/TypeRefs.js"
|
|
import { matchers, object, verify, when } from "testdouble"
|
|
import { EntityClient } from "../../../../src/common/api/common/EntityClient.js"
|
|
import { ConfigurationDatabase } from "../../../../src/common/api/worker/facades/lazy/ConfigurationDatabase.js"
|
|
import { LoginController } from "../../../../src/common/api/main/LoginController.js"
|
|
import { EventController } from "../../../../src/common/api/main/EventController.js"
|
|
import { WorkerFacade } from "../../../../src/common/api/worker/facades/WorkerFacade.js"
|
|
import { NotFoundError } from "../../../../src/common/api/common/error/RestError.js"
|
|
import { SearchModel } from "../../../../src/mail-app/search/model/SearchModel.js"
|
|
import { MailFacade } from "../../../../src/common/api/worker/facades/lazy/MailFacade.js"
|
|
import { FileController } from "../../../../src/common/file/FileController.js"
|
|
import { createTestEntity } from "../../TestUtils.js"
|
|
import { ExternalImageRule, MailState } from "../../../../src/common/api/common/TutanotaConstants.js"
|
|
import { GroupInfoTypeRef } from "../../../../src/common/api/entities/sys/TypeRefs.js"
|
|
import { CryptoFacade } from "../../../../src/common/api/worker/crypto/CryptoFacade.js"
|
|
import { ContactImporter } from "../../../../src/mail-app/contacts/ContactImporter.js"
|
|
import { MailboxDetail, MailboxModel } from "../../../../src/common/mailFunctionality/MailboxModel.js"
|
|
import { ContactModel } from "../../../../src/common/contactsFunctionality/ContactModel.js"
|
|
import { SendMailModel } from "../../../../src/common/mailFunctionality/SendMailModel.js"
|
|
import { MailModel } from "../../../../src/mail-app/mail/model/MailModel.js"
|
|
import { downcast } from "@tutao/tutanota-utils"
|
|
import { CalendarEventsRepository } from "../../../../src/common/calendar/date/CalendarEventsRepository"
|
|
import { UndoModel } from "../../../../src/mail-app/UndoModel"
|
|
import { MailViewModel } from "../../../../src/mail-app/mail/view/MailViewModel"
|
|
import { isBrowser } from "../../../../src/common/api/common/Env"
|
|
import { CommonSystemFacade } from "../../../../src/common/native/common/generatedipc/CommonSystemFacade"
|
|
import { unsubscribe } from "../../../../src/mail-app/mail/view/MailViewerUtils"
|
|
|
|
o.spec("MailViewerViewModel", function () {
|
|
let mail: Mail
|
|
let mailDetails: MailDetails
|
|
let showFolder: boolean = false
|
|
let entityClient: EntityClient
|
|
|
|
let mailModel: MailModel
|
|
let commonSystemFacade: CommonSystemFacade
|
|
let mailboxModel: MailboxModel
|
|
let contactModel: ContactModel
|
|
let configFacade: ConfigurationDatabase
|
|
let fileController: FileController
|
|
let logins: LoginController
|
|
let eventController: EventController
|
|
let workerFacade: WorkerFacade
|
|
let searchModel: SearchModel
|
|
let mailFacade: MailFacade
|
|
let sendMailModel: SendMailModel
|
|
let cryptoFacade: CryptoFacade
|
|
let contactImporter: ContactImporter
|
|
let eventsRepository: CalendarEventsRepository
|
|
let undoModel: UndoModel
|
|
|
|
function makeViewModelWithHeaders(headers: string) {
|
|
entityClient = object()
|
|
mailModel = object()
|
|
commonSystemFacade = object()
|
|
mailboxModel = object()
|
|
contactModel = object()
|
|
configFacade = object()
|
|
fileController = object()
|
|
logins = object()
|
|
sendMailModel = object()
|
|
eventController = object()
|
|
workerFacade = object()
|
|
searchModel = object()
|
|
mailFacade = object()
|
|
cryptoFacade = object()
|
|
contactImporter = object()
|
|
eventsRepository = object()
|
|
prepareMailWithHeaders(mailFacade, headers)
|
|
undoModel = object()
|
|
|
|
return new MailViewerViewModel(
|
|
mail,
|
|
showFolder,
|
|
entityClient,
|
|
mailboxModel,
|
|
mailModel,
|
|
commonSystemFacade,
|
|
contactModel,
|
|
configFacade,
|
|
fileController,
|
|
logins,
|
|
eventController,
|
|
workerFacade,
|
|
searchModel,
|
|
mailFacade,
|
|
cryptoFacade,
|
|
async () => contactImporter,
|
|
[],
|
|
eventsRepository,
|
|
undoModel,
|
|
)
|
|
}
|
|
|
|
function prepareMailWithHeaders(mailFacade: MailFacade, headers: string) {
|
|
const toRecipients = [
|
|
createTestEntity(MailAddressTypeRef, {
|
|
name: "Ma",
|
|
address: "ma@tuta.com",
|
|
}),
|
|
]
|
|
mail = createTestEntity(MailTypeRef, {
|
|
_id: ["mailListId", "mailId"],
|
|
listUnsubscribe: true,
|
|
mailDetails: ["mailDetailsListId", "mailDetailsId"],
|
|
state: MailState.RECEIVED,
|
|
sender: createTestEntity(MailAddressTypeRef, {
|
|
name: "ListSender",
|
|
address: "sender@list.com",
|
|
}),
|
|
})
|
|
mailDetails = createTestEntity(MailDetailsTypeRef, {
|
|
headers: createTestEntity(HeaderTypeRef, {
|
|
headers,
|
|
}),
|
|
recipients: createTestEntity(RecipientsTypeRef, {
|
|
toRecipients,
|
|
}),
|
|
body: object(),
|
|
})
|
|
mailDetails.body.text = "Hello World"
|
|
mailDetails.body.compressedText = null
|
|
downcast(mailDetails.body)._errors = undefined
|
|
when(mailFacade.loadMailDetailsBlob(mail)).thenResolve(mailDetails)
|
|
when(configFacade.getExternalImageRule(mail.sender.address)).thenResolve(ExternalImageRule.None)
|
|
when(mailModel.checkMailForPhishing(matchers.anything(), matchers.anything())).thenResolve(false)
|
|
when(entityClient.load(ConversationEntryTypeRef, mail.conversationEntry)).thenResolve(object())
|
|
when(workerFacade.urlify(matchers.anything())).thenResolve("")
|
|
when(commonSystemFacade.executePostRequest(matchers.anything(), matchers.anything())).thenResolve(true)
|
|
}
|
|
|
|
o.spec("unsubscribe", function () {
|
|
function initUnsubscribeHeaders(headers: string) {
|
|
const viewModel = makeViewModelWithHeaders(headers)
|
|
const mailGroupInfo = createTestEntity(GroupInfoTypeRef, {
|
|
mailAddressAliases: [],
|
|
mailAddress: "ma@tuta.com",
|
|
})
|
|
const mailboxDetail = { mailGroupInfo: mailGroupInfo } as MailboxDetail
|
|
when(mailModel.getMailboxDetailsForMail(matchers.anything())).thenResolve(mailboxDetail)
|
|
when(logins.getUserController()).thenReturn({ userGroupInfo: mailGroupInfo })
|
|
return viewModel
|
|
}
|
|
|
|
async function testHeaderUnsubscribePost(headers: string, expectedUrl: string, expectedPostResult: boolean) {
|
|
const viewModel = initUnsubscribeHeaders(headers)
|
|
const unsubscribeActions = await viewModel.determineUnsubscribeOrder()
|
|
|
|
const unsubscribeAction = unsubscribeActions.shift()!
|
|
const postResult = await viewModel.unsubscribePost(unsubscribeAction)
|
|
|
|
if (!isBrowser()) {
|
|
verify(commonSystemFacade.executePostRequest(unsubscribeAction.postUrl, "List-Unsubscribe: One-Click"), { times: expectedPostResult ? 1 : 0 })
|
|
} else {
|
|
verify(mailModel.unsubscribe(mail, unsubscribeAction.postUrl))
|
|
}
|
|
o(unsubscribeAction.postUrl).equals(expectedUrl)
|
|
o(postResult).equals(expectedPostResult)
|
|
}
|
|
|
|
o.spec("list-unsubscribe http url", function () {
|
|
o("with GET unsubscribe url", async function () {
|
|
const headers = ["List-Unsubscribe: <http://unsub.me?id=2134>, <mailto:unsubscribe@newsletter.de>"]
|
|
const expectedGetUrl = "http://unsub.me?id=2134"
|
|
await testHeaderUnsubscribePost(headers.join("\r\n"), expectedGetUrl, false)
|
|
})
|
|
o("with POST", async function () {
|
|
const headers = [
|
|
"List-Unsubscribe: <http://unsub.me?id=2134>, <mailto:unsubscribe@newsletter.de>",
|
|
"List-Unsubscribe-Post: List-Unsubscribe=One-Click",
|
|
]
|
|
const expectedPostUrl = "http://unsub.me?id=2134"
|
|
await testHeaderUnsubscribePost(headers.join("\r\n"), expectedPostUrl, true)
|
|
})
|
|
o("with POST whitespace", async function () {
|
|
const headers = [
|
|
"List-Unsubscribe: <http://unsub.me?id=2134>, <mailto:unsubscribe@newsletter.de>",
|
|
"List-Unsubscribe-Post: List-Unsubscribe=One-Click",
|
|
]
|
|
const expectedPostUrl = "http://unsub.me?id=2134"
|
|
await testHeaderUnsubscribePost(headers.join("\r\n"), expectedPostUrl, true)
|
|
})
|
|
o("with POST tab", async function () {
|
|
const headers = [
|
|
"List-Unsubscribe:\t <http://unsub.me?id=2134>, <mailto:unsubscribe@newsletter.de>",
|
|
"List-Unsubscribe-Post: List-Unsubscribe=One-Click",
|
|
]
|
|
const expectedPostUrl = "http://unsub.me?id=2134"
|
|
await testHeaderUnsubscribePost(headers.join("\r\n"), expectedPostUrl, true)
|
|
})
|
|
o("with POST newline whitespace", async function () {
|
|
const headers = [
|
|
"List-Unsubscribe: \r\n <http://unsub.me?id=2134>, <mailto:unsubscribe@newsletter.de>",
|
|
"List-Unsubscribe-Post: List-Unsubscribe=One-Click",
|
|
]
|
|
const expectedPostUrl = "http://unsub.me?id=2134"
|
|
await testHeaderUnsubscribePost(headers.join("\r\n"), expectedPostUrl, true)
|
|
})
|
|
o("with POST newline tab", async function () {
|
|
const headers = [
|
|
"List-Unsubscribe: \r\n\t<http://unsub.me?id=2134>, <mailto:unsubscribe@newsletter.de>",
|
|
"List-Unsubscribe-Post: List-Unsubscribe=One-Click",
|
|
]
|
|
const expectedPostUrl = "http://unsub.me?id=2134"
|
|
await testHeaderUnsubscribePost(headers.join("\r\n"), expectedPostUrl, true)
|
|
})
|
|
o("no list unsubscribe header", async function () {
|
|
const headers = "To: InvalidHeader"
|
|
const viewModel = initUnsubscribeHeaders(headers)
|
|
const unsubscribeActions = await viewModel.determineUnsubscribeOrder()
|
|
o(unsubscribeActions.length).equals(0)
|
|
verify(mailModel.unsubscribe(matchers.anything(), matchers.anything()), { times: 0 })
|
|
verify(commonSystemFacade.executePostRequest(matchers.anything(), matchers.anything()), { times: 0 })
|
|
})
|
|
o("determineUnsubscribeOrder with mailto", async function () {
|
|
const headers = ["List-Unsubscribe: \t<mailto:unsubscribe@newsletter.de>"]
|
|
const viewModel = initUnsubscribeHeaders(headers.join("\r\n"))
|
|
const unsubOrder = await viewModel.determineUnsubscribeOrder()
|
|
o(unsubOrder.length).equals(1)
|
|
o(unsubOrder[0].postUrl).equals("mailto:unsubscribe@newsletter.de")
|
|
o(unsubOrder[0].type).equals(UnsubscribeType.MAILTO_UNSUBSCRIBE)
|
|
})
|
|
o("determineUnsubscribeOrder with post", async function () {
|
|
const headers = ["List-Unsubscribe: \t<http://unsub.me?id=2134>", "List-Unsubscribe-Post: One-Click"]
|
|
const viewModel = initUnsubscribeHeaders(headers.join("\r\n"))
|
|
const unsubOrder = await viewModel.determineUnsubscribeOrder()
|
|
o(unsubOrder.length).equals(1)
|
|
o(unsubOrder[0].postUrl).equals("http://unsub.me?id=2134")
|
|
o(unsubOrder[0].type).equals(UnsubscribeType.HTTP_POST_UNSUBSCRIBE)
|
|
})
|
|
o("determineUnsubscribeOrder with get", async function () {
|
|
const headers = ["List-Unsubscribe: \t<http://unsub.me?id=2134>"]
|
|
const viewModel = initUnsubscribeHeaders(headers.join("\r\n"))
|
|
const unsubOrder = await viewModel.determineUnsubscribeOrder()
|
|
o(unsubOrder.length).equals(1)
|
|
o(unsubOrder[0].postUrl).equals("http://unsub.me?id=2134")
|
|
o(unsubOrder[0].type).equals(UnsubscribeType.HTTP_GET_UNSUBSCRIBE)
|
|
})
|
|
o("determineUnsubscribeOrder with get + mailto", async function () {
|
|
const headers = ["List-Unsubscribe: \t<http://unsub.me?id=2134>, <mailto:unsubscribe@newsletter.de>"]
|
|
const viewModel = initUnsubscribeHeaders(headers.join("\r\n"))
|
|
const unsubOrder = await viewModel.determineUnsubscribeOrder()
|
|
o(unsubOrder.length).equals(2)
|
|
o(unsubOrder[0].postUrl).equals("http://unsub.me?id=2134")
|
|
o(unsubOrder[0].type).equals(UnsubscribeType.HTTP_GET_UNSUBSCRIBE)
|
|
o(unsubOrder[1].postUrl).equals("mailto:unsubscribe@newsletter.de")
|
|
o(unsubOrder[1].type).equals(UnsubscribeType.MAILTO_UNSUBSCRIBE)
|
|
})
|
|
o("determineUnsubscribeOrder with post + mailto", async function () {
|
|
const headers = ["List-Unsubscribe: \t<http://unsub.me?id=2134>, <mailto:unsubscribe@newsletter.de>", "List-Unsubscribe-Post: One-Click"]
|
|
const viewModel = initUnsubscribeHeaders(headers.join("\r\n"))
|
|
const unsubOrder = await viewModel.determineUnsubscribeOrder()
|
|
o(unsubOrder.length).equals(2)
|
|
o(unsubOrder[0].postUrl).equals("http://unsub.me?id=2134")
|
|
o(unsubOrder[0].type).equals(UnsubscribeType.HTTP_POST_UNSUBSCRIBE)
|
|
o(unsubOrder[1].postUrl).equals("mailto:unsubscribe@newsletter.de")
|
|
o(unsubOrder[1].type).equals(UnsubscribeType.MAILTO_UNSUBSCRIBE)
|
|
})
|
|
o("determineUnsubscribeOrder with invalid prefixes are not parsed", async function () {
|
|
const headers = ["List-Unsubscribe: \t<invalid-http-postUrl>, <invalid-mailto-postUrl>", "List-Unsubscribe-Post: One-Click"]
|
|
const viewModel = initUnsubscribeHeaders(headers.join("\r\n"))
|
|
const unsubOrder = await viewModel.determineUnsubscribeOrder()
|
|
o(unsubOrder.length).equals(0)
|
|
})
|
|
})
|
|
})
|
|
|
|
o.spec("load mail details", function () {
|
|
o("load mail details successfully", async function () {
|
|
const viewModel = makeViewModelWithHeaders("")
|
|
when(mailFacade.loadMailDetailsBlob(mail)).thenResolve(mailDetails)
|
|
|
|
await viewModel.loadAll(Promise.resolve())
|
|
|
|
o(viewModel.isLoading()).deepEquals(false)
|
|
o(viewModel.getMailBody()).deepEquals("Hello World")
|
|
o(viewModel.didErrorsOccur()).deepEquals(false)
|
|
})
|
|
|
|
o("mail details NotFoundError", async function () {
|
|
const viewModel = makeViewModelWithHeaders("")
|
|
when(mailFacade.loadMailDetailsBlob(mail)).thenReject(new NotFoundError("mail details not found"))
|
|
|
|
await viewModel.loadAll(Promise.resolve())
|
|
|
|
o(viewModel.isLoading()).deepEquals(false)
|
|
o(viewModel.getMailBody()).deepEquals("")
|
|
o(viewModel.didErrorsOccur()).deepEquals(true)
|
|
})
|
|
|
|
o("changind sent mail from mail details draft to mail details blob", async function () {
|
|
const viewModel = makeViewModelWithHeaders("")
|
|
mail.mailDetailsDraft = ["draftListId", "draftId"]
|
|
|
|
const mailDetailsBlob = mail.mailDetails
|
|
mail.mailDetails = null
|
|
|
|
when(mailFacade.loadMailDetailsDraft(mail)).thenReject(new NotFoundError("mail details draft not found"))
|
|
await viewModel.loadAll(Promise.resolve())
|
|
o(viewModel.isLoading()).deepEquals(false)
|
|
o(viewModel.getMailBody()).deepEquals("")
|
|
o(viewModel.didErrorsOccur()).deepEquals(true)
|
|
|
|
mail.mailDetailsDraft = null
|
|
mail.mailDetails = mailDetailsBlob
|
|
await viewModel.loadAll(Promise.resolve())
|
|
|
|
o(viewModel.isLoading()).deepEquals(false)
|
|
o(viewModel.getMailBody()).deepEquals("Hello World")
|
|
o(viewModel.didErrorsOccur()).deepEquals(false)
|
|
})
|
|
})
|
|
})
|