tutanota/test/tests/mail/view/MailViewerViewModelTest.ts
abp 488c72c25f
rework mail list-unsubscribe flow
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>
2025-09-15 15:22:01 +02:00

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)
})
})
})