tutanota/test/tests/mail/SpamClassificationHandlerTest.ts

231 lines
9.4 KiB
TypeScript
Raw Normal View History

2025-10-14 15:53:36 +02:00
import o from "@tutao/otest"
import { matchers, object, verify, when } from "testdouble"
2025-10-14 16:35:33 +02:00
import {
Body,
BodyTypeRef,
2025-10-14 16:57:31 +02:00
ClientSpamClassifierResultTypeRef,
2025-10-14 16:35:33 +02:00
Mail,
MailDetails,
MailDetailsTypeRef,
MailFolder,
MailFolderTypeRef,
MailTypeRef,
} from "../../../src/common/api/entities/tutanota/TypeRefs"
2025-10-14 15:53:36 +02:00
import { SpamClassifier, SpamTrainMailDatum } from "../../../src/mail-app/workerUtils/spamClassification/SpamClassifier"
import { getMailBodyText } from "../../../src/common/api/common/CommonMailUtils"
2025-10-14 17:54:14 +02:00
import { MailSetKind } from "../../../src/common/api/common/TutanotaConstants"
2025-10-14 15:53:36 +02:00
import { ClientClassifierType } from "../../../src/common/api/common/ClientClassifierType"
2025-10-14 17:54:14 +02:00
import { assert, assertNotNull, defer, Nullable } from "@tutao/tutanota-utils"
2025-10-14 15:53:36 +02:00
import { MailFacade } from "../../../src/common/api/worker/facades/lazy/MailFacade"
import { createTestEntity } from "../TestUtils"
import { SpamClassificationHandler } from "../../../src/mail-app/mail/model/SpamClassificationHandler"
import { EntityClient } from "../../../src/common/api/common/EntityClient"
import { ClientModelInfo } from "../../../src/common/api/common/EntityFunctions"
2025-10-14 17:54:14 +02:00
import { BulkMailLoader } from "../../../src/mail-app/workerUtils/index/BulkMailLoader"
2025-10-14 15:53:36 +02:00
import { EntityRestInterface } from "../../../src/common/api/worker/rest/EntityRestClient"
import { FolderSystem } from "../../../src/common/api/common/mail/FolderSystem"
2025-10-14 16:35:33 +02:00
import { isSameId } from "../../../src/common/api/common/utils/EntityUtils"
import { WebsocketConnectivityModel } from "../../../src/common/misc/WebsocketConnectivityModel"
2025-10-14 15:53:36 +02:00
const { anything } = matchers
o.spec("SpamClassificationHandlerTest", function () {
let mailFacade = object<MailFacade>()
let body: Body
let mail: Mail
let spamClassifier: SpamClassifier
let spamHandler: SpamClassificationHandler
let restClient: EntityRestInterface
let bulkMailLoader: BulkMailLoader
const inboxRuleOutcome = defer<Nullable<MailFolder>>()
let folderSystem: FolderSystem
2025-10-14 16:35:33 +02:00
let mailDetails: MailDetails
let connectivityModel: WebsocketConnectivityModel
2025-10-14 15:53:36 +02:00
const inboxFolder = createTestEntity(MailFolderTypeRef, { _id: ["listId", "inbox"], folderType: MailSetKind.INBOX })
const trashFolder = createTestEntity(MailFolderTypeRef, { _id: ["listId", "trash"], folderType: MailSetKind.TRASH })
const spamFolder = createTestEntity(MailFolderTypeRef, { _id: ["listId", "spam"], folderType: MailSetKind.SPAM })
o.beforeEach(function () {
spamClassifier = object<SpamClassifier>()
restClient = object<EntityRestInterface>()
2025-10-14 16:35:33 +02:00
2025-10-14 15:53:36 +02:00
body = createTestEntity(BodyTypeRef, { text: "Body Text" })
2025-10-14 16:35:33 +02:00
mailDetails = createTestEntity(MailDetailsTypeRef, { _id: "mailDetail", body })
2025-10-14 15:53:36 +02:00
mail = createTestEntity(MailTypeRef, {
_id: ["listId", "elementId"],
sets: [spamFolder._id],
subject: "subject",
_ownerGroup: "owner",
2025-10-14 16:35:33 +02:00
mailDetails: ["detailsList", mailDetails._id],
2025-10-15 15:49:14 +02:00
unread: true,
2025-10-14 17:54:14 +02:00
isInboxRuleApplied: false,
clientSpamClassifierResult: null,
2025-10-14 15:53:36 +02:00
})
bulkMailLoader = object<BulkMailLoader>()
folderSystem = object<FolderSystem>()
connectivityModel = object<WebsocketConnectivityModel>()
when(connectivityModel.isLeader()).thenReturn(true)
2025-10-14 17:54:14 +02:00
when(mailFacade.simpleMoveMails(anything(), anything(), ClientClassifierType.CLIENT_CLASSIFICATION)).thenResolve([])
2025-10-14 15:53:36 +02:00
when(folderSystem.getSystemFolderByType(MailSetKind.SPAM)).thenReturn(spamFolder)
when(folderSystem.getSystemFolderByType(MailSetKind.INBOX)).thenReturn(inboxFolder)
when(folderSystem.getSystemFolderByType(MailSetKind.TRASH)).thenReturn(trashFolder)
2025-10-14 17:54:14 +02:00
when(folderSystem.getFolderByMail(anything())).thenDo((mail: Mail) => {
assert(mail.sets.length === 1, "Expected exactly one mail set")
const mailFolderId = assertNotNull(mail.sets[0])
if (isSameId(mailFolderId, trashFolder._id)) return trashFolder
else if (isSameId(mailFolderId, spamFolder._id)) return spamFolder
else if (isSameId(mailFolderId, inboxFolder._id)) return inboxFolder
else throw new Error("Unknown mail Folder")
})
when(
bulkMailLoader.loadMailDetails(
matchers.argThat((requestedMails: Array<Mail>) => {
assert(requestedMails.length === 1, "exactly one mail is requested at a time")
return isSameId(requestedMails[0]._id, mail._id)
}),
),
anything(),
2025-10-15 15:49:14 +02:00
).thenDo(async () => [{ mail, mailDetails }])
2025-10-14 15:53:36 +02:00
const entityClient = new EntityClient(restClient, ClientModelInfo.getNewInstanceForTestsOnly())
2025-10-16 16:19:37 +02:00
spamHandler = new SpamClassificationHandler(mailFacade, spamClassifier, entityClient, connectivityModel)
2025-10-14 15:53:36 +02:00
})
o("processSpam correctly verifies if email is stored in spam folder", async function () {
inboxRuleOutcome.resolve(null)
mail.sets = [spamFolder._id]
2025-10-14 17:54:14 +02:00
when(spamClassifier.predict(anything())).thenResolve(false)
2025-10-14 15:53:36 +02:00
const expectedTrainingData: SpamTrainMailDatum = {
mailId: mail._id,
subject: mail.subject,
body: getMailBodyText(body),
isSpam: false,
ownerGroup: "owner",
isSpamConfidence: 1,
2025-10-14 15:53:36 +02:00
}
2025-10-16 16:19:37 +02:00
const finalResult = await spamHandler.predictSpamForNewMail(mail, folderSystem)
2025-10-14 15:53:36 +02:00
verify(spamClassifier.storeSpamClassification(expectedTrainingData), { times: 1 })
verify(mailFacade.simpleMoveMails([mail._id], MailSetKind.INBOX, ClientClassifierType.CLIENT_CLASSIFICATION))
o(finalResult).deepEquals(inboxFolder)
})
2025-10-14 16:57:31 +02:00
o("does not classify mail if the mail has non null client classification result", async function () {
mail.sets = [inboxFolder._id]
mail.isInboxRuleApplied = false
mail.clientSpamClassifierResult = createTestEntity(ClientSpamClassifierResultTypeRef)
inboxRuleOutcome.resolve(null)
const expectedTrainingData: SpamTrainMailDatum = {
mailId: mail._id,
subject: mail.subject,
body: getMailBodyText(body),
isSpam: false,
ownerGroup: "owner",
isSpamConfidence: 0,
}
2025-10-16 16:19:37 +02:00
const finalResult = await spamHandler.predictSpamForNewMail(mail, folderSystem)
2025-10-14 16:57:31 +02:00
o(finalResult).equals(null)
2025-10-14 17:54:14 +02:00
verify(mailFacade.simpleMoveMails(anything(), anything(), anything()), { times: 0 })
2025-10-14 16:57:31 +02:00
verify(spamClassifier.predict(anything()), { times: 0 })
verify(spamClassifier.storeSpamClassification(expectedTrainingData), { times: 1 })
})
2025-10-15 16:30:18 +02:00
o("mail in spam folder is not classified but stored with confidence 0", async function () {
2025-10-14 15:53:36 +02:00
inboxRuleOutcome.resolve(null)
mail.sets = [trashFolder._id]
const expectedTrainingData: SpamTrainMailDatum = {
mailId: mail._id,
subject: mail.subject,
body: getMailBodyText(body),
isSpam: false,
ownerGroup: "owner",
isSpamConfidence: 1,
2025-10-14 15:53:36 +02:00
}
const finalResult = await spamHandler.predictSpamForNewMail(inboxRuleOutcome.promise, mail, folderSystem)
2025-10-15 16:30:18 +02:00
o(finalResult).deepEquals(null)
verify(mailFacade.simpleMoveMails(anything(), anything(), anything()), { times: 0 })
2025-10-14 15:53:36 +02:00
verify(spamClassifier.storeSpamClassification(expectedTrainingData), { times: 1 })
})
2025-10-15 16:30:18 +02:00
o("processSpam moves mail to inbox when detected as such and its not already in inbox", async function () {
2025-10-14 15:53:36 +02:00
inboxRuleOutcome.resolve(null)
2025-10-15 16:30:18 +02:00
when(spamClassifier.predict(anything())).thenResolve(false)
2025-10-14 15:53:36 +02:00
2025-10-15 16:30:18 +02:00
mail.sets = [spamFolder._id]
mail.unread = false
2025-10-14 15:53:36 +02:00
const expectedDatum: SpamTrainMailDatum = {
mailId: mail._id,
subject: mail.subject,
body: getMailBodyText(body),
2025-10-15 16:30:18 +02:00
isSpam: false,
2025-10-14 15:53:36 +02:00
isSpamConfidence: 1,
ownerGroup: "owner",
}
const finalResult = await spamHandler.predictSpamForNewMail(inboxRuleOutcome.promise, mail, folderSystem)
2025-10-15 16:30:18 +02:00
o(finalResult).deepEquals(inboxFolder)
2025-10-14 15:53:36 +02:00
verify(spamClassifier.storeSpamClassification(expectedDatum), { times: 1 })
2025-10-15 16:30:18 +02:00
verify(mailFacade.simpleMoveMails([["listId", "elementId"]], MailSetKind.INBOX, ClientClassifierType.CLIENT_CLASSIFICATION), { times: 1 })
2025-10-14 15:53:36 +02:00
})
2025-10-15 16:30:18 +02:00
o("processSpam moves mail to spam when detected as such and its not already in spam", async function () {
2025-10-14 15:53:36 +02:00
inboxRuleOutcome.resolve(null)
2025-10-15 16:30:18 +02:00
when(spamClassifier.predict(anything())).thenResolve(true)
2025-10-14 15:53:36 +02:00
mail.sets = [inboxFolder._id]
const expectedDatum: SpamTrainMailDatum = {
mailId: mail._id,
subject: mail.subject,
body: getMailBodyText(body),
2025-10-15 16:30:18 +02:00
isSpam: true,
isSpamConfidence: 1,
2025-10-14 15:53:36 +02:00
ownerGroup: "owner",
}
const finalResult = await spamHandler.predictSpamForNewMail(inboxRuleOutcome.promise, mail, folderSystem)
2025-10-15 16:30:18 +02:00
o(finalResult).deepEquals(spamFolder)
2025-10-14 15:53:36 +02:00
verify(spamClassifier.storeSpamClassification(expectedDatum), { times: 1 })
2025-10-14 17:54:14 +02:00
verify(mailFacade.simpleMoveMails([["listId", "elementId"]], MailSetKind.SPAM, ClientClassifierType.CLIENT_CLASSIFICATION), { times: 1 })
2025-10-14 15:53:36 +02:00
})
2025-10-14 16:35:33 +02:00
o("does nothing if mail has not been read and not moved or had label applied", async function () {
2025-10-14 15:53:36 +02:00
mail.unread = true
2025-10-17 10:02:26 +02:00
await spamHandler.updateSpamClassificationData(mail)
2025-10-15 15:54:49 +02:00
verify(spamClassifier.updateSpamClassification(matchers.anything(), matchers.anything(), matchers.anything()), { times: 0 })
2025-10-14 15:53:36 +02:00
})
2025-10-15 15:49:14 +02:00
o("update spam classification data on every mail update", async function () {
2025-10-15 15:54:49 +02:00
when(spamClassifier.getSpamClassification(anything())).thenResolve({ isSpam: false, isSpamConfidence: 0 })
2025-10-14 15:53:36 +02:00
mail.sets = [spamFolder._id]
2025-10-17 10:02:26 +02:00
await spamHandler.updateSpamClassificationData(mail)
verify(spamClassifier.updateSpamClassification(["listId", "elementId"], true, 1), { times: 1 })
2025-10-14 15:53:36 +02:00
})
o("does update spam classification data if mail was not previously included", async function () {
mail.sets = [inboxFolder._id]
2025-10-15 15:54:49 +02:00
when(spamClassifier.getSpamClassification(mail._id)).thenResolve(null)
2025-10-14 15:53:36 +02:00
const spamTrainMailDatum: SpamTrainMailDatum = {
mailId: mail._id,
subject: mail.subject,
body: getMailBodyText(body),
isSpam: false,
isSpamConfidence: 1,
2025-10-14 15:53:36 +02:00
ownerGroup: "owner",
}
2025-10-17 10:02:26 +02:00
await spamHandler.updateSpamClassificationData(mail)
2025-10-14 16:35:33 +02:00
verify(spamClassifier.storeSpamClassification(spamTrainMailDatum), { times: 1 })
2025-10-14 15:53:36 +02:00
})
})