mirror of
https://github.com/tutao/tutanota.git
synced 2025-12-08 06:09:50 +00:00
Instead of applying inbox rules based on the unread mail state in the inbox folder, we introduce the new ProcessingState enum on the mail type. If a mail has been processed by the leader client, which is checking for matching inbox rules, the ProcessingState is updated. If there is a matching rule the flag is updated through the MoveMailService, if there is no matching rule, the flag is updated using the ClientClassifierResultService. Both requests are throttled / debounced. After processing inbox rules, spam prediction is conducted for mails that have not yet been moved by an inbox rule. The ProcessingState for not matching ham mails is also updated using the ClientClassifierResultService. This new inbox rule handing solves the following two problems: - when clicking on a notification it could still happen, that sometimes the inbox rules where not applied - when the inbox folder had a lot of unread mails, the loading time did massively increase, since inbox rules were re-applied on every load Co-authored-by: amm <amm@tutao.de> Co-authored-by: Nick <nif@tutao.de> Co-authored-by: das <das@tutao.de> Co-authored-by: abp <abp@tutao.de> Co-authored-by: jhm <17314077+jomapp@users.noreply.github.com> Co-authored-by: map <mpfau@users.noreply.github.com> Co-authored-by: Kinan <104761667+kibibytium@users.noreply.github.com>
257 lines
9.6 KiB
TypeScript
257 lines
9.6 KiB
TypeScript
import { createMoveMailData, InboxRule, Mail, MailFolder, MoveMailData } from "../../../common/api/entities/tutanota/TypeRefs.js"
|
|
import { InboxRuleType, MailSetKind, MAX_NBR_OF_MAILS_SYNC_OPERATION, ProcessingState } from "../../../common/api/common/TutanotaConstants"
|
|
import { isDomainName, isRegularExpression } from "../../../common/misc/FormatValidator"
|
|
import { assertNotNull, asyncFind, debounce, ofClass, promiseMap, splitInChunks, throttleStart } from "@tutao/tutanota-utils"
|
|
import { lang } from "../../../common/misc/LanguageViewModel"
|
|
import type { MailboxDetail } from "../../../common/mailFunctionality/MailboxModel.js"
|
|
import { LockedError, PreconditionFailedError } from "../../../common/api/common/error/RestError"
|
|
import type { SelectorItemList } from "../../../common/gui/base/DropDownSelector.js"
|
|
import { elementIdPart, isSameId } from "../../../common/api/common/utils/EntityUtils"
|
|
import { assertMainOrNode, isWebClient } from "../../../common/api/common/Env"
|
|
import { MailFacade } from "../../../common/api/worker/facades/lazy/MailFacade.js"
|
|
import { LoginController } from "../../../common/api/main/LoginController.js"
|
|
import { getMailHeaders } from "./MailUtils.js"
|
|
import { MailModel } from "./MailModel"
|
|
import { ClientClassifierType } from "../../../common/api/common/ClientClassifierType"
|
|
|
|
assertMainOrNode()
|
|
|
|
const moveMailDataPerFolder: MoveMailData[] = []
|
|
let noRuleMatchMailIds: IdTuple[] = []
|
|
|
|
const THROTTLE_MOVE_MAIL_SERVICE_REQUESTS_MS = 200
|
|
const DEBOUNCE_CLIENT_CLASSIFIER_RESULT_SERVICE_REQUESTS_MS = 1000
|
|
|
|
async function sendMoveMailRequest(mailFacade: MailFacade): Promise<void> {
|
|
if (moveMailDataPerFolder.length) {
|
|
const moveToTargetFolder = assertNotNull(moveMailDataPerFolder.shift())
|
|
const mailChunks = splitInChunks(MAX_NBR_OF_MAILS_SYNC_OPERATION, moveToTargetFolder.mails)
|
|
await promiseMap(mailChunks, (mailChunk) => {
|
|
moveToTargetFolder.mails = mailChunk
|
|
return mailFacade.moveMails(mailChunk, moveToTargetFolder.targetFolder, null, ClientClassifierType.CUSTOMER_INBOX_RULES)
|
|
})
|
|
.catch(
|
|
ofClass(LockedError, (e) => {
|
|
//LockedError should no longer be thrown!?!
|
|
console.log("moving mail failed", e, moveToTargetFolder)
|
|
}),
|
|
)
|
|
.catch(
|
|
ofClass(PreconditionFailedError, (e) => {
|
|
// move mail operation may have been locked by other process
|
|
console.log("moving mail failed", e, moveToTargetFolder)
|
|
}),
|
|
)
|
|
.finally(() => {
|
|
return processMatchingRules(mailFacade)
|
|
})
|
|
}
|
|
}
|
|
|
|
const processMatchingRules = throttleStart(THROTTLE_MOVE_MAIL_SERVICE_REQUESTS_MS, async (mailFacade: MailFacade) => {
|
|
// Each target folder requires one request,
|
|
// We debounce the requests to a rate of THROTTLE_MOVE_MAIL_SERVICE_REQUESTS_MS
|
|
return sendMoveMailRequest(mailFacade)
|
|
})
|
|
|
|
const processNotMatchingRules = debounce(DEBOUNCE_CLIENT_CLASSIFIER_RESULT_SERVICE_REQUESTS_MS, async (mailFacade: MailFacade) => {
|
|
// Each update to ClientClassifierResultService (for mails that did not move) requires one request
|
|
// We debounce the requests to a rate of DEBOUNCE_CLIENT_CLASSIFIER_RESULT_SERVICE_REQUESTS_MS
|
|
if (noRuleMatchMailIds.length) {
|
|
const mailIds = noRuleMatchMailIds
|
|
noRuleMatchMailIds = []
|
|
return mailFacade.updateMailPredictionState(mailIds, ProcessingState.INBOX_RULE_PROCESSED_AND_SPAM_PREDICTION_PENDING)
|
|
}
|
|
})
|
|
|
|
export function getInboxRuleTypeNameMapping(): SelectorItemList<string> {
|
|
return [
|
|
{
|
|
value: InboxRuleType.FROM_EQUALS,
|
|
name: lang.get("inboxRuleSenderEquals_action"),
|
|
},
|
|
{
|
|
value: InboxRuleType.RECIPIENT_TO_EQUALS,
|
|
name: lang.get("inboxRuleToRecipientEquals_action"),
|
|
},
|
|
{
|
|
value: InboxRuleType.RECIPIENT_CC_EQUALS,
|
|
name: lang.get("inboxRuleCCRecipientEquals_action"),
|
|
},
|
|
{
|
|
value: InboxRuleType.RECIPIENT_BCC_EQUALS,
|
|
name: lang.get("inboxRuleBCCRecipientEquals_action"),
|
|
},
|
|
{
|
|
value: InboxRuleType.SUBJECT_CONTAINS,
|
|
name: lang.get("inboxRuleSubjectContains_action"),
|
|
},
|
|
{
|
|
value: InboxRuleType.MAIL_HEADER_CONTAINS,
|
|
name: lang.get("inboxRuleMailHeaderContains_action"),
|
|
},
|
|
]
|
|
}
|
|
|
|
export function getInboxRuleTypeName(type: string): string {
|
|
let typeNameMapping = getInboxRuleTypeNameMapping().find((t) => t.value === type)
|
|
return typeNameMapping != null ? typeNameMapping.name : ""
|
|
}
|
|
|
|
export class InboxRuleHandler {
|
|
constructor(
|
|
private readonly mailFacade: MailFacade,
|
|
private readonly logins: LoginController,
|
|
private readonly mailModel: MailModel,
|
|
) {}
|
|
|
|
/**
|
|
* Checks the mail for an existing inbox rule and moves the mail to the target folder of the rule.
|
|
* @returns true if a rule matches otherwise false
|
|
*/
|
|
async findAndApplyMatchingRule(mailboxDetail: MailboxDetail, mail: Readonly<Mail>, applyRulesOnServer: boolean): Promise<MailFolder | null> {
|
|
const shouldApply = mail.processingState === ProcessingState.INBOX_RULE_NOT_PROCESSED
|
|
|
|
if (
|
|
mail._errors ||
|
|
!shouldApply ||
|
|
!(await isInboxFolder(this.mailModel, mailboxDetail, mail)) ||
|
|
!this.logins.getUserController().isPaidAccount() ||
|
|
mailboxDetail.mailbox.folders == null
|
|
) {
|
|
return null
|
|
}
|
|
|
|
const inboxRule = await _findMatchingRule(this.mailFacade, mail, this.logins.getUserController().props.inboxRules)
|
|
if (inboxRule) {
|
|
const folders = await this.mailModel.getMailboxFoldersForId(mailboxDetail.mailbox.folders._id)
|
|
const targetFolder = folders.getFolderById(elementIdPart(inboxRule.targetFolder))
|
|
|
|
if (targetFolder && targetFolder.folderType !== MailSetKind.INBOX) {
|
|
if (applyRulesOnServer) {
|
|
let moveMailData = moveMailDataPerFolder.find((folderMoveMailData) => isSameId(folderMoveMailData.targetFolder, inboxRule.targetFolder))
|
|
|
|
if (moveMailData) {
|
|
moveMailData.mails.push(mail._id)
|
|
} else {
|
|
moveMailData = createMoveMailData({
|
|
targetFolder: inboxRule.targetFolder,
|
|
mails: [mail._id],
|
|
excludeMailSet: null,
|
|
moveReason: ClientClassifierType.CUSTOMER_INBOX_RULES,
|
|
})
|
|
moveMailDataPerFolder.push(moveMailData)
|
|
}
|
|
}
|
|
|
|
processMatchingRules(this.mailFacade)
|
|
|
|
return targetFolder
|
|
} else {
|
|
return null
|
|
}
|
|
} else {
|
|
// if we are not on the webapp this is handled in SpamClassificationHandler
|
|
if (isWebClient()) {
|
|
noRuleMatchMailIds.push(mail._id)
|
|
processNotMatchingRules(this.mailFacade)
|
|
}
|
|
|
|
return null
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Finds the first matching inbox rule for the mail and returns it.
|
|
* export only for testing
|
|
*/
|
|
export async function _findMatchingRule(mailFacade: MailFacade, mail: Mail, rules: InboxRule[]): Promise<InboxRule | null> {
|
|
return asyncFind(rules, (rule) => checkInboxRule(mailFacade, mail, rule)).then((v) => v ?? null)
|
|
}
|
|
|
|
async function checkInboxRule(mailFacade: MailFacade, mail: Mail, inboxRule: InboxRule): Promise<boolean> {
|
|
const ruleType = inboxRule.type
|
|
try {
|
|
if (ruleType === InboxRuleType.FROM_EQUALS) {
|
|
let mailAddresses = [mail.sender.address]
|
|
|
|
if (mail.differentEnvelopeSender) {
|
|
mailAddresses.push(mail.differentEnvelopeSender)
|
|
}
|
|
|
|
return _checkEmailAddresses(mailAddresses, inboxRule)
|
|
} else if (ruleType === InboxRuleType.RECIPIENT_TO_EQUALS) {
|
|
const toRecipients = (await mailFacade.loadMailDetailsBlob(mail)).recipients.toRecipients
|
|
return _checkEmailAddresses(
|
|
toRecipients.map((m) => m.address),
|
|
inboxRule,
|
|
)
|
|
} else if (ruleType === InboxRuleType.RECIPIENT_CC_EQUALS) {
|
|
const ccRecipients = (await mailFacade.loadMailDetailsBlob(mail)).recipients.ccRecipients
|
|
return _checkEmailAddresses(
|
|
ccRecipients.map((m) => m.address),
|
|
inboxRule,
|
|
)
|
|
} else if (ruleType === InboxRuleType.RECIPIENT_BCC_EQUALS) {
|
|
const bccRecipients = (await mailFacade.loadMailDetailsBlob(mail)).recipients.bccRecipients
|
|
return _checkEmailAddresses(
|
|
bccRecipients.map((m) => m.address),
|
|
inboxRule,
|
|
)
|
|
} else if (ruleType === InboxRuleType.SUBJECT_CONTAINS) {
|
|
return _checkContainsRule(mail.subject, inboxRule)
|
|
} else if (ruleType === InboxRuleType.MAIL_HEADER_CONTAINS) {
|
|
const details = await mailFacade.loadMailDetailsBlob(mail)
|
|
if (details.headers != null) {
|
|
return _checkContainsRule(getMailHeaders(details.headers), inboxRule)
|
|
} else {
|
|
return false
|
|
}
|
|
} else {
|
|
console.warn("Unknown rule type: ", inboxRule.type)
|
|
return false
|
|
}
|
|
} catch (e) {
|
|
console.error("Error processing inbox rule:", e.message)
|
|
return false
|
|
}
|
|
}
|
|
|
|
function _checkContainsRule(value: string, inboxRule: InboxRule): boolean {
|
|
return (isRegularExpression(inboxRule.value) && _matchesRegularExpression(value, inboxRule)) || value.includes(inboxRule.value)
|
|
}
|
|
|
|
/** export for test. */
|
|
export function _matchesRegularExpression(value: string, inboxRule: InboxRule): boolean {
|
|
if (isRegularExpression(inboxRule.value)) {
|
|
let flags = inboxRule.value.replace(/.*\/([gimsuy]*)$/, "$1")
|
|
let pattern = inboxRule.value.replace(new RegExp("^/(.*?)/" + flags + "$"), "$1")
|
|
let regExp = new RegExp(pattern, flags)
|
|
return regExp.test(value)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
function _checkEmailAddresses(mailAddresses: string[], inboxRule: InboxRule): boolean {
|
|
const mailAddress = mailAddresses.find((mailAddress) => {
|
|
let cleanMailAddress = mailAddress.toLowerCase().trim()
|
|
|
|
if (isRegularExpression(inboxRule.value)) {
|
|
return _matchesRegularExpression(cleanMailAddress, inboxRule)
|
|
} else if (isDomainName(inboxRule.value)) {
|
|
let domain = cleanMailAddress.split("@")[1]
|
|
return domain === inboxRule.value
|
|
} else {
|
|
return cleanMailAddress === inboxRule.value
|
|
}
|
|
})
|
|
return mailAddress != null
|
|
}
|
|
|
|
async function isInboxFolder(mailModel: MailModel, mailboxDetail: MailboxDetail, mail: Mail): Promise<boolean> {
|
|
const folders = await mailModel.getMailboxFoldersForId(assertNotNull(mailboxDetail.mailbox.folders)._id)
|
|
const mailFolder = folders.getFolderByMail(mail)
|
|
return mailFolder?.folderType === MailSetKind.INBOX
|
|
}
|