MailSet support (static mail listIds)

In order to allow importing of mails we replace legacy MailFolders
(non-static mail listIds) with new MailSets (static mail listIds).
From now on, mails have static mail listIds and static mail elementIds.
To move mails between new MailSets we introduce MailSetEntries
("entries" property on a MailSet), which are index entries sorted by
the received date of the referenced mails (customId). This commit adds
support for new MailSets, while still supporting legacy MailFolders
(mail lists) to support migrating gradually.

* TutanotaModelV74 adds:
  * MailSet support
  * and defaultAlarmList on GroupSettings

* SystemModelV107 adds model changes for counter (unread mails) updates

* Adapt mail list to show MailSet and legacy mails
  The list model is now largely unaware about listIds since it can
  display mails from multiple MailBags. MailBags are static mailLists
  from which a mail is only removed from when the mail is permanently
  deleted.

* Adapt offline storage for mail sets
  Offline storage gained the ability to provide cached entities
  from a list of ids.
This commit is contained in:
map 2024-08-07 08:38:58 +02:00 committed by Johannes Münichsdorfer
parent b803773b4d
commit 2d24bab6f9
97 changed files with 33829 additions and 1275 deletions

View file

@ -560,3 +560,26 @@ export function zeroOut(...arrays: (Uint8Array | Int8Array)[]) {
a.fill(0)
}
}
/**
* @return 1 if first is bigger than second, -1 if second is bigger than first and 0 otherwise
*/
export function compare(first: Uint8Array, second: Uint8Array): number {
if (first.length > second.length) {
return 1
} else if (first.length < second.length) {
return -1
}
for (let i = 0; i < first.length; i++) {
const a = first[i]
const b = second[i]
if (a > b) {
return 1
} else if (a < b) {
return -1
}
}
return 0
}

View file

@ -42,6 +42,7 @@ export {
arrayOf,
count,
zeroOut,
compare,
} from "./ArrayUtils.js"
export { AsyncResult } from "./AsyncResult.js"
export { intersection, trisectingDiff, setAddAll, max, maxBy, findBy, min, minBy, mapWith, mapWithout, setEquals, setMap } from "./CollectionUtils.js"

View file

@ -18,6 +18,7 @@ import {
splitInChunks,
symmetricDifference,
} from "../lib/index.js"
import { compare } from "../lib/ArrayUtils.js"
type ObjectWithId = {
v: number
@ -875,4 +876,18 @@ o.spec("array utils", function () {
o(arrayOf(2, (idx) => idx + 1 + " one thousand")).deepEquals(["1 one thousand", "2 one thousand"])
})
o("customId comparision", function () {
o(compare(new Uint8Array([]), new Uint8Array([]))).equals(0)
o(compare(new Uint8Array([1]), new Uint8Array([]))).equals(1)
o(compare(new Uint8Array([]), new Uint8Array([1]))).equals(-1)
o(compare(new Uint8Array([1, 1]), new Uint8Array([1, 1]))).equals(0)
o(compare(new Uint8Array([1, 1, 3]), new Uint8Array([1, 1, 2]))).equals(1)
o(compare(new Uint8Array([1, 1, 2]), new Uint8Array([1, 1, 3]))).equals(-1)
})
})

View file

@ -380,6 +380,16 @@
"info": "AddAssociation GroupKeyRotationData/groupMembershipUpdateData/AGGREGATION/2432."
}
]
},
{
"version": 107,
"changes": [
{
"name": "RenameAttribute",
"sourceType": "WebsocketCounterValue",
"info": "RenameAttribute WebsocketCounterValue: mailListId -> counterId."
}
]
}
]
}

View file

@ -450,6 +450,46 @@
"info": "RemoveValue Contact/autoTransmitPassword/78."
}
]
},
{
"version": 74,
"changes": [
{
"name": "AddAssociation",
"sourceType": "GroupSettings",
"info": "AddAssociation GroupSettings/defaultAlarmsList/AGGREGATION/1449."
},
{
"name": "AddValue",
"sourceType": "MailFolder",
"info": "AddValue MailFolder/isLabel/1457."
},
{
"name": "AddValue",
"sourceType": "MailFolder",
"info": "AddValue MailFolder/isMailSet/1458."
},
{
"name": "AddAssociation",
"sourceType": "MailFolder",
"info": "AddAssociation MailFolder/entries/LIST_ASSOCIATION/1459."
},
{
"name": "AddAssociation",
"sourceType": "MailBox",
"info": "AddAssociation MailBox/archivedMailBags/AGGREGATION/1463."
},
{
"name": "AddAssociation",
"sourceType": "MailBox",
"info": "AddAssociation MailBox/currentMailBag/AGGREGATION/1464."
},
{
"name": "AddAssociation",
"sourceType": "Mail",
"info": "AddAssociation Mail/sets/LIST_ELEMENT_ASSOCIATION/1465."
}
]
}
]
}

View file

@ -220,6 +220,7 @@ export class CalendarModel {
group: group._id,
color: color,
name: null,
defaultAlarmsList: [],
})
userSettingsGroupRoot.groupSettings.push(newGroupSettings)
await this.entityClient.update(userSettingsGroupRoot)

View file

@ -133,7 +133,7 @@ export class CalendarSearchModel {
continue
}
if (restriction.listIds.length > 0 && !restriction.listIds.includes(listIdPart(event._id))) {
if (restriction.folderIds.length > 0 && !restriction.folderIds.includes(listIdPart(event._id))) {
// check that the event is in the searched calendar.
continue
}
@ -221,7 +221,7 @@ export function isSameSearchRestriction(a: SearchRestriction, b: SearchRestricti
a.end === b.end &&
isSameAttributeIds &&
(a.eventSeries === b.eventSeries || (a.eventSeries === null && b.eventSeries === true) || (a.eventSeries === true && b.eventSeries === null)) &&
arrayEquals(a.listIds, b.listIds)
arrayEquals(a.folderIds, b.folderIds)
)
}

View file

@ -50,8 +50,8 @@ export function getSearchUrl(
if (restriction.end) {
params.end = restriction.end
}
if (restriction.listIds.length > 0) {
params.list = restriction.listIds
if (restriction.folderIds.length > 0) {
params.folder = restriction.folderIds
}
if (restriction.eventSeries != null) {
@ -67,14 +67,14 @@ export function getSearchUrl(
/**
* Adjusts the restriction according to the account type if necessary
*/
export function createRestriction(start: number | null, end: number | null, listIds: Array<string>, eventSeries: boolean): SearchRestriction {
export function createRestriction(start: number | null, end: number | null, folderIds: Array<string>, eventSeries: boolean): SearchRestriction {
return {
type: CalendarEventTypeRef,
start: start,
end: end,
field: null,
attributeIds: null,
listIds,
folderIds,
eventSeries,
}
}
@ -85,7 +85,7 @@ export function createRestriction(start: number | null, end: number | null, list
export function getRestriction(route: string): SearchRestriction {
let start: number | null = null
let end: number | null = null
let listIds: Array<string> = []
let folderIds: Array<string> = []
let eventSeries: boolean = true
if (route.startsWith("/calendar") || route.startsWith("/search/calendar")) {
@ -104,9 +104,9 @@ export function getRestriction(route: string): SearchRestriction {
end = filterInt(params["end"])
}
const list = params["list"]
if (Array.isArray(list)) {
listIds = list
const folder = params["folder"]
if (Array.isArray(folder)) {
folderIds = folder
}
} catch (e) {
console.log("invalid query: " + route, e)
@ -127,7 +127,7 @@ export function getRestriction(route: string): SearchRestriction {
throw new Error("invalid type " + route)
}
return createRestriction(start, end, listIds, eventSeries)
return createRestriction(start, end, folderIds, eventSeries)
}
export function decodeCalendarSearchKey(searchKey: string): { id: Id; start: number } {

View file

@ -21,7 +21,7 @@ import {
ofClass,
TypeRef,
} from "@tutao/tutanota-utils"
import { areResultsForTheSameQuery, hasMoreResults, isSameSearchRestriction, CalendarSearchModel } from "../model/CalendarSearchModel.js"
import { areResultsForTheSameQuery, CalendarSearchModel, hasMoreResults, isSameSearchRestriction } from "../model/CalendarSearchModel.js"
import { NotFoundError } from "../../../../common/api/common/error/RestError.js"
import { createRestriction, decodeCalendarSearchKey, encodeCalendarSearchKey, getRestriction } from "../model/SearchUtils.js"
import Stream from "mithril/stream"
@ -159,7 +159,7 @@ export class CalendarSearchViewModel {
}
private listIdMatchesRestriction(listId: string, restriction: SearchRestriction): boolean {
return restriction.listIds.length === 0 || restriction.listIds.includes(listId)
return restriction.folderIds.length === 0 || restriction.folderIds.includes(listId)
}
onNewUrl(args: Record<string, any>, requestedPath: string) {
@ -217,7 +217,7 @@ export class CalendarSearchViewModel {
this.startDate = restriction.start ? new Date(restriction.start) : null
this.endDate = restriction.end ? new Date(restriction.end) : null
this.selectedCalendar = this.extractCalendarListIds(restriction.listIds)
this.selectedCalendar = this.extractCalendarListIds(restriction.folderIds)
this.includeRepeatingEvents = restriction.eventSeries ?? true
this.lazyCalendarInfos.load()
this.latestCalendarRestriction = restriction
@ -415,12 +415,12 @@ export class CalendarSearchViewModel {
return { items: entries, complete }
},
loadSingle: async (elementId: Id) => {
loadSingle: async (_listId: Id, elementId: Id) => {
const lastResult = this._searchResult
if (!lastResult) {
return null
}
const id = lastResult.results.find((r) => r[1] === elementId)
const id = lastResult.results.find((resultId) => elementIdPart(resultId) === elementId)
if (id) {
return this.entityClient
.load(lastResult.restriction.type, id)
@ -446,7 +446,7 @@ export class CalendarSearchViewModel {
if (result && isSameTypeRef(typeRef, result.restriction.type)) {
// The list id must be null/empty, otherwise the user is filtering by list, and it shouldn't be ignored
const ignoreList = isSameTypeRef(typeRef, MailTypeRef) && result.restriction.listIds.length === 0
const ignoreList = isSameTypeRef(typeRef, MailTypeRef) && result.restriction.folderIds.length === 0
return result.results.some((r) => this.compareItemId(r, id, ignoreList))
}

View file

@ -802,6 +802,7 @@ export class CalendarView extends BaseTopLevelView implements TopLevelView<Calen
group: groupInfo.group,
color: properties.color,
name: shared && properties.name !== groupInfo.name ? properties.name : null,
defaultAlarmsList: [],
})
userSettingsGroupRoot.groupSettings.push(newGroupSettings)
}

View file

@ -1,22 +1,22 @@
import type { FolderSystem } from "./mail/FolderSystem.js"
import { Body, Mail, MailFolder } from "../entities/tutanota/TypeRefs.js"
import { MailFolderType } from "./TutanotaConstants.js"
import { MailSetKind } from "./TutanotaConstants.js"
export function isSubfolderOfType(system: FolderSystem, folder: MailFolder, type: MailFolderType): boolean {
export function isSubfolderOfType(system: FolderSystem, folder: MailFolder, type: MailSetKind): boolean {
const systemFolder = system.getSystemFolderByType(type)
return systemFolder != null && system.checkFolderForAncestor(folder, systemFolder._id)
}
/**
* Returns true if given folder is the {@link MailFolderType.SPAM} or {@link MailFolderType.TRASH} folder, or a descendant of those folders.
* Returns true if given folder is the {@link MailSetKind.SPAM} or {@link MailSetKind.TRASH} folder, or a descendant of those folders.
*/
export function isSpamOrTrashFolder(system: FolderSystem, folder: MailFolder): boolean {
// not using isOfTypeOrSubfolderOf because checking the type first is cheaper
return (
folder.folderType === MailFolderType.TRASH ||
folder.folderType === MailFolderType.SPAM ||
isSubfolderOfType(system, folder, MailFolderType.TRASH) ||
isSubfolderOfType(system, folder, MailFolderType.SPAM)
folder.folderType === MailSetKind.TRASH ||
folder.folderType === MailSetKind.SPAM ||
isSubfolderOfType(system, folder, MailSetKind.TRASH) ||
isSubfolderOfType(system, folder, MailSetKind.SPAM)
)
}

View file

@ -23,7 +23,7 @@ export const REQUEST_SIZE_LIMIT_MAP: Map<string, number> = new Map([
export const SYSTEM_GROUP_MAIL_ADDRESS = "system@tutanota.de"
export const getMailFolderType = (folder: MailFolder): MailFolderType => downcast(folder.folderType)
export const getMailFolderType = (folder: MailFolder): MailSetKind => downcast(folder.folderType)
type ObjectPropertyKey = string | number | symbol
export const reverse = <K extends ObjectPropertyKey, V extends ObjectPropertyKey>(objectMap: Record<K, V>): Record<V, K> =>
@ -84,7 +84,7 @@ export const enum BucketPermissionType {
External = "3",
}
export enum MailFolderType {
export enum MailSetKind {
CUSTOM = "0",
INBOX = "1",
SENT = "2",
@ -92,6 +92,7 @@ export enum MailFolderType {
ARCHIVE = "4",
SPAM = "5",
DRAFT = "6",
ALL = "7",
}
export const enum ReplyType {

View file

@ -1,7 +1,7 @@
import { groupBy, partition } from "@tutao/tutanota-utils"
import { MailFolder } from "../../entities/tutanota/TypeRefs.js"
import { MailFolderType } from "../TutanotaConstants.js"
import { elementIdPart, getElementId, isSameId } from "../utils/EntityUtils.js"
import { groupBy, isNotEmpty, partition } from "@tutao/tutanota-utils"
import { Mail, MailFolder } from "../../entities/tutanota/TypeRefs.js"
import { MailSetKind } from "../TutanotaConstants.js"
import { elementIdPart, getElementId, getListId, isSameId } from "../utils/EntityUtils.js"
export interface IndentedFolder {
level: number
@ -17,7 +17,7 @@ export class FolderSystem {
const folderByParent = groupBy(folders, (folder) => (folder.parentFolder ? elementIdPart(folder.parentFolder) : null))
const topLevelFolders = folders.filter((f) => f.parentFolder == null)
const [systemFolders, customFolders] = partition(topLevelFolders, (f) => f.folderType !== MailFolderType.CUSTOM)
const [systemFolders, customFolders] = partition(topLevelFolders, (f) => f.folderType !== MailSetKind.CUSTOM)
this.systemSubtrees = systemFolders.sort(compareSystem).map((f) => this.makeSubtree(folderByParent, f, compareCustom))
this.customSubtrees = customFolders.sort(compareCustom).map((f) => this.makeSubtree(folderByParent, f, compareCustom))
@ -28,16 +28,25 @@ export class FolderSystem {
}
/** Search for a specific folder type. Some mailboxes might not have some system folders! */
getSystemFolderByType(type: Omit<MailFolderType, MailFolderType.CUSTOM>): MailFolder | null {
getSystemFolderByType(type: Omit<MailSetKind, MailSetKind.CUSTOM>): MailFolder | null {
return this.systemSubtrees.find((f) => f.folder.folderType === type)?.folder ?? null
}
getFolderById(folderId: IdTuple): MailFolder | null {
getFolderById(folderId: Id): MailFolder | null {
const subtree = this.getFolderByIdInSubtrees(this.systemSubtrees, folderId) ?? this.getFolderByIdInSubtrees(this.customSubtrees, folderId)
return subtree?.folder ?? null
}
getFolderByMailListId(mailListId: Id): MailFolder | null {
getFolderByMail(mail: Mail): MailFolder | null {
const sets = mail.sets
if (isNotEmpty(sets)) {
return this.getFolderById(elementIdPart(sets[0]))
} else {
return this.getFolderByMailListIdLegacy(getListId(mail))
}
}
private getFolderByMailListIdLegacy(mailListId: Id): MailFolder | null {
const subtree =
this.getFolderByMailListIdInSubtrees(this.systemSubtrees, mailListId) ?? this.getFolderByMailListIdInSubtrees(this.customSubtrees, mailListId)
return subtree?.folder ?? null
@ -49,7 +58,7 @@ export class FolderSystem {
*/
getCustomFoldersOfParent(parent: IdTuple | null): MailFolder[] {
if (parent) {
const parentFolder = this.getFolderByIdInSubtrees([...this.customSubtrees, ...this.systemSubtrees], parent)
const parentFolder = this.getFolderByIdInSubtrees([...this.customSubtrees, ...this.systemSubtrees], elementIdPart(parent))
return parentFolder ? parentFolder.children.map((child) => child.folder) : []
} else {
return this.customSubtrees.map((subtree) => subtree.folder)
@ -57,7 +66,7 @@ export class FolderSystem {
}
getDescendantFoldersOfParent(parent: IdTuple): IndentedFolder[] {
const parentFolder = this.getFolderByIdInSubtrees([...this.customSubtrees, ...this.systemSubtrees], parent)
const parentFolder = this.getFolderByIdInSubtrees([...this.customSubtrees, ...this.systemSubtrees], elementIdPart(parent))
if (parentFolder) {
return this.getIndentedFolderList([parentFolder]).slice(1)
} else {
@ -78,7 +87,7 @@ export class FolderSystem {
} else if (isSameId(currentFolderPointer.parentFolder, potentialAncestorId)) {
return true
}
currentFolderPointer = this.getFolderById(currentFolderPointer.parentFolder)
currentFolderPointer = this.getFolderById(elementIdPart(currentFolderPointer.parentFolder))
}
}
@ -99,8 +108,8 @@ export class FolderSystem {
})
}
private getFolderByIdInSubtrees(systems: ReadonlyArray<FolderSubtree>, folderId: IdTuple): FolderSubtree | null {
return this.getFolderBy(systems, (system) => isSameId(system.folder._id, folderId))
private getFolderByIdInSubtrees(systems: ReadonlyArray<FolderSubtree>, folderId: Id): FolderSubtree | null {
return this.getFolderBy(systems, (system) => isSameId(getElementId(system.folder), folderId))
}
private getFolderByMailListIdInSubtrees(systems: ReadonlyArray<FolderSubtree>, mailListId: Id): FolderSubtree | null {
@ -155,15 +164,16 @@ function compareCustom(folder1: MailFolder, folder2: MailFolder): number {
return folder1.name.localeCompare(folder2.name)
}
type SystemMailFolderTypes = Exclude<MailFolderType, MailFolderType.CUSTOM>
type SystemMailFolderTypes = Exclude<MailSetKind, MailSetKind.CUSTOM>
const folderTypeToOrder: Record<SystemMailFolderTypes, number> = {
[MailFolderType.INBOX]: 0,
[MailFolderType.DRAFT]: 1,
[MailFolderType.SENT]: 2,
[MailFolderType.TRASH]: 4,
[MailFolderType.ARCHIVE]: 5,
[MailFolderType.SPAM]: 6,
[MailSetKind.INBOX]: 0,
[MailSetKind.DRAFT]: 1,
[MailSetKind.SENT]: 2,
[MailSetKind.TRASH]: 4,
[MailSetKind.ARCHIVE]: 5,
[MailSetKind.SPAM]: 6,
[MailSetKind.ALL]: 7,
}
function compareSystem(folder1: MailFolder, folder2: MailFolder): number {

View file

@ -6,6 +6,7 @@ import {
base64ToUint8Array,
base64UrlToBase64,
clone,
compare,
hexToBase64,
isSameTypeRef,
pad,
@ -108,7 +109,7 @@ export type StrippedEntity<T extends Entity> =
*/
export function firstBiggerThanSecond(firstId: Id, secondId: Id, typeModel?: TypeModel): boolean {
if (typeModel?.values._id.type === ValueType.CustomId) {
return firstBiggerThanSecond(customIdToString(firstId), customIdToString(secondId))
return firstBiggerThanSecondCustomId(firstId, secondId)
} else {
// if the number of digits is bigger, then the id is bigger, otherwise we can use the lexicographical comparison
if (firstId.length > secondId.length) {
@ -121,6 +122,17 @@ export function firstBiggerThanSecond(firstId: Id, secondId: Id, typeModel?: Typ
}
}
export function firstBiggerThanSecondCustomId(firstId: Id, secondId: Id): boolean {
return compare(customIdToUint8array(firstId), customIdToUint8array(secondId)) === 1
}
function customIdToUint8array(id: Id): Uint8Array {
if (id === "") {
return new Uint8Array()
}
return base64ToUint8Array(base64UrlToBase64(id))
}
export function compareNewestFirst(id1: Id | IdTuple, id2: Id | IdTuple): number {
let firstId = id1 instanceof Array ? id1[1] : id1
let secondId = id2 instanceof Array ? id2[1] : id2

View file

@ -1,6 +1,6 @@
const modelInfo = {
version: 106,
compatibleSince: 106,
version: 107,
compatibleSince: 107,
}
export default modelInfo

File diff suppressed because it is too large Load diff

View file

@ -3455,7 +3455,7 @@ export type WebsocketCounterValue = {
_id: Id;
count: NumberString;
mailListId: Id;
counterId: Id;
}
export const WebsocketEntityDataTypeRef: TypeRef<WebsocketEntityData> = new TypeRef("sys", "WebsocketEntityData")

View file

@ -1,6 +1,6 @@
const modelInfo = {
version: 73,
compatibleSince: 73,
version: 74,
compatibleSince: 74,
}
export default modelInfo

File diff suppressed because it is too large Load diff

View file

@ -560,6 +560,18 @@ export type CustomerAccountCreateData = {
userData: UserAccountUserData;
userGroupData: InternalGroupData;
}
export const DefaultAlarmInfoTypeRef: TypeRef<DefaultAlarmInfo> = new TypeRef("tutanota", "DefaultAlarmInfo")
export function createDefaultAlarmInfo(values: StrippedEntity<DefaultAlarmInfo>): DefaultAlarmInfo {
return Object.assign(create(typeModels.DefaultAlarmInfo, DefaultAlarmInfoTypeRef), values)
}
export type DefaultAlarmInfo = {
_type: TypeRef<DefaultAlarmInfo>;
_id: Id;
trigger: string;
}
export const DeleteGroupDataTypeRef: TypeRef<DeleteGroupData> = new TypeRef("tutanota", "DeleteGroupData")
export function createDeleteGroupData(values: StrippedEntity<DeleteGroupData>): DeleteGroupData {
@ -931,6 +943,7 @@ export type GroupSettings = {
color: string;
name: null | string;
defaultAlarmsList: DefaultAlarmInfo[];
group: Id;
}
export const HeaderTypeRef: TypeRef<Header> = new TypeRef("tutanota", "Header")
@ -1135,6 +1148,7 @@ export type Mail = {
mailDetails: null | IdTuple;
mailDetailsDraft: null | IdTuple;
sender: MailAddress;
sets: IdTuple[];
}
export const MailAddressTypeRef: TypeRef<MailAddress> = new TypeRef("tutanota", "MailAddress")
@ -1164,6 +1178,19 @@ export type MailAddressProperties = {
mailAddress: string;
senderName: string;
}
export const MailBagTypeRef: TypeRef<MailBag> = new TypeRef("tutanota", "MailBag")
export function createMailBag(values: StrippedEntity<MailBag>): MailBag {
return Object.assign(create(typeModels.MailBag, MailBagTypeRef), values)
}
export type MailBag = {
_type: TypeRef<MailBag>;
_id: Id;
mails: Id;
}
export const MailBoxTypeRef: TypeRef<MailBox> = new TypeRef("tutanota", "MailBox")
export function createMailBox(values: StrippedEntity<MailBox>): MailBox {
@ -1182,6 +1209,8 @@ export type MailBox = {
_permissions: Id;
lastInfoDate: Date;
archivedMailBags: MailBag[];
currentMailBag: null | MailBag;
folders: null | MailFolderRef;
mailDetailsDrafts: null | MailDetailsDraftsRef;
receivedAttachments: Id;
@ -1274,8 +1303,11 @@ export type MailFolder = {
_ownerKeyVersion: null | NumberString;
_permissions: Id;
folderType: NumberString;
isLabel: boolean;
isMailSet: boolean;
name: string;
entries: Id;
mails: Id;
parentFolder: null | IdTuple;
}
@ -1292,6 +1324,22 @@ export type MailFolderRef = {
folders: Id;
}
export const MailSetEntryTypeRef: TypeRef<MailSetEntry> = new TypeRef("tutanota", "MailSetEntry")
export function createMailSetEntry(values: StrippedEntity<MailSetEntry>): MailSetEntry {
return Object.assign(create(typeModels.MailSetEntry, MailSetEntryTypeRef), values)
}
export type MailSetEntry = {
_type: TypeRef<MailSetEntry>;
_format: NumberString;
_id: IdTuple;
_ownerGroup: null | Id;
_permissions: Id;
mail: IdTuple;
}
export const MailboxGroupRootTypeRef: TypeRef<MailboxGroupRoot> = new TypeRef("tutanota", "MailboxGroupRoot")
export function createMailboxGroupRoot(values: StrippedEntity<MailboxGroupRoot>): MailboxGroupRoot {
@ -1361,6 +1409,7 @@ export type MoveMailData = {
_format: NumberString;
mails: IdTuple[];
sourceFolder: null | IdTuple;
targetFolder: IdTuple;
}
export const NewDraftAttachmentTypeRef: TypeRef<NewDraftAttachment> = new TypeRef("tutanota", "NewDraftAttachment")

View file

@ -101,7 +101,7 @@ import { BlobFacade } from "./BlobFacade.js"
import { assertWorkerOrNode, isApp, isDesktop } from "../../../common/Env.js"
import { EntityClient } from "../../../common/EntityClient.js"
import { getEnabledMailAddressesForGroupInfo, getUserGroupMemberships } from "../../../common/utils/GroupUtils.js"
import { containsId, elementIdPart, getLetId, isSameId, listIdPart, stringToCustomId } from "../../../common/utils/EntityUtils.js"
import { containsId, elementIdPart, getElementId, getLetId, isSameId, listIdPart, stringToCustomId } from "../../../common/utils/EntityUtils.js"
import { htmlToText } from "../../search/IndexUtils.js"
import { MailBodyTooLargeError } from "../../../common/error/MailBodyTooLargeError.js"
import { UNCOMPRESSED_MAX_SIZE } from "../../Compression.js"
@ -349,8 +349,8 @@ export class MailFacade {
return deferredUpdatePromiseWrapper.promise
}
async moveMails(mails: IdTuple[], targetFolder: IdTuple): Promise<void> {
await this.serviceExecutor.post(MoveMailService, createMoveMailData({ mails, targetFolder }))
async moveMails(mails: IdTuple[], sourceFolder: IdTuple, targetFolder: IdTuple): Promise<void> {
await this.serviceExecutor.post(MoveMailService, createMoveMailData({ mails, sourceFolder, targetFolder }))
}
async reportMail(mail: Mail, reportType: MailReportType): Promise<void> {
@ -644,11 +644,12 @@ export class MailFacade {
await this.serviceExecutor.delete(MailFolderService, deleteMailFolderData, { sessionKey: "dummy" as any })
}
async fixupCounterForMailList(groupId: Id, listId: Id, unreadMails: number): Promise<void> {
async fixupCounterForFolder(groupId: Id, folder: MailFolder, unreadMails: number): Promise<void> {
const counterId = folder.isMailSet ? getElementId(folder) : folder.mails
const data = createWriteCounterData({
counterType: CounterType.UnreadMails,
row: groupId,
column: listId,
column: counterId,
value: String(unreadMails),
})
await this.serviceExecutor.post(CounterService, data)

View file

@ -30,6 +30,7 @@ import { TokenOrNestedTokens } from "cborg/interface"
import {
CalendarEventTypeRef,
FileTypeRef,
MailBoxTypeRef,
MailDetailsBlobTypeRef,
MailDetailsDraftTypeRef,
MailFolderTypeRef,
@ -248,6 +249,20 @@ export class OfflineStorage implements CacheStorage, ExposedCacheStorage {
return result?.entity ? this.deserialize(typeRef, result.entity.value as Uint8Array) : null
}
async provideMultiple<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, elementIds: Id[]): Promise<Array<T>> {
if (elementIds.length === 0) return []
const type = getTypeId(typeRef)
const serializedList: ReadonlyArray<Record<string, TaggedSqlValue>> = await this.allChunked(
MAX_SAFE_SQL_VARS - 2,
elementIds,
(c) => sql`SELECT entity FROM list_entities WHERE type = ${type} AND listId = ${listId} AND elementId IN ${paramList(c)}`,
)
return this.deserializeList(
typeRef,
serializedList.map((r) => r.entity.value as Uint8Array),
)
}
async getIdsInRange<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id): Promise<Array<Id>> {
const type = getTypeId(typeRef)
const range = await this.getRange(type, listId)
@ -487,14 +502,25 @@ AND NOT(${firstIdBigger("elementId", upper)})`
// lead to an overflow in our 42 bit timestamp in the id.
const cutoffTimestamp = now - timeRangeMillisSafe
const cutoffId = timestampToGeneratedId(cutoffTimestamp)
const folders = await this.getListElementsOfType(MailFolderTypeRef)
const folderSystem = new FolderSystem(folders)
for (const folder of folders) {
if (isSpamOrTrashFolder(folderSystem, folder)) {
await this.deleteMailList(folder.mails, GENERATED_MAX_ID)
const mailBoxes = await this.getElementsOfType(MailBoxTypeRef)
for (const mailBox of mailBoxes) {
const isMailsetMigrated = mailBox.currentMailBag != null
if (isMailsetMigrated) {
var mailListIds = [mailBox.currentMailBag!, ...mailBox.archivedMailBags].map((mailbag) => mailbag.mails)
for (const mailListId of mailListIds) {
await this.deleteMailList(mailListId, cutoffId)
}
} else {
await this.deleteMailList(folder.mails, cutoffId)
const folders = await this.getWholeList(MailFolderTypeRef, mailBox.folders!.folders)
const folderSystem = new FolderSystem(folders)
for (const folder of folders) {
if (isSpamOrTrashFolder(folderSystem, folder)) {
await this.deleteMailList(folder.mails, GENERATED_MAX_ID)
} else {
await this.deleteMailList(folder.mails, cutoffId)
}
}
}
}
}
@ -684,6 +710,23 @@ AND NOT(${firstIdBigger("elementId", upper)})`
await this.sqlCipherFacade.run(formattedQuery.query, formattedQuery.params)
}
}
/**
* convenience method to execute a potentially too large query over several chunks.
* chunkSize must be chosen such that the total number of SQL variables in the final query does not exceed MAX_SAFE_SQL_VARS
* */
private async allChunked(
chunkSize: number,
originalList: SqlValue[],
formatter: (chunk: SqlValue[]) => FormattedQuery,
): Promise<Array<Record<string, TaggedSqlValue>>> {
const result: Array<Record<string, TaggedSqlValue>> = []
for (const chunk of splitInChunks(chunkSize, originalList)) {
const formattedQuery = formatter(chunk)
result.push(...(await this.sqlCipherFacade.all(formattedQuery.query, formattedQuery.params)))
}
return result
}
}
/*

View file

@ -25,6 +25,8 @@ import { tutanota73 } from "./migrations/tutanota-v73.js"
import { sys104 } from "./migrations/sys-v104.js"
import { sys105 } from "./migrations/sys-v105.js"
import { sys106 } from "./migrations/sys-v106.js"
import { tutanota74 } from "./migrations/tutanota-v74.js"
import { sys107 } from "./migrations/sys-v107.js"
export interface OfflineMigration {
readonly app: VersionMetadataBaseKey
@ -61,6 +63,8 @@ export const OFFLINE_STORAGE_MIGRATIONS: ReadonlyArray<OfflineMigration> = [
sys104,
sys105,
sys106,
tutanota74,
sys107,
]
const CURRENT_OFFLINE_VERSION = 1

View file

@ -0,0 +1,10 @@
import { OfflineMigration } from "../OfflineStorageMigrator.js"
import { OfflineStorage } from "../OfflineStorage.js"
export const sys107: OfflineMigration = {
app: "sys",
version: 107,
async migrate(storage: OfflineStorage) {
// only changes data transfer type
},
}

View file

@ -0,0 +1,16 @@
import { OfflineMigration } from "../OfflineStorageMigrator.js"
import { OfflineStorage } from "../OfflineStorage.js"
import { addValue, migrateAllElements, migrateAllListElements } from "../StandardMigrations.js"
import { createMail, createMailBox, MailBoxTypeRef, MailFolderTypeRef, MailTypeRef } from "../../../entities/tutanota/TypeRefs.js"
import { GENERATED_MIN_ID } from "../../../common/utils/EntityUtils.js"
export const tutanota74: OfflineMigration = {
app: "tutanota",
version: 74,
async migrate(storage: OfflineStorage) {
// the TutanotaModelV75 introduces MailSets to support import and labels
await migrateAllListElements(MailFolderTypeRef, storage, [addValue("isLabel", "0"), addValue("isMailSet", "0"), addValue("entries", GENERATED_MIN_ID)])
await migrateAllElements(MailBoxTypeRef, storage, [createMailBox]) // initialize mailbags
await migrateAllListElements(MailTypeRef, storage, [createMail]) // initialize sets
},
}

View file

@ -133,6 +133,10 @@ export class LateInitializedCacheStorageImpl implements CacheStorageLateInitiali
return this.inner.provideFromRange(typeRef, listId, start, count, reverse)
}
provideMultiple<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: string, elementIds: string[]): Promise<T[]> {
return this.inner.provideMultiple(typeRef, listId, elementIds)
}
getWholeList<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id): Promise<Array<T>> {
return this.inner.getWholeList(typeRef, listId)
}

View file

@ -115,6 +115,11 @@ export interface ExposedCacheStorage {
*/
provideFromRange<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, start: Id, count: number, reverse: boolean): Promise<T[]>
/**
* Load a set of list element entities by id. Missing elements are not returned, no error is thrown.
*/
provideMultiple<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, elementIds: Id[]): Promise<Array<T>>
/**
* retrieve all list elements that are in the cache
* @param typeRef

View file

@ -222,6 +222,19 @@ export class EphemeralCacheStorage implements CacheStorage {
return result
}
async provideMultiple<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, elementIds: Id[]): Promise<Array<T>> {
const listCache = this.lists.get(typeRefToPath(typeRef))?.get(listId)
if (listCache == null) {
return []
}
let result: T[] = []
for (let a = 0; a < elementIds.length; a++) {
result.push(clone(listCache.elements.get(elementIds[a]) as T))
}
return result
}
async getRangeForList<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id): Promise<{ lower: Id; upper: Id } | null> {
const listCache = this.lists.get(typeRefToPath(typeRef))?.get(listId)

View file

@ -323,7 +323,6 @@ export class Indexer {
const transaction = await this.db.dbFacade.createTransaction(false, [MetaDataOS, GroupDataOS])
await transaction.put(MetaDataOS, Metadata.userEncDbKey, userEncDbKey.key)
await transaction.put(MetaDataOS, Metadata.mailIndexingEnabled, this._mail.mailIndexingEnabled)
await transaction.put(MetaDataOS, Metadata.excludedListIds, this._mail._excludedListIds)
await transaction.put(MetaDataOS, Metadata.encDbIv, aes256EncryptSearchIndexEntry(this.db.key, this.db.iv))
await transaction.put(MetaDataOS, Metadata.userGroupKeyVersion, userEncDbKey.encryptingKeyVersion)
await transaction.put(MetaDataOS, Metadata.lastEventIndexTimeMs, this._entityRestClient.getRestClient().getServerTimestampMs())
@ -336,7 +335,6 @@ export class Indexer {
this.db.key = decryptKey(userGroupKey, metaData.userEncDbKey)
this.db.iv = unauthenticatedAesDecrypt(this.db.key, neverNull(metaData.encDbIv), true)
this._mail.mailIndexingEnabled = metaData.mailIndexingEnabled
this._mail._excludedListIds = metaData.excludedListIds
const groupDiff = await this._loadGroupDiff(user)
await this._updateGroups(user, groupDiff)
await this._mail.updateCurrentIndexTimestamp(user)

View file

@ -1,4 +1,4 @@
import { FULL_INDEXED_TIMESTAMP, MailFolderType, MailState, NOTHING_INDEXED_TIMESTAMP, OperationType } from "../../common/TutanotaConstants"
import { FULL_INDEXED_TIMESTAMP, MailSetKind, MailState, NOTHING_INDEXED_TIMESTAMP, OperationType } from "../../common/TutanotaConstants"
import type { File as TutanotaFile, Mail, MailBox, MailDetails, MailFolder } from "../../entities/tutanota/TypeRefs.js"
import {
FileTypeRef,
@ -55,7 +55,6 @@ export class MailIndexer {
mailboxIndexingPromise: Promise<void>
isIndexing: boolean = false
_indexingCancelled: boolean
_excludedListIds: Id[]
_core: IndexerCore
_db: Db
_entityRestClient: EntityRestClient
@ -82,7 +81,6 @@ export class MailIndexer {
this.mailIndexingEnabled = false
this.mailboxIndexingPromise = Promise.resolve()
this._indexingCancelled = false
this._excludedListIds = []
this._entityRestClient = entityRestClient
this._dateProvider = dateProvider
}
@ -146,10 +144,6 @@ export class MailIndexer {
mail: Mail
keyToIndexEntries: Map<string, SearchIndexEntry[]>
} | null> {
if (this._isExcluded(event)) {
return Promise.resolve(null)
}
return this._defaultCachingEntity
.load(MailTypeRef, [event.instanceListId, event.instanceId])
.then(async (mail) => {
@ -213,14 +207,10 @@ export class MailIndexer {
return this._db.dbFacade.createTransaction(true, [ElementDataOS]).then((transaction) => {
return transaction.get(ElementDataOS, encInstanceId).then((elementData) => {
if (elementData) {
if (this._isExcluded(event)) {
return this._core._processDeleted(event, indexUpdate) // move to spam folder
} else {
indexUpdate.move.push({
encInstanceId,
newListId: event.instanceListId,
})
}
indexUpdate.move.push({
encInstanceId,
newListId: event.instanceListId,
})
} else {
// instance is moved but not yet indexed: handle as new for example moving a mail from non indexed folder like spam to indexed folder
return this.processNewMail(event).then((result) => {
@ -233,42 +223,34 @@ export class MailIndexer {
})
}
enableMailIndexing(user: User): Promise<void> {
return this._db.dbFacade.createTransaction(true, [MetaDataOS]).then((t) => {
return t.get(MetaDataOS, Metadata.mailIndexingEnabled).then((enabled) => {
if (!enabled) {
return promiseMap(filterMailMemberships(user), (mailGroupMembership) => this._getSpamFolder(mailGroupMembership)).then((spamFolders) => {
this._excludedListIds = spamFolders.map((folder) => folder.mails)
this.mailIndexingEnabled = true
return this._db.dbFacade.createTransaction(false, [MetaDataOS]).then((t2) => {
t2.put(MetaDataOS, Metadata.mailIndexingEnabled, true)
t2.put(MetaDataOS, Metadata.excludedListIds, this._excludedListIds)
async enableMailIndexing(user: User): Promise<void> {
const t = await this._db.dbFacade.createTransaction(true, [MetaDataOS])
const enabled = await t.get(MetaDataOS, Metadata.mailIndexingEnabled)
if (!enabled) {
this.mailIndexingEnabled = true
const t2 = await this._db.dbFacade.createTransaction(false, [MetaDataOS])
t2.put(MetaDataOS, Metadata.mailIndexingEnabled, true)
t2.put(MetaDataOS, Metadata.excludedListIds, [])
// create index in background, termination is handled in Indexer.enableMailIndexing
const oldestTimestamp = this._dateProvider.getStartOfDayShiftedBy(-INITIAL_MAIL_INDEX_INTERVAL_DAYS).getTime()
// create index in background, termination is handled in Indexer.enableMailIndexing
const oldestTimestamp = this._dateProvider.getStartOfDayShiftedBy(-INITIAL_MAIL_INDEX_INTERVAL_DAYS).getTime()
this.indexMailboxes(user, oldestTimestamp).catch(
ofClass(CancelledError, (e) => {
console.log("cancelled initial indexing", e)
}),
)
return t2.wait()
})
})
} else {
return t.get(MetaDataOS, Metadata.excludedListIds).then((excludedListIds) => {
this.mailIndexingEnabled = true
this._excludedListIds = excludedListIds || []
})
}
this.indexMailboxes(user, oldestTimestamp).catch(
ofClass(CancelledError, (e) => {
console.log("cancelled initial indexing", e)
}),
)
return t2.wait()
} else {
return t.get(MetaDataOS, Metadata.excludedListIds).then((excludedListIds) => {
this.mailIndexingEnabled = true
})
})
}
}
disableMailIndexing(userId: Id): Promise<void> {
this.mailIndexingEnabled = false
this._indexingCancelled = true
this._excludedListIds = []
return this._db.dbFacade.deleteDatabase(b64UserIdHash(userId))
}
@ -580,24 +562,17 @@ export class MailIndexer {
})
}
_isExcluded(event: EntityUpdate): boolean {
return this._excludedListIds.indexOf(event.instanceListId) !== -1
}
/**
* Provides all non-excluded mail list ids of the given mailbox
* Provides all mail list ids of the given mailbox
*/
async _loadMailListIds(mailbox: MailBox): Promise<Id[]> {
const folders = await this._defaultCachingEntity.loadAll(MailFolderTypeRef, neverNull(mailbox.folders).folders)
const mailListIds: Id[] = []
for (const folder of folders) {
if (!this._excludedListIds.includes(folder.mails)) {
mailListIds.push(folder.mails)
}
const isMailsetMigrated = mailbox.currentMailBag != null
if (isMailsetMigrated) {
return [mailbox.currentMailBag!, ...mailbox.archivedMailBags].map((mailbag) => mailbag.mails)
} else {
const folders = await this._defaultCachingEntity.loadAll(MailFolderTypeRef, neverNull(mailbox.folders).folders)
return folders.map((f) => f.mails)
}
return mailListIds
}
_getSpamFolder(mailGroup: GroupMembership): Promise<MailFolder> {
@ -607,7 +582,7 @@ export class MailIndexer {
.then((mbox) => {
return this._defaultCachingEntity
.loadAll(MailFolderTypeRef, neverNull(mbox.folders).folders)
.then((folders) => neverNull(folders.find((folder) => folder.folderType === MailFolderType.SPAM)))
.then((folders) => neverNull(folders.find((folder) => folder.folderType === MailSetKind.SPAM)))
})
}

View file

@ -1,7 +1,6 @@
import { MailTypeRef } from "../../entities/tutanota/TypeRefs.js"
import { DbTransaction } from "./DbFacade"
import { resolveTypeReference } from "../../common/EntityFunctions"
import type { PromiseMapFn } from "@tutao/tutanota-utils"
import {
arrayHash,
asyncFind,
@ -9,11 +8,13 @@ import {
downcast,
getDayShifted,
getStartOfDay,
isNotEmpty,
isNotNull,
isSameTypeRef,
neverNull,
promiseMap,
promiseMapCompat,
PromiseMapFn,
tokenize,
TypeRef,
uint8ArrayToBase64,
@ -47,7 +48,7 @@ import {
typeRefToTypeInfo,
} from "./IndexUtils"
import { FULL_INDEXED_TIMESTAMP, NOTHING_INDEXED_TIMESTAMP } from "../../common/TutanotaConstants"
import { compareNewestFirst, firstBiggerThanSecond, timestampToGeneratedId } from "../../common/utils/EntityUtils"
import { compareNewestFirst, elementIdPart, firstBiggerThanSecond, getListId, timestampToGeneratedId } from "../../common/utils/EntityUtils"
import { INITIAL_MAIL_INDEX_INTERVAL_DAYS, MailIndexer } from "./MailIndexer"
import { SuggestionFacade } from "./SuggestionFacade"
import { AssociationType, Cardinality, ValueType } from "../../common/EntityConstants.js"
@ -490,7 +491,6 @@ export class SearchFacade {
* Reduces the search result by filtering out all mailIds that don't match all search tokens
*/
_filterByEncryptedId(results: KeyToEncryptedIndexEntries[]): KeyToEncryptedIndexEntries[] {
// let matchingEncIds = null
let matchingEncIds: Set<number> | null = null
for (const keyToEncryptedIndexEntry of results) {
if (matchingEncIds == null) {
@ -626,37 +626,62 @@ export class SearchFacade {
// Use separate array to only sort new results and not all of them.
return this._db.dbFacade
.createTransaction(true, [ElementDataOS])
.then(
(
transaction, // As an attempt to optimize search we look for items in parallel. Promise.map iterates in arbitrary order!
) =>
// BUT! we have to look at all of them! Otherwise we may return them in the wrong order. We cannot return elements 10, 15, 20 if we didn't
// return element 5 first, no one will ask for it later.
// The best thing performance-wise would be to split into chunks of certain length and process them in parallel and stop after certain chunk.
promiseMap(
indexEntries.slice(0, maxResults || indexEntries.length + 1),
(entry, index) => {
return transaction.get(ElementDataOS, uint8ArrayToBase64(entry.encId)).then((elementData: ElementDataDbRow | null) => {
// mark result index id as processed to not query result in next load more operation
entriesCopy[index] = null
if (
elementData &&
(!(searchResult.restriction.listIds.length > 0) || searchResult.restriction.listIds.includes(elementData[0]))
) {
return [elementData[0], entry.id] as IdTuple
}
.then((transaction) =>
// As an attempt to optimize search we look for items in parallel. Promise.map iterates in arbitrary order!
// BUT! we have to look at all of them! Otherwise, we may return them in the wrong order.
// We cannot return elements 10, 15, 20 if we didn't return element 5 first, no one will ask for it later.
// The best thing performance-wise would be to split into chunks of certain length and process them in parallel and stop after certain chunk.
promiseMap(
indexEntries.slice(0, maxResults || indexEntries.length + 1),
async (entry, index) => {
return transaction.get(ElementDataOS, uint8ArrayToBase64(entry.encId)).then((elementData: ElementDataDbRow | null) => {
// mark result index id as processed to not query result in next load more operation
entriesCopy[index] = null
if (elementData) {
return [elementData[0], entry.id] as IdTuple
} else {
return null
})
},
{
concurrency: 5,
},
),
}
})
},
{
concurrency: 5,
},
),
)
.then((intermediateResults) => intermediateResults.filter(isNotNull))
.then(async (intermediateResults) => {
// apply folder restrictions to intermediateResults
if (!(searchResult.restriction.folderIds.length > 0)) {
// no folder restrictions (ALL)
return intermediateResults
} else {
// some folder restrictions (e.g. INBOX)
// With the new mailSet architecture (static mail lists) we need to load every mail
// in order to check in which mailSet (folder) a mail is included in.
const mails = await Promise.all(
intermediateResults.map((intermediateResultId) => this._entityClient.load(MailTypeRef, intermediateResultId)),
)
return mails
.filter((mail) => {
let folderIds: Array<Id>
if (isNotEmpty(mail.sets)) {
// new mailSet folders
folderIds = mail.sets.map((setId) => elementIdPart(setId))
} else {
// legacy mail folder (mail list)
folderIds = [getListId(mail)]
}
return folderIds.some((folderId) => searchResult.restriction.folderIds.includes(folderId))
})
.map((mail) => mail._id)
}
})
.then((newResults) => {
searchResult.results.push(...(newResults.filter(isNotNull) as IdTuple[]))
searchResult.results.push(...(newResults as IdTuple[]))
searchResult.moreResults = entriesCopy.filter(isNotNull)
})
}

View file

@ -129,8 +129,8 @@ export type SearchRestriction = {
field: string | null
// must be kept in sync with attributeIds
attributeIds: number[] | null
// if empty, match anything. otherwise it's an OR-match.
listIds: Array<Id>
// list of locations (calendars, folders, labels to search). if empty, match anything. otherwise it's an OR-match.
folderIds: Array<Id>
// if true, include repeating events in the search
eventSeries: boolean | null
}

View file

@ -10,6 +10,7 @@ import {
MailBoxTypeRef,
MailFolder,
MailFolderTypeRef,
MailSetEntryTypeRef,
MailTypeRef,
} from "../api/entities/tutanota/TypeRefs.js"
import { Group, GroupInfo, GroupInfoTypeRef, GroupMembership, GroupTypeRef, WebsocketCounterData } from "../api/entities/sys/TypeRefs.js"
@ -23,18 +24,18 @@ import { EntityClient } from "../api/common/EntityClient.js"
import { LoginController } from "../api/main/LoginController.js"
import { WebsocketConnectivityModel } from "../misc/WebsocketConnectivityModel.js"
import { InboxRuleHandler } from "../../mail-app/mail/model/InboxRuleHandler.js"
import { assertNotNull, groupBy, lazyMemoized, neverNull, noOp, ofClass, promiseMap, splitInChunks } from "@tutao/tutanota-utils"
import { assertNotNull, first, groupBy, isNotEmpty, lazyMemoized, neverNull, noOp, ofClass, promiseMap, splitInChunks } from "@tutao/tutanota-utils"
import {
FeatureType,
MailFolderType,
MailReportType,
MailSetKind,
MAX_NBR_MOVE_DELETE_MAIL_SERVICE,
OperationType,
ReportMovedMailsType,
} from "../api/common/TutanotaConstants.js"
import { assertSystemFolderOfType, getEnabledMailAddressesWithUser } from "./SharedMailUtils.js"
import { LockedError, NotFoundError, PreconditionFailedError } from "../api/common/error/RestError.js"
import { elementIdPart, GENERATED_MAX_ID, getElementId, getListId, isSameId, listIdPart } from "../api/common/utils/EntityUtils.js"
import { CUSTOM_MIN_ID, elementIdPart, GENERATED_MAX_ID, getElementId, getListId, isSameId } from "../api/common/utils/EntityUtils.js"
import { containsEventOfType, EntityUpdateData, isUpdateForTypeRef } from "../api/common/utils/EntityUpdateUtils.js"
import m from "mithril"
import { lang } from "../misc/LanguageViewModel.js"
@ -130,12 +131,10 @@ export class MailModel {
return this.entityClient.loadAll(MailFolderTypeRef, folderListId).then((folders) => {
return folders.filter((f) => {
// We do not show spam or archive for external users
if (!this.logins.isInternalUserLoggedIn() && (f.folderType === MailFolderType.SPAM || f.folderType === MailFolderType.ARCHIVE)) {
return false
} else if (this.logins.isEnabled(FeatureType.InternalCommunication) && f.folderType === MailFolderType.SPAM) {
if (!this.logins.isInternalUserLoggedIn() && (f.folderType === MailSetKind.SPAM || f.folderType === MailSetKind.ARCHIVE)) {
return false
} else {
return true
return !(this.logins.isEnabled(FeatureType.InternalCommunication) && f.folderType === MailSetKind.SPAM)
}
})
})
@ -163,15 +162,20 @@ export class MailModel {
}
}
getMailboxDetailsForMail(mail: Mail): Promise<MailboxDetail | null> {
return this.getMailboxDetailsForMailListId(mail._id[0])
async getMailboxDetailsForMail(mail: Mail): Promise<MailboxDetail | null> {
const mailboxDetails = await this.getMailboxDetails()
const detail = mailboxDetails.find((md) => md.folders.getFolderByMail(mail)) ?? null
if (detail == null) {
console.warn("Mailbox detail for mail does not exist", mail)
}
return detail
}
async getMailboxDetailsForMailListId(mailListId: Id): Promise<MailboxDetail | null> {
async getMailboxDetailsForMailFolder(mailFolder: MailFolder): Promise<MailboxDetail | null> {
const mailboxDetails = await this.getMailboxDetails()
const detail = mailboxDetails.find((md) => md.folders.getFolderByMailListId(mailListId)) ?? null
const detail = mailboxDetails.find((md) => md.folders.getFolderById(getElementId(mailFolder))) ?? null
if (detail == null) {
console.warn("Mailbox detail for mail list does not exist", mailListId)
console.warn("Mailbox detail for mail folder does not exist", mailFolder)
}
return detail
}
@ -180,30 +184,36 @@ export class MailModel {
const mailboxDetails = await this.getMailboxDetails()
return assertNotNull(
mailboxDetails.find((md) => mailGroupId === md.mailGroup._id),
"No mailbox details for mail group",
"Mailbox detail for mail group does not exist",
)
}
async getUserMailboxDetails(): Promise<MailboxDetail> {
const userMailGroupMembership = this.logins.getUserController().getUserMailGroupMembership()
const mailboxDetails = await this.getMailboxDetails()
return assertNotNull(mailboxDetails.find((md) => md.mailGroup._id === userMailGroupMembership.group))
return assertNotNull(
mailboxDetails.find((md) => md.mailGroup._id === userMailGroupMembership.group),
"Mailbox detail for user does not exist",
)
}
getMailboxFolders(mail: Mail): Promise<FolderSystem | null> {
async getMailboxFolders(mail: Mail): Promise<FolderSystem | null> {
return this.getMailboxDetailsForMail(mail).then((md) => md && md.folders)
}
getMailFolder(mailListId: Id): MailFolder | null {
getMailFolderForMail(mail: Mail): MailFolder | null {
const mailboxDetails = this.mailboxDetails() || []
let foundFolder: MailFolder | null = null
for (let detail of mailboxDetails) {
const f = detail.folders.getFolderByMailListId(mailListId)
if (f) {
return f
if (isNotEmpty(mail.sets)) {
foundFolder = detail.folders.getFolderById(elementIdPart(mail.sets[0]))
} else {
foundFolder = detail.folders.getFolderByMail(mail)
}
}
if (foundFolder != null) return foundFolder
}
return null
}
@ -211,14 +221,14 @@ export class MailModel {
* Sends the given folder and all its descendants to the spam folder, reporting mails (if applicable) and removes any empty folders
*/
async sendFolderToSpam(folder: MailFolder): Promise<void> {
const mailboxDetail = await this.getMailboxDetailsForMailListId(folder.mails)
const mailboxDetail = await this.getMailboxDetailsForMailFolder(folder)
if (mailboxDetail == null) {
return
}
let deletedFolder = await this.removeAllEmpty(mailboxDetail, folder)
if (!deletedFolder) {
return this.mailFacade.updateMailFolderParent(folder, assertSystemFolderOfType(mailboxDetail.folders, MailFolderType.SPAM)._id)
return this.mailFacade.updateMailFolderParent(folder, assertSystemFolderOfType(mailboxDetail.folders, MailSetKind.SPAM)._id)
}
}
@ -229,13 +239,14 @@ export class MailModel {
}
/**
* Finally deletes all given mails. Caller must ensure that mails are only from one folder
* Finally move all given mails. Caller must ensure that mails are only from
* * one folder (because we send one source folder)
* * from one list (for locking it on the server)
*/
async _moveMails(mails: Mail[], targetMailFolder: MailFolder): Promise<void> {
let moveMails = mails.filter((m) => m._id[0] !== targetMailFolder.mails && targetMailFolder._ownerGroup === m._ownerGroup) // prevent moving mails between mail boxes.
// Do not move if target is the same as the current mailFolder
const sourceMailFolder = this.getMailFolder(getListId(mails[0]))
const sourceMailFolder = this.getMailFolderForMail(mails[0])
let moveMails = mails.filter((m) => sourceMailFolder !== targetMailFolder && targetMailFolder._ownerGroup === m._ownerGroup) // prevent moving mails between mail boxes.
if (moveMails.length > 0 && sourceMailFolder && !isSameId(targetMailFolder._id, sourceMailFolder._id)) {
const mailChunks = splitInChunks(
@ -244,7 +255,7 @@ export class MailModel {
)
for (const mailChunk of mailChunks) {
await this.mailFacade.moveMails(mailChunk, targetMailFolder._id)
await this.mailFacade.moveMails(mailChunk, sourceMailFolder._id, targetMailFolder._id)
}
}
}
@ -255,16 +266,20 @@ export class MailModel {
*/
async moveMails(mails: ReadonlyArray<Mail>, targetMailFolder: MailFolder): Promise<void> {
const mailsPerFolder = groupBy(mails, (mail) => {
return getListId(mail)
return isNotEmpty(mail.sets) ? elementIdPart(mail.sets[0]) : getListId(mail)
})
for (const [listId, mails] of mailsPerFolder) {
const sourceMailFolder = this.getMailFolder(listId)
for (const [folderId, mailsInFolder] of mailsPerFolder) {
const sourceMailFolder = this.getMailFolderForMail(mailsInFolder[0])
if (sourceMailFolder) {
await this._moveMails(mails, targetMailFolder)
// group another time because mails in the same Set can be from different mail bags.
const mailsPerList = groupBy(mailsInFolder, (mail) => getListId(mail))
for (const [listId, mailsInList] of mailsPerList) {
await this._moveMails(mailsInList, targetMailFolder)
}
} else {
console.log("Move mail: no mail folder for list id", listId)
console.log("Move mail: no mail folder for folder id", folderId)
}
}
}
@ -296,30 +311,34 @@ export class MailModel {
* A deletion confirmation must have been show before.
*/
async deleteMails(mails: ReadonlyArray<Mail>): Promise<void> {
const mailsPerFolder = groupBy(mails, (mail) => {
return getListId(mail)
})
if (mails.length === 0) {
return
}
const mailsPerFolder = groupBy(mails, (mail) => {
return isNotEmpty(mail.sets) ? elementIdPart(mail.sets[0]) : getListId(mail)
})
const folders = await this.getMailboxFolders(mails[0])
if (folders == null) {
return
}
const trashFolder = assertNotNull(folders.getSystemFolderByType(MailFolderType.TRASH))
const trashFolder = assertNotNull(folders.getSystemFolderByType(MailSetKind.TRASH))
for (const [listId, mails] of mailsPerFolder) {
const sourceMailFolder = this.getMailFolder(listId)
for (const [folder, mailsInFolder] of mailsPerFolder) {
const sourceMailFolder = this.getMailFolderForMail(mailsInFolder[0])
if (sourceMailFolder) {
if (isSpamOrTrashFolder(folders, sourceMailFolder)) {
await this._finallyDeleteMails(mails)
const mailsPerList = groupBy(mailsInFolder, (mail) => getListId(mail))
for (const [listId, mailsInList] of mailsPerList) {
if (sourceMailFolder) {
if (isSpamOrTrashFolder(folders, sourceMailFolder)) {
await this._finallyDeleteMails(mailsInList)
} else {
await this._moveMails(mailsInList, trashFolder)
}
} else {
await this._moveMails(mails, trashFolder)
console.log("Delete mail: no mail folder for list id", folder)
}
} else {
console.log("Delete mail: no mail folder for list id", listId)
}
}
}
@ -329,7 +348,7 @@ export class MailModel {
*/
async _finallyDeleteMails(mails: Mail[]): Promise<void> {
if (!mails.length) return Promise.resolve()
const mailFolder = neverNull(this.getMailFolder(getListId(mails[0])))
const mailFolder = neverNull(this.getMailFolderForMail(mails[0]))
const mailIds = mails.map((m) => m._id)
const mailChunks = splitInChunks(MAX_NBR_MOVE_DELETE_MAIL_SERVICE, mailIds)
@ -356,16 +375,20 @@ export class MailModel {
await this._init()
m.redraw()
}
} else if (isUpdateForTypeRef(MailTypeRef, update) && update.operation === OperationType.CREATE) {
} else if (
isUpdateForTypeRef(MailTypeRef, update) &&
update.operation === OperationType.CREATE &&
!containsEventOfType(updates, OperationType.DELETE, update.instanceId)
) {
if (this.inboxRuleHandler && this.connectivityModel) {
const folder = this.getMailFolder(update.instanceListId)
const mailId: IdTuple = [update.instanceListId, update.instanceId]
const mail = await this.entityClient.load(MailTypeRef, mailId)
const folder = this.getMailFolderForMail(mail)
if (folder && folder.folderType === MailFolderType.INBOX && !containsEventOfType(updates, OperationType.DELETE, update.instanceId)) {
if (folder && folder.folderType === MailSetKind.INBOX) {
// If we don't find another delete operation on this email in the batch, then it should be a create operation,
// otherwise it's a move
const mailId: IdTuple = [update.instanceListId, update.instanceId]
const mail = await this.entityClient.load(MailTypeRef, mailId)
await this.getMailboxDetailsForMailListId(update.instanceListId)
await this.getMailboxDetailsForMail(mail)
.then((mailboxDetail) => {
// We only apply rules on server if we are the leader in case of incoming messages
return (
@ -377,7 +400,13 @@ export class MailModel {
)
)
})
.then((newId) => this._showNotification(newId || mailId))
.then((newFolderAndMail) => {
if (newFolderAndMail) {
this._showNotification(newFolderAndMail.folder, newFolderAndMail.mail)
} else {
this._showNotification(folder, mail)
}
})
.catch(noOp)
}
}
@ -389,13 +418,13 @@ export class MailModel {
const normalized = this.mailboxCounters() || {}
const group = normalized[counters.mailGroup] || {}
for (const value of counters.counterValues) {
group[value.mailListId] = Number(value.count) || 0
group[value.counterId] = Number(value.count) || 0
}
normalized[counters.mailGroup] = group
this.mailboxCounters(normalized)
}
_showNotification(mailId: IdTuple) {
_showNotification(folder: MailFolder, mail: Mail) {
this.notifications.showNotification(
NotificationType.Mail,
lang.get("newMails_msg"),
@ -403,21 +432,25 @@ export class MailModel {
actions: [],
},
(_) => {
m.route.set(`/mail/${listIdPart(mailId)}/${elementIdPart(mailId)}`)
m.route.set(`/mail/${getElementId(folder)}/${getElementId(mail)}`)
window.focus()
},
)
}
getCounterValue(listId: Id): Promise<number | null> {
return this.getMailboxDetailsForMailListId(listId)
getCounterValue(folder: MailFolder): Promise<number | null> {
return this.getMailboxDetailsForMailFolder(folder)
.then((mailboxDetails) => {
if (mailboxDetails == null) {
return null
} else {
const counters = this.mailboxCounters()
const mailGroupCounter = counters[mailboxDetails.mailGroup._id]
return mailGroupCounter && mailGroupCounter[listId]
const mailGroupCounter = this.mailboxCounters()[mailboxDetails.mailGroup._id]
if (mailGroupCounter) {
const counterId = folder.isMailSet ? getElementId(folder) : folder.mails
return mailGroupCounter[counterId]
} else {
return null
}
}
})
.catch(() => null)
@ -437,13 +470,13 @@ export class MailModel {
* Sends the given folder and all its descendants to the trash folder, removes any empty folders
*/
async trashFolderAndSubfolders(folder: MailFolder): Promise<void> {
const mailboxDetail = await this.getMailboxDetailsForMailListId(folder.mails)
const mailboxDetail = await this.getMailboxDetailsForMailFolder(folder)
if (mailboxDetail == null) {
return
}
let deletedFolder = await this.removeAllEmpty(mailboxDetail, folder)
if (!deletedFolder) {
const trash = assertSystemFolderOfType(mailboxDetail.folders, MailFolderType.TRASH)
const trash = assertSystemFolderOfType(mailboxDetail.folders, MailSetKind.TRASH)
return this.mailFacade.updateMailFolderParent(folder, trash._id)
}
}
@ -460,9 +493,8 @@ export class MailModel {
// we don't update folder system quickly enough so we keep track of deleted folders here and consider them "empty" when all their children are here
const deleted = new Set<Id>()
for (const descendant of descendants) {
// Only load one mail, if there is even one we won't remove
if (
(await this.entityClient.loadRange(MailTypeRef, descendant.folder.mails, GENERATED_MAX_ID, 1, true)).length === 0 &&
(await this.isEmptyFolder(descendant.folder)) &&
mailboxDetail.folders.getCustomFoldersOfParent(descendant.folder._id).every((f) => deleted.has(getElementId(f)))
) {
deleted.add(getElementId(descendant.folder))
@ -471,9 +503,8 @@ export class MailModel {
someNonEmpty = true
}
}
// Only load one mail, if there is even one we won't remove
if (
(await this.entityClient.loadRange(MailTypeRef, folder.mails, GENERATED_MAX_ID, 1, true)).length === 0 &&
(await this.isEmptyFolder(folder)) &&
mailboxDetail.folders.getCustomFoldersOfParent(folder._id).every((f) => deleted.has(getElementId(f))) &&
!someNonEmpty
) {
@ -484,8 +515,17 @@ export class MailModel {
}
}
// Only load one mail, if there is even one we won't remove
private async isEmptyFolder(descendant: MailFolder) {
if (descendant.isMailSet) {
return (await this.entityClient.loadRange(MailSetEntryTypeRef, descendant.entries, CUSTOM_MIN_ID, 1, false)).length === 0
} else {
return (await this.entityClient.loadRange(MailTypeRef, descendant.mails, GENERATED_MAX_ID, 1, true)).length === 0
}
}
public async finallyDeleteCustomMailFolder(folder: MailFolder): Promise<void> {
if (folder.folderType !== MailFolderType.CUSTOM) {
if (folder.folderType !== MailSetKind.CUSTOM) {
throw new ProgrammingError("Cannot delete non-custom folder: " + String(folder._id))
}
@ -499,9 +539,11 @@ export class MailModel {
)
}
async fixupCounterForMailList(listId: Id, unreadMails: number) {
const mailboxDetails = await this.getMailboxDetailsForMailListId(listId)
mailboxDetails && (await this.mailFacade.fixupCounterForMailList(mailboxDetails.mailGroup._id, listId, unreadMails))
async fixupCounterForFolder(folder: MailFolder, unreadMails: number) {
const mailboxDetails = await this.getMailboxDetailsForMailFolder(folder)
if (mailboxDetails) {
await this.mailFacade.fixupCounterForFolder(mailboxDetails.mailGroup._id, folder, unreadMails)
}
}
async clearFolder(folder: MailFolder): Promise<void> {

View file

@ -11,7 +11,7 @@ import {
MailDetails,
MailTypeRef,
} from "../api/entities/tutanota/TypeRefs.js"
import { ApprovalStatus, ConversationType, MailFolderType, MailMethod, MAX_ATTACHMENT_SIZE, OperationType, ReplyType } from "../api/common/TutanotaConstants.js"
import { ApprovalStatus, ConversationType, MailSetKind, MailMethod, MAX_ATTACHMENT_SIZE, OperationType, ReplyType } from "../api/common/TutanotaConstants.js"
import { PartialRecipient, Recipient, RecipientList, Recipients, RecipientType } from "../api/common/recipients/Recipient.js"
import {
assertNotNull,
@ -917,8 +917,8 @@ export class SendMailModel {
private async isMailInTrashOrSpam(draft: Mail): Promise<boolean> {
const folders = await this.mailModel.getMailboxFolders(draft)
const mailFolder = folders?.getFolderByMailListId(getListId(draft))
return !!mailFolder && (mailFolder.folderType === MailFolderType.TRASH || mailFolder.folderType === MailFolderType.SPAM)
const mailFolder = folders?.getFolderByMail(draft)
return !!mailFolder && (mailFolder.folderType === MailSetKind.TRASH || mailFolder.folderType === MailSetKind.SPAM)
}
private sendApprovalMail(body: string): Promise<unknown> {

View file

@ -14,14 +14,14 @@ import {
TutanotaProperties,
} from "../api/entities/tutanota/TypeRefs.js"
import { fullNameToFirstAndLastName, mailAddressToFirstAndLastName } from "../misc/parsing/MailAddressParser.js"
import { assertNotNull, contains, endsWith, first, neverNull } from "@tutao/tutanota-utils"
import { assertNotNull, contains, endsWith, first, isNotEmpty, neverNull } from "@tutao/tutanota-utils"
import {
ContactAddressType,
ConversationType,
EncryptionAuthStatus,
getMailFolderType,
GroupType,
MailFolderType,
MailSetKind,
MailState,
MAX_ATTACHMENT_SIZE,
ReplyType,
@ -36,7 +36,7 @@ import { Icons } from "../gui/base/icons/Icons.js"
import { MailboxDetail, MailModel } from "./MailModel.js"
import { LoginController } from "../api/main/LoginController.js"
import { EntityClient } from "../api/common/EntityClient.js"
import { getListId } from "../api/common/utils/EntityUtils.js"
import { getListId, isSameId } from "../api/common/utils/EntityUtils.js"
import type { FolderSystem, IndentedFolder } from "../api/common/mail/FolderSystem.js"
import { MailFacade } from "../api/worker/facades/lazy/MailFacade.js"
import { ListFilter } from "../misc/ListModel.js"
@ -170,27 +170,27 @@ export function getFolderName(folder: MailFolder): string {
}
}
export function getFolderIconByType(folderType: MailFolderType): AllIcons {
export function getFolderIconByType(folderType: MailSetKind): AllIcons {
switch (folderType) {
case MailFolderType.CUSTOM:
case MailSetKind.CUSTOM:
return Icons.Folder
case MailFolderType.INBOX:
case MailSetKind.INBOX:
return Icons.Inbox
case MailFolderType.SENT:
case MailSetKind.SENT:
return Icons.Send
case MailFolderType.TRASH:
case MailSetKind.TRASH:
return Icons.TrashBin
case MailFolderType.ARCHIVE:
case MailSetKind.ARCHIVE:
return Icons.Archive
case MailFolderType.SPAM:
case MailSetKind.SPAM:
return Icons.Spam
case MailFolderType.DRAFT:
case MailSetKind.DRAFT:
return Icons.Draft
default:
@ -368,7 +368,15 @@ export async function getMoveTargetFolderSystems(model: MailModel, mails: readon
return []
}
const folderSystem = mailboxDetails.folders
return folderSystem.getIndentedList().filter((f: IndentedFolder) => f.folder.mails !== getListId(firstMail))
return folderSystem.getIndentedList().filter((f: IndentedFolder) => {
if (f.folder.isMailSet && isNotEmpty(firstMail.sets)) {
const folderId = firstMail.sets[0]
return !isSameId(f.folder._id, folderId)
} else {
return f.folder.mails !== getListId(firstMail)
}
})
}
export const MAX_FOLDER_INDENT_LEVEL = 10
@ -459,11 +467,11 @@ export function isTutanotaMailAddress(mailAddress: string): boolean {
*
* Use with caution.
*/
export function assertSystemFolderOfType(system: FolderSystem, type: Omit<MailFolderType, MailFolderType.CUSTOM>): MailFolder {
export function assertSystemFolderOfType(system: FolderSystem, type: Omit<MailSetKind, MailSetKind.CUSTOM>): MailFolder {
return assertNotNull(system.getSystemFolderByType(type), "System folder of type does not exist!")
}
export function isOfTypeOrSubfolderOf(system: FolderSystem, folder: MailFolder, type: MailFolderType): boolean {
export function isOfTypeOrSubfolderOf(system: FolderSystem, folder: MailFolder, type: MailSetKind): boolean {
return folder.folderType === type || isSubfolderOfType(system, folder, type)
}

View file

@ -25,20 +25,20 @@ import { ListFetchResult, PageSize } from "../gui/base/ListUtils.js"
import { isOfflineError } from "../api/common/utils/ErrorUtils.js"
import { ListAutoSelectBehavior } from "./DeviceConfig.js"
export interface ListModelConfig<ElementType> {
export interface ListModelConfig<ListElementType> {
topId: Id
/**
* Get the given number of entities starting after the given id. May return more elements than requested, e.g. if all elements are available on first fetch.
*/
fetch(startId: Id, count: number): Promise<ListFetchResult<ElementType>>
fetch(startId: Id, count: number): Promise<ListFetchResult<ListElementType>>
/**
* Returns null if the given element could not be loaded
*/
loadSingle(elementId: Id): Promise<ElementType | null>
loadSingle(listId: Id, elementId: Id): Promise<ListElementType | null>
sortCompare(entity1: ElementType, entity2: ElementType): number
sortCompare(entity1: ListElementType, entity2: ListElementType): number
autoSelectBehavior: () => ListAutoSelectBehavior
}
@ -197,10 +197,10 @@ export class ListModel<ElementType extends ListElement> {
return this.filter != null
}
async entityEventReceived(elementId: Id, operation: OperationType): Promise<void> {
async entityEventReceived(listId: Id, elementId: Id, operation: OperationType): Promise<void> {
if (operation === OperationType.CREATE || operation === OperationType.UPDATE) {
// load the element without range checks for now
const entity = await this.config.loadSingle(elementId)
const entity = await this.config.loadSingle(listId, elementId)
if (!entity) {
return
}

View file

@ -1,15 +1,16 @@
import m from "mithril"
import { locator } from "../../api/main/CommonLocator"
import { MailFolderType } from "../../api/common/TutanotaConstants.js"
import { MailSetKind } from "../../api/common/TutanotaConstants.js"
import { assertSystemFolderOfType } from "../../mailFunctionality/SharedMailUtils.js"
import { getElementId } from "../../api/common/utils/EntityUtils.js"
export async function openMailbox(userId: Id, mailAddress: string, requestedPath: string | null) {
if (locator.logins.isUserLoggedIn() && locator.logins.getUserController().user._id === userId) {
if (!requestedPath) {
const [mailboxDetail] = await locator.mailModel.getMailboxDetails()
const inbox = assertSystemFolderOfType(mailboxDetail.folders, MailFolderType.INBOX)
m.route.set("/mail/" + inbox.mails)
const inbox = assertSystemFolderOfType(mailboxDetail.folders, MailSetKind.INBOX)
m.route.set("/mail/" + getElementId(inbox))
} else {
m.route.set("/mail" + requestedPath)
}

View file

@ -3,7 +3,7 @@ import { assertMainOrNode } from "../../api/common/Env"
import { modal } from "../../gui/base/Modal"
import { CALENDAR_PREFIX, CONTACTS_PREFIX, MAIL_PREFIX, SEARCH_PREFIX, SETTINGS_PREFIX } from "../../misc/RouteChange"
import { last } from "@tutao/tutanota-utils"
import { CloseEventBusOption, MailFolderType, SECOND_MS } from "../../api/common/TutanotaConstants.js"
import { CloseEventBusOption, MailSetKind, SECOND_MS } from "../../api/common/TutanotaConstants.js"
import { MobileFacade } from "../common/generatedipc/MobileFacade.js"
import { styles } from "../../gui/styles"
import { WebsocketConnectivityModel } from "../../misc/WebsocketConnectivityModel.js"
@ -12,6 +12,7 @@ import { TopLevelView } from "../../../TopLevelView.js"
import stream from "mithril/stream"
import { assertSystemFolderOfType } from "../../mailFunctionality/SharedMailUtils.js"
import { getElementId } from "../../api/common/utils/EntityUtils.js"
assertMainOrNode()
@ -85,12 +86,12 @@ export class WebMobileFacade implements MobileFacade {
.filter((part) => part !== "")
if (parts.length > 1) {
const selectedMailListId = parts[1]
const selectedMailFolderId = parts[1]
const [mailboxDetail] = await this.mailModel.getMailboxDetails()
const inboxMailListId = assertSystemFolderOfType(mailboxDetail.folders, MailFolderType.INBOX).mails
const inboxMailFolderId = getElementId(assertSystemFolderOfType(mailboxDetail.folders, MailSetKind.INBOX))
if (inboxMailListId !== selectedMailListId) {
m.route.set(MAIL_PREFIX + "/" + inboxMailListId)
if (inboxMailFolderId !== selectedMailFolderId) {
m.route.set(MAIL_PREFIX + "/" + inboxMailFolderId)
return true
} else {
return false

View file

@ -198,7 +198,7 @@ export class UserListView implements UpdatableSettingsViewer {
const { instanceListId, instanceId, operation } = update
if (isUpdateForTypeRef(GroupInfoTypeRef, update) && this.listId.getSync() === instanceListId) {
await this.listModel.entityEventReceived(instanceId, operation)
await this.listModel.entityEventReceived(instanceListId, instanceId, operation)
} else if (isUpdateFor(locator.logins.getUserController().user, update)) {
await this.loadAdmins()
this.listModel.reapplyFilter()
@ -228,7 +228,7 @@ export class UserListView implements UpdatableSettingsViewer {
return { items: allUserGroupInfos, complete: true }
},
loadSingle: async (elementId) => {
loadSingle: async (_listId: Id, elementId: Id) => {
const listId = await this.listId.getAsync()
try {
return await locator.entityClient.load<GroupInfo>(GroupInfoTypeRef, [listId, elementId])

View file

@ -91,6 +91,7 @@ export class CustomColorEditorPreview implements Component {
movedTime: null,
phishingStatus: "0",
recipientCount: "0",
sets: [],
} satisfies Partial<Mail>
const mail = createMail({
sender: createMailAddress({

View file

@ -51,6 +51,7 @@ export function showGroupInvitationDialog(invitation: ReceivedGroupInvitation) {
group: invitation.sharedGroup,
color: newColor,
name: newName,
defaultAlarmsList: [],
})
userSettingsGroupRoot.groupSettings.push(groupSettings)
}

View file

@ -45,8 +45,8 @@ export function applicationPaths({
"/recover": recover,
"/mailto": mail,
"/mail": mail,
"/mail/:listId": mail,
"/mail/:listId/:mailId": mail,
"/mail/:folderId": mail,
"/mail/:folderId/:mailId": mail,
"/ext": externalLogin,
"/contact": contact,
"/contact/:listId": contact,

View file

@ -88,7 +88,7 @@ export class ContactListViewModel {
const items = await this.getRecipientsForList(listId)
return { items, complete: true }
},
loadSingle: async (elementId: Id) => {
loadSingle: async (_listId: Id, elementId: Id) => {
return this.entityClient.load(ContactListEntryTypeRef, [listId, elementId])
},
sortCompare: (rl1, rl2) => rl1.emailAddress.localeCompare(rl2.emailAddress),
@ -194,8 +194,9 @@ export class ContactListViewModel {
private readonly entityEventsReceived: EntityEventsListener = async (updates: ReadonlyArray<EntityUpdateData>): Promise<void> => {
for (const update of updates) {
if (this.selectedContactList) {
if (isUpdateForTypeRef(ContactListEntryTypeRef, update) && isSameId(this.selectedContactList, update.instanceListId)) {
await this.listModel?.entityEventReceived(update.instanceId, update.operation)
const { instanceListId, instanceId, operation } = update
if (isUpdateForTypeRef(ContactListEntryTypeRef, update) && isSameId(this.selectedContactList, instanceListId)) {
await this.listModel?.entityEventReceived(instanceListId, instanceId, operation)
} else if (isUpdateForTypeRef(ContactTypeRef, update)) {
this.getContactsForSelectedContactListEntry()
}

View file

@ -34,7 +34,7 @@ export class ContactViewModel {
const items = await this.entityClient.loadAll(ContactTypeRef, this.contactListId)
return { items, complete: true }
},
loadSingle: async (elementId: Id) => {
loadSingle: async (_listId: Id, elementId: Id) => {
const listId = await this.contactModel.getContactListId()
if (listId == null) return null
return this.entityClient.load(ContactTypeRef, [listId, elementId])
@ -82,8 +82,9 @@ export class ContactViewModel {
private readonly entityListener: EntityEventsListener = async (updates) => {
for (const update of updates) {
if (isUpdateForTypeRef(ContactTypeRef, update) && update.instanceListId === this.contactListId) {
await this.listModel.entityEventReceived(update.instanceId, update.operation)
const { instanceListId, instanceId, operation } = update
if (isUpdateForTypeRef(ContactTypeRef, update) && instanceListId === this.contactListId) {
await this.listModel.entityEventReceived(instanceListId, instanceId, operation)
}
}
}

View file

@ -1,17 +1,17 @@
import type { InboxRule, Mail, MoveMailData } from "../../../common/api/entities/tutanota/TypeRefs.js"
import type { InboxRule, Mail, MailFolder, MoveMailData } from "../../../common/api/entities/tutanota/TypeRefs.js"
import { createMoveMailData } from "../../../common/api/entities/tutanota/TypeRefs.js"
import { InboxRuleType, MailFolderType, MAX_NBR_MOVE_DELETE_MAIL_SERVICE } from "../../../common/api/common/TutanotaConstants"
import { InboxRuleType, MailSetKind, MAX_NBR_MOVE_DELETE_MAIL_SERVICE } from "../../../common/api/common/TutanotaConstants"
import { isDomainName, isRegularExpression } from "../../../common/misc/FormatValidator"
import { assertNotNull, asyncFind, debounce, ofClass, promiseMap, splitInChunks } from "@tutao/tutanota-utils"
import { lang } from "../../../common/misc/LanguageViewModel"
import type { MailboxDetail } from "../../../common/mailFunctionality/MailModel.js"
import { LockedError, PreconditionFailedError } from "../../../common/api/common/error/RestError"
import type { SelectorItemList } from "../../../common/gui/base/DropDownSelector.js"
import { getElementId, getListId, isSameId } from "../../../common/api/common/utils/EntityUtils"
import { elementIdPart, isSameId } from "../../../common/api/common/utils/EntityUtils"
import { assertMainOrNode } from "../../../common/api/common/Env"
import { MailFacade } from "../../../common/api/worker/facades/lazy/MailFacade.js"
import { LoginController } from "../../../common/api/main/LoginController.js"
import { assertSystemFolderOfType, getMailHeaders } from "../../../common/mailFunctionality/SharedMailUtils.js"
import { getMailHeaders } from "../../../common/mailFunctionality/SharedMailUtils.js"
assertMainOrNode()
const moveMailDataPerFolder: MoveMailData[] = []
@ -24,7 +24,8 @@ async function sendMoveMailRequest(mailFacade: MailFacade): Promise<void> {
const mailChunks = splitInChunks(MAX_NBR_MOVE_DELETE_MAIL_SERVICE, moveToTargetFolder.mails)
await promiseMap(mailChunks, (mailChunk) => {
moveToTargetFolder.mails = mailChunk
return mailFacade.moveMails(mailChunk, moveToTargetFolder.targetFolder)
const sourceFolder = assertNotNull(moveToTargetFolder.sourceFolder) // old clients don't send sourceFolder. assertNotNull can be removed once sourceFolder cardinality is ONE
return mailFacade.moveMails(mailChunk, sourceFolder, moveToTargetFolder.targetFolder)
})
.catch(
ofClass(LockedError, (e) => {
@ -96,16 +97,17 @@ export class InboxRuleHandler {
* 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: Mail, applyRulesOnServer: boolean): Promise<IdTuple | null> {
if (mail._errors || !mail.unread || !isInboxList(mailboxDetail, getListId(mail)) || !this.logins.getUserController().isPremiumAccount()) {
async findAndApplyMatchingRule(mailboxDetail: MailboxDetail, mail: Mail, applyRulesOnServer: boolean): Promise<{ folder: MailFolder; mail: Mail } | null> {
if (mail._errors || !mail.unread || !isInboxFolder(mailboxDetail, mail) || !this.logins.getUserController().isPremiumAccount()) {
return null
}
const inboxRule = await _findMatchingRule(this.mailFacade, mail, this.logins.getUserController().props.inboxRules)
if (inboxRule) {
let targetFolder = mailboxDetail.folders.getFolderById(inboxRule.targetFolder)
let inboxFolder = assertNotNull(mailboxDetail.folders.getSystemFolderByType(MailSetKind.INBOX))
let targetFolder = mailboxDetail.folders.getFolderById(elementIdPart(inboxRule.targetFolder))
if (targetFolder && targetFolder.folderType !== MailFolderType.INBOX) {
if (targetFolder && targetFolder.folderType !== MailSetKind.INBOX) {
if (applyRulesOnServer) {
let moveMailData = moveMailDataPerFolder.find((folderMoveMailData) => isSameId(folderMoveMailData.targetFolder, inboxRule.targetFolder))
@ -113,6 +115,7 @@ export class InboxRuleHandler {
moveMailData.mails.push(mail._id)
} else {
moveMailData = createMoveMailData({
sourceFolder: inboxFolder._id,
targetFolder: inboxRule.targetFolder,
mails: [mail._id],
})
@ -122,7 +125,7 @@ export class InboxRuleHandler {
applyMatchingRules(this.mailFacade)
}
return [targetFolder.mails, getElementId(mail)]
return { folder: targetFolder, mail }
} else {
return null
}
@ -220,6 +223,7 @@ function _checkEmailAddresses(mailAddresses: string[], inboxRule: InboxRule): bo
return mailAddress != null
}
export function isInboxList(mailboxDetail: MailboxDetail, listId: Id): boolean {
return isSameId(listId, assertSystemFolderOfType(mailboxDetail.folders, MailFolderType.INBOX).mails)
export function isInboxFolder(mailboxDetail: MailboxDetail, mail: Mail): boolean {
const mailFolder = mailboxDetail.folders.getFolderByMail(mail)
return mailFolder?.folderType === MailSetKind.INBOX
}

View file

@ -1,20 +1,12 @@
import { ConversationEntry, ConversationEntryTypeRef, Mail, MailTypeRef } from "../../../common/api/entities/tutanota/TypeRefs.js"
import { MailViewerViewModel } from "./MailViewerViewModel.js"
import { CreateMailViewerOptions } from "./MailViewer.js"
import {
elementIdPart,
firstBiggerThanSecond,
getElementId,
getListId,
haveSameId,
isSameId,
listIdPart,
} from "../../../common/api/common/utils/EntityUtils.js"
import { elementIdPart, firstBiggerThanSecond, getElementId, haveSameId, isSameId, listIdPart } from "../../../common/api/common/utils/EntityUtils.js"
import { assertNotNull, findLastIndex, groupBy, makeSingleUse } from "@tutao/tutanota-utils"
import { EntityClient } from "../../../common/api/common/EntityClient.js"
import { LoadingStateTracker } from "../../../common/offline/LoadingState.js"
import { EntityEventsListener, EventController } from "../../../common/api/main/EventController.js"
import { ConversationType, MailFolderType, MailState, OperationType } from "../../../common/api/common/TutanotaConstants.js"
import { ConversationType, MailSetKind, MailState, OperationType } from "../../../common/api/common/TutanotaConstants.js"
import { NotAuthorizedError, NotFoundError } from "../../../common/api/common/error/RestError.js"
import { MailModel } from "../../../common/mailFunctionality/MailModel.js"
import { EntityUpdateData, isUpdateForTypeRef } from "../../../common/api/common/utils/EntityUpdateUtils.js"
@ -254,8 +246,8 @@ export class ConversationViewModel {
private async isInTrash(mail: Mail) {
const mailboxDetail = await this.mailModel.getMailboxDetailsForMail(mail)
const mailFolder = this.mailModel.getMailFolder(getListId(mail))
return mailFolder && mailboxDetail && isOfTypeOrSubfolderOf(mailboxDetail.folders, mailFolder, MailFolderType.TRASH)
const mailFolder = this.mailModel.getMailFolderForMail(mail)
return mailFolder && mailboxDetail && isOfTypeOrSubfolderOf(mailboxDetail.folders, mailFolder, MailSetKind.TRASH)
}
conversationItems(): ReadonlyArray<ConversationItem> {

View file

@ -1,4 +1,4 @@
import { MailFolder, MailTypeRef } from "../../../common/api/entities/tutanota/TypeRefs.js"
import { Mail, MailFolder, MailSetEntryTypeRef, MailTypeRef } from "../../../common/api/entities/tutanota/TypeRefs.js"
import { DropDownSelector, SelectorItemList } from "../../../common/gui/base/DropDownSelector.js"
import m from "mithril"
import { TextField } from "../../../common/gui/base/TextField.js"
@ -7,25 +7,26 @@ import { locator } from "../../../common/api/main/CommonLocator.js"
import { LockedError } from "../../../common/api/common/error/RestError.js"
import { lang, TranslationKey } from "../../../common/misc/LanguageViewModel.js"
import { MailboxDetail } from "../../../common/mailFunctionality/MailModel.js"
import { MailFolderType, MailReportType } from "../../../common/api/common/TutanotaConstants.js"
import { isSameId } from "../../../common/api/common/utils/EntityUtils.js"
import { MailReportType, MailSetKind } from "../../../common/api/common/TutanotaConstants.js"
import { elementIdPart, isSameId, listIdPart } from "../../../common/api/common/utils/EntityUtils.js"
import { reportMailsAutomatically } from "./MailReportDialog.js"
import { isOfflineError } from "../../../common/api/common/utils/ErrorUtils.js"
import { getFolderName, getIndentedFolderNameForDropdown, getPathToFolderString } from "../../../common/mailFunctionality/SharedMailUtils.js"
import { isSpamOrTrashFolder } from "../../../common/api/common/CommonMailUtils.js"
import { groupByAndMap } from "@tutao/tutanota-utils"
/**
* Dialog for Edit and Add folder are the same.
* @param editFolder if this is null, a folder is being added, otherwise a folder is being edited
* @param editedFolder if this is null, a folder is being added, otherwise a folder is being edited
*/
export async function showEditFolderDialog(mailBoxDetail: MailboxDetail, editFolder: MailFolder | null = null, parentFolder: MailFolder | null = null) {
export async function showEditFolderDialog(mailBoxDetail: MailboxDetail, editedFolder: MailFolder | null = null, parentFolder: MailFolder | null = null) {
const noParentFolderOption = lang.get("comboBoxSelectionNone_msg")
const mailGroupId = mailBoxDetail.mailGroup._id
let folderNameValue = editFolder?.name ?? ""
let folderNameValue = editedFolder?.name ?? ""
let targetFolders: SelectorItemList<MailFolder | null> = mailBoxDetail.folders
.getIndentedList(editFolder)
.getIndentedList(editedFolder)
// filter: SPAM and TRASH and descendants are only shown if editing (folders can only be moved there, not created there)
.filter((folderInfo) => !(editFolder === null && isSpamOrTrashFolder(mailBoxDetail.folders, folderInfo.folder)))
.filter((folderInfo) => !(editedFolder === null && isSpamOrTrashFolder(mailBoxDetail.folders, folderInfo.folder)))
.map((folderInfo) => {
return {
name: getIndentedFolderNameForDropdown(folderInfo),
@ -36,7 +37,7 @@ export async function showEditFolderDialog(mailBoxDetail: MailboxDetail, editFol
let selectedParentFolder = parentFolder
let form = () => [
m(TextField, {
label: editFolder ? "rename_action" : "folderName_label",
label: editedFolder ? "rename_action" : "folderName_label",
value: folderNameValue,
oninput: (newInput) => {
folderNameValue = newInput
@ -51,47 +52,69 @@ export async function showEditFolderDialog(mailBoxDetail: MailboxDetail, editFol
helpLabel: () => (selectedParentFolder ? getPathToFolderString(mailBoxDetail.folders, selectedParentFolder) : ""),
}),
]
async function getMailIdsGroupedByListId(folder: MailFolder): Promise<Map<Id, Id[]>> {
const mailSetEntries = await locator.entityClient.loadAll(MailSetEntryTypeRef, folder.entries)
return groupByAndMap(
mailSetEntries,
(mse) => listIdPart(mse.mail),
(mse) => elementIdPart(mse.mail),
)
}
async function loadAllMailsOfFolder(folder: MailFolder, reportableMails: Array<Mail>) {
if (folder.isMailSet) {
const mailIdsPerBag = await getMailIdsGroupedByListId(folder)
for (const [mailListId, mailIds] of mailIdsPerBag) {
reportableMails.push(...(await locator.entityClient.loadMultiple(MailTypeRef, mailListId, mailIds)))
}
} else {
reportableMails.push(...(await locator.entityClient.loadAll(MailTypeRef, folder.mails)))
}
}
const okAction = async (dialog: Dialog) => {
// closing right away to prevent duplicate actions
dialog.close()
try {
// if folder is null, create new folder
if (editFolder === null) {
if (editedFolder === null) {
await locator.mailFacade.createMailFolder(folderNameValue, selectedParentFolder?._id ?? null, mailGroupId)
} else {
// if it is being moved to trash (and not already in trash), ask about trashing
if (selectedParentFolder?.folderType === MailFolderType.TRASH && !isSameId(selectedParentFolder._id, editFolder.parentFolder)) {
if (selectedParentFolder?.folderType === MailSetKind.TRASH && !isSameId(selectedParentFolder._id, editedFolder.parentFolder)) {
const confirmed = await Dialog.confirm(() =>
lang.get("confirmDeleteCustomFolder_msg", {
"{1}": getFolderName(editFolder),
"{1}": getFolderName(editedFolder),
}),
)
if (!confirmed) return
await locator.mailFacade.updateMailFolderName(editFolder, folderNameValue)
await locator.mailModel.trashFolderAndSubfolders(editFolder)
} else if (selectedParentFolder?.folderType === MailFolderType.SPAM && !isSameId(selectedParentFolder._id, editFolder.parentFolder)) {
await locator.mailFacade.updateMailFolderName(editedFolder, folderNameValue)
await locator.mailModel.trashFolderAndSubfolders(editedFolder)
} else if (selectedParentFolder?.folderType === MailSetKind.SPAM && !isSameId(selectedParentFolder._id, editedFolder.parentFolder)) {
// if it is being moved to spam (and not already in spam), ask about reporting containing emails
const confirmed = await Dialog.confirm(() =>
lang.get("confirmSpamCustomFolder_msg", {
"{1}": getFolderName(editFolder),
"{1}": getFolderName(editedFolder),
}),
)
if (!confirmed) return
// get mails to report before moving to mail model
const descendants = mailBoxDetail.folders.getDescendantFoldersOfParent(editFolder._id).sort((l, r) => r.level - l.level)
let reportableMails = await locator.entityClient.loadAll(MailTypeRef, editFolder.mails)
const descendants = mailBoxDetail.folders.getDescendantFoldersOfParent(editedFolder._id).sort((l, r) => r.level - l.level)
let reportableMails: Array<Mail> = []
await loadAllMailsOfFolder(editedFolder, reportableMails)
for (const descendant of descendants) {
reportableMails.push(...(await locator.entityClient.loadAll(MailTypeRef, descendant.folder.mails)))
await loadAllMailsOfFolder(descendant.folder, reportableMails)
}
await reportMailsAutomatically(MailReportType.SPAM, locator.mailModel, mailBoxDetail, reportableMails)
await locator.mailFacade.updateMailFolderName(editFolder, folderNameValue)
await locator.mailModel.sendFolderToSpam(editFolder)
await locator.mailFacade.updateMailFolderName(editedFolder, folderNameValue)
await locator.mailModel.sendFolderToSpam(editedFolder)
} else {
await locator.mailFacade.updateMailFolderName(editFolder, folderNameValue)
await locator.mailFacade.updateMailFolderParent(editFolder, selectedParentFolder?._id || null)
await locator.mailFacade.updateMailFolderName(editedFolder, folderNameValue)
await locator.mailFacade.updateMailFolderParent(editedFolder, selectedParentFolder?._id || null)
}
}
} catch (error) {
@ -102,7 +125,7 @@ export async function showEditFolderDialog(mailBoxDetail: MailboxDetail, editFol
}
Dialog.showActionDialog({
title: editFolder ? lang.get("editFolder_action") : lang.get("addFolder_action"),
title: editedFolder ? lang.get("editFolder_action") : lang.get("addFolder_action"),
child: form,
validator: () => checkFolderName(mailBoxDetail, folderNameValue, mailGroupId, selectedParentFolder?._id ?? null),
allowOkWithReturn: true,

View file

@ -4,7 +4,7 @@ import { locator } from "../../../common/api/main/CommonLocator.js"
import { SidebarSection } from "../../../common/gui/SidebarSection.js"
import { IconButton, IconButtonAttrs } from "../../../common/gui/base/IconButton.js"
import { FolderSubtree } from "../../../common/api/common/mail/FolderSystem.js"
import { getElementId } from "../../../common/api/common/utils/EntityUtils.js"
import { elementIdPart, getElementId } from "../../../common/api/common/utils/EntityUtils.js"
import { isSelectedPrefix, NavButtonAttrs, NavButtonColor } from "../../../common/gui/base/NavButton.js"
import { MAIL_PREFIX } from "../../../common/misc/RouteChange.js"
import { MailFolderRow } from "./MailFolderRow.js"
@ -14,7 +14,7 @@ import { attachDropdown, DropdownButtonAttrs } from "../../../common/gui/base/Dr
import { Icons } from "../../../common/gui/base/icons/Icons.js"
import { ButtonColor } from "../../../common/gui/base/Button.js"
import { ButtonSize } from "../../../common/gui/base/ButtonSize.js"
import { MailFolderType } from "../../../common/api/common/TutanotaConstants.js"
import { MailSetKind } from "../../../common/api/common/TutanotaConstants.js"
import { px, size } from "../../../common/gui/size.js"
import { RowButton } from "../../../common/gui/base/buttons/RowButton.js"
import { getFolderIcon, getFolderName, MAX_FOLDER_INDENT_LEVEL } from "../../../common/mailFunctionality/SharedMailUtils.js"
@ -22,7 +22,7 @@ import { isSpamOrTrashFolder } from "../../../common/api/common/CommonMailUtils.
export interface MailFolderViewAttrs {
mailboxDetail: MailboxDetail
mailListToSelectedMail: ReadonlyMap<Id, Id>
mailFolderToSelectedMail: ReadonlyMap<MailFolder, Id>
onFolderClick: (folder: MailFolder) => unknown
onFolderDrop: (mailId: string, folder: MailFolder) => unknown
expandedFolders: ReadonlySet<Id>
@ -51,7 +51,7 @@ export class MailFoldersView implements Component<MailFolderViewAttrs> {
const selectedFolder = mailboxDetail.folders
.getIndentedList()
.map((f) => f.folder)
.find((f) => isSelectedPrefix(MAIL_PREFIX + "/" + f.mails))
.find((f) => isSelectedPrefix(MAIL_PREFIX + "/" + getElementId(f)))
const path = selectedFolder ? mailboxDetail.folders.getPathToFolder(selectedFolder._id) : []
const isInternalUser = locator.logins.isInternalUserLoggedIn()
const systemChildren = this.renderFolderTree(systemSystems, groupCounters, attrs, path, isInternalUser)
@ -93,15 +93,16 @@ export class MailFoldersView implements Component<MailFolderViewAttrs> {
if (attrs.inEditMode) {
return m.route.get()
} else {
const mailId = attrs.mailListToSelectedMail.get(system.folder.mails)
const mailId = attrs.mailFolderToSelectedMail.get(system.folder)
const folderId = getElementId(system.folder)
if (mailId) {
return `/mail/${system.folder.mails}/${mailId}`
return `${MAIL_PREFIX}/${folderId}/${mailId}`
} else {
return `/mail/${system.folder.mails}`
return `${MAIL_PREFIX}/${folderId}`
}
}
},
isSelectedPrefix: attrs.inEditMode ? false : MAIL_PREFIX + "/" + system.folder.mails,
isSelectedPrefix: attrs.inEditMode ? false : MAIL_PREFIX + "/" + getElementId(system.folder),
colors: NavButtonColor.Nav,
click: () => attrs.onFolderClick(system.folder),
dropHandler: (droppedMailId) => attrs.onFolderDrop(droppedMailId, system.folder),
@ -110,12 +111,13 @@ export class MailFoldersView implements Component<MailFolderViewAttrs> {
}
const currentExpansionState = attrs.inEditMode ? true : attrs.expandedFolders.has(getElementId(system.folder)) ?? false //default is false
const hasChildren = system.children.length > 0
const summedCount = !currentExpansionState && hasChildren ? this.getTotalFolderCounter(groupCounters, system) : groupCounters[system.folder.mails]
const counterId = system.folder.isMailSet ? getElementId(system.folder) : system.folder.mails
const summedCount = !currentExpansionState && hasChildren ? this.getTotalFolderCounter(groupCounters, system) : groupCounters[counterId]
const childResult =
hasChildren && currentExpansionState
? this.renderFolderTree(system.children, groupCounters, attrs, path, isInternalUser, indentationLevel + 1)
: { children: null, numRows: 0 }
const isTrashOrSpam = system.folder.folderType === MailFolderType.TRASH || system.folder.folderType === MailFolderType.SPAM
const isTrashOrSpam = system.folder.folderType === MailSetKind.TRASH || system.folder.folderType === MailSetKind.SPAM
const isRightButtonVisible = this.visibleRow === id
const rightButton =
isInternalUser && !isTrashOrSpam && (isRightButtonVisible || attrs.inEditMode)
@ -171,7 +173,8 @@ export class MailFoldersView implements Component<MailFolderViewAttrs> {
}
private getTotalFolderCounter(counters: Counters, system: FolderSubtree): number {
return (counters[system.folder.mails] ?? 0) + system.children.reduce((acc, child) => acc + this.getTotalFolderCounter(counters, child), 0)
const counterId = system.folder.isMailSet ? getElementId(system.folder) : system.folder.mails
return (counters[counterId] ?? 0) + system.children.reduce((acc, child) => acc + this.getTotalFolderCounter(counters, child), 0)
}
private createFolderMoreButton(folder: MailFolder, attrs: MailFolderViewAttrs, onClose: Thunk): IconButtonAttrs {
@ -183,7 +186,7 @@ export class MailFoldersView implements Component<MailFolderViewAttrs> {
size: ButtonSize.Compact,
},
childAttrs: () => {
return folder.folderType === MailFolderType.CUSTOM
return folder.folderType === MailSetKind.CUSTOM
? // cannot add new folder to custom folder in spam or trash folder
isSpamOrTrashFolder(attrs.mailboxDetail.folders, folder)
? [this.editButtonAttrs(attrs, folder), this.deleteButtonAttrs(attrs, folder)]
@ -222,7 +225,7 @@ export class MailFoldersView implements Component<MailFolderViewAttrs> {
attrs.onShowFolderAddEditDialog(
attrs.mailboxDetail.mailGroup._id,
folder,
folder.parentFolder ? attrs.mailboxDetail.folders.getFolderById(folder.parentFolder) : null,
folder.parentFolder ? attrs.mailboxDetail.folders.getFolderById(elementIdPart(folder.parentFolder)) : null,
)
},
}

View file

@ -8,8 +8,7 @@ import { AllIcons } from "../../../common/gui/base/Icon"
import { Icons } from "../../../common/gui/base/icons/Icons"
import { isApp, isDesktop } from "../../../common/api/common/Env"
import { assertNotNull, neverNull, noOp, promiseMap } from "@tutao/tutanota-utils"
import { MailFolderType, MailReportType } from "../../../common/api/common/TutanotaConstants"
import { getElementId, getListId } from "../../../common/api/common/utils/EntityUtils"
import { MailReportType, MailSetKind } from "../../../common/api/common/TutanotaConstants"
import { reportMailsAutomatically } from "./MailReportDialog"
import { DataFile } from "../../../common/api/common/DataFile"
import { lang, TranslationKey } from "../../../common/misc/LanguageViewModel"
@ -20,22 +19,23 @@ import { ConversationViewModel } from "./ConversationViewModel.js"
import { size } from "../../../common/gui/size.js"
import { PinchZoom } from "../../../common/gui/PinchZoom.js"
import { InlineImageReference, InlineImages } from "../../../common/mailFunctionality/inlineImagesUtils.js"
import { isOfTypeOrSubfolderOf } from "../../../common/mailFunctionality/SharedMailUtils.js"
import {
assertSystemFolderOfType,
getFolderIcon,
getFolderName,
getIndentedFolderNameForDropdown,
getMoveTargetFolderSystems,
isOfTypeOrSubfolderOf,
} from "../../../common/mailFunctionality/SharedMailUtils.js"
import { isSpamOrTrashFolder } from "../../../common/api/common/CommonMailUtils.js"
import { getElementId } from "../../../common/api/common/utils/EntityUtils.js"
export async function showDeleteConfirmationDialog(mails: ReadonlyArray<Mail>): Promise<boolean> {
let trashMails: Mail[] = []
let moveMails: Mail[] = []
for (let mail of mails) {
const folder = locator.mailModel.getMailFolder(mail._id[0])
const mailboxDetail = await locator.mailModel.getMailboxDetailsForMailListId(getListId(mail))
const folder = locator.mailModel.getMailFolderForMail(mail)
const mailboxDetail = await locator.mailModel.getMailboxDetailsForMail(mail)
if (mailboxDetail == null) {
continue
}
@ -96,7 +96,7 @@ interface MoveMailsParams {
* @return whether mails were actually moved
*/
export async function moveMails({ mailModel, mails, targetMailFolder, isReportable = true }: MoveMailsParams): Promise<boolean> {
const details = await mailModel.getMailboxDetailsForMailListId(targetMailFolder.mails)
const details = await mailModel.getMailboxDetailsForMailFolder(targetMailFolder)
if (details == null) {
return false
}
@ -104,11 +104,11 @@ export async function moveMails({ mailModel, mails, targetMailFolder, isReportab
return mailModel
.moveMails(mails, targetMailFolder)
.then(async () => {
if (isOfTypeOrSubfolderOf(system, targetMailFolder, MailFolderType.SPAM) && isReportable) {
if (isOfTypeOrSubfolderOf(system, targetMailFolder, MailSetKind.SPAM) && isReportable) {
const reportableMails = mails.map((mail) => {
// mails have just been moved
const reportableMail = createMail(mail)
reportableMail._id = [targetMailFolder.mails, getElementId(mail)]
reportableMail._id = targetMailFolder.isMailSet ? mail._id : [targetMailFolder.mails, getElementId(mail)]
return reportableMail
})
const mailboxDetails = await mailModel.getMailboxDetailsForMailGroup(assertNotNull(targetMailFolder._ownerGroup))
@ -135,7 +135,7 @@ export function archiveMails(mails: Mail[]): Promise<void> {
moveMails({
mailModel: locator.mailModel,
mails: mails,
targetMailFolder: assertSystemFolderOfType(folders, MailFolderType.ARCHIVE),
targetMailFolder: assertSystemFolderOfType(folders, MailSetKind.ARCHIVE),
})
})
} else {
@ -151,7 +151,7 @@ export function moveToInbox(mails: Mail[]): Promise<any> {
moveMails({
mailModel: locator.mailModel,
mails: mails,
targetMailFolder: assertSystemFolderOfType(folders, MailFolderType.INBOX),
targetMailFolder: assertSystemFolderOfType(folders, MailSetKind.INBOX),
})
})
} else {
@ -160,7 +160,7 @@ export function moveToInbox(mails: Mail[]): Promise<any> {
}
export function getMailFolderIcon(mail: Mail): AllIcons {
let folder = locator.mailModel.getMailFolder(mail._id[0])
let folder = locator.mailModel.getMailFolderForMail(mail)
if (folder) {
return getFolderIcon(folder)

View file

@ -1,7 +1,7 @@
import m, { Children, Component, Vnode } from "mithril"
import { lang } from "../../../common/misc/LanguageViewModel"
import { Keys, MailFolderType, MailState } from "../../../common/api/common/TutanotaConstants"
import { Keys, MailSetKind, MailState } from "../../../common/api/common/TutanotaConstants"
import type { Mail, MailFolder } from "../../../common/api/entities/tutanota/TypeRefs.js"
import { size } from "../../../common/gui/size"
import { styles } from "../../../common/gui/styles"
@ -39,12 +39,10 @@ export interface MailListViewAttrs {
// but for that we need to rewrite the List
onClearFolder: () => unknown
mailViewModel: MailViewModel
listId: Id
onSingleSelection: (mail: Mail) => unknown
}
export class MailListView implements Component<MailListViewAttrs> {
listId: Id
// Mails that are currently being or have already been downloaded/bundled/saved
// Map of (Mail._id ++ MailExportMode) -> Promise<Filepath>
// TODO this currently grows bigger and bigger and bigger if the user goes on an exporting spree.
@ -84,7 +82,6 @@ export class MailListView implements Component<MailListViewAttrs> {
constructor({ attrs }: Vnode<MailListViewAttrs>) {
this.mailViewModel = attrs.mailViewModel
this.listId = attrs.listId
this.exportedMails = new Map()
this._listDom = null
this.mailViewModel.showingTrashOrSpamFolder().then((result) => {
@ -106,9 +103,9 @@ export class MailListView implements Component<MailListViewAttrs> {
private getRecoverFolder(mail: Mail, folders: FolderSystem): MailFolder {
if (mail.state === MailState.DRAFT) {
return assertSystemFolderOfType(folders, MailFolderType.DRAFT)
return assertSystemFolderOfType(folders, MailSetKind.DRAFT)
} else {
return assertSystemFolderOfType(folders, MailFolderType.INBOX)
return assertSystemFolderOfType(folders, MailSetKind.INBOX)
}
}
@ -301,7 +298,7 @@ export class MailListView implements Component<MailListViewAttrs> {
this.mailViewModel = vnode.attrs.mailViewModel
// Save the folder before showing the dialog so that there's no chance that it will change
const folder = this.mailViewModel.getSelectedFolder()
const folder = this.mailViewModel.getFolder()
const purgeButtonAttrs: ButtonAttrs = {
label: "clearFolder_action",
type: ButtonType.Primary,
@ -396,10 +393,10 @@ export class MailListView implements Component<MailListViewAttrs> {
}
private async targetInbox(): Promise<boolean> {
const selectedFolder = this.mailViewModel.getSelectedFolder()
const selectedFolder = this.mailViewModel.getFolder()
if (selectedFolder) {
const mailDetails = await this.mailViewModel.getMailboxDetails()
return isOfTypeOrSubfolderOf(mailDetails.folders, selectedFolder, MailFolderType.ARCHIVE) || selectedFolder.folderType === MailFolderType.TRASH
return isOfTypeOrSubfolderOf(mailDetails.folders, selectedFolder, MailSetKind.ARCHIVE) || selectedFolder.folderType === MailSetKind.TRASH
} else {
return false
}
@ -422,7 +419,7 @@ export class MailListView implements Component<MailListViewAttrs> {
//to determinate the target folder
const targetMailFolder = this.showingSpamOrTrash
? this.getRecoverFolder(listElement, folders)
: assertNotNull(folders.getSystemFolderByType(this.showingArchive ? MailFolderType.INBOX : MailFolderType.ARCHIVE))
: assertNotNull(folders.getSystemFolderByType(this.showingArchive ? MailSetKind.INBOX : MailSetKind.ARCHIVE))
const wereMoved = await moveMails({
mailModel: locator.mailModel,
mails: [listElement],

View file

@ -1,4 +1,4 @@
import { EncryptionAuthStatus, getMailFolderType, MailFolderType, MailState, ReplyType } from "../../../common/api/common/TutanotaConstants"
import { getMailFolderType, MailSetKind, MailState, ReplyType } from "../../../common/api/common/TutanotaConstants"
import { FontIcons } from "../../../common/gui/base/icons/FontIcons"
import type { Mail } from "../../../common/api/entities/tutanota/TypeRefs.js"
import { formatTimeOrDateOrYesterday } from "../../../common/misc/Formatter.js"
@ -20,17 +20,17 @@ import { px, size } from "../../../common/gui/size.js"
import { NBSP, noOp } from "@tutao/tutanota-utils"
import { VirtualRow } from "../../../common/gui/base/ListUtils.js"
import { companyTeamLabel } from "../../../common/misc/ClientConstants.js"
import { getConfidentialFontIcon, getSenderOrRecipientHeading } from "../../../common/mailFunctionality/SharedMailUtils.js"
import { isTutanotaTeamMail } from "../../../common/mailFunctionality/SharedMailUtils.js"
import { getConfidentialFontIcon, getSenderOrRecipientHeading, isTutanotaTeamMail } from "../../../common/mailFunctionality/SharedMailUtils.js"
const iconMap: Record<MailFolderType, string> = {
[MailFolderType.CUSTOM]: FontIcons.Folder,
[MailFolderType.INBOX]: FontIcons.Inbox,
[MailFolderType.SENT]: FontIcons.Sent,
[MailFolderType.TRASH]: FontIcons.Trash,
[MailFolderType.ARCHIVE]: FontIcons.Archive,
[MailFolderType.SPAM]: FontIcons.Spam,
[MailFolderType.DRAFT]: FontIcons.Draft,
const iconMap: Record<MailSetKind, string> = {
[MailSetKind.CUSTOM]: FontIcons.Folder,
[MailSetKind.INBOX]: FontIcons.Inbox,
[MailSetKind.SENT]: FontIcons.Sent,
[MailSetKind.TRASH]: FontIcons.Trash,
[MailSetKind.ARCHIVE]: FontIcons.Archive,
[MailSetKind.SPAM]: FontIcons.Spam,
[MailSetKind.DRAFT]: FontIcons.Draft,
[MailSetKind.ALL]: FontIcons.Folder,
}
export const MAIL_ROW_V_MARGIN = 3
@ -49,7 +49,7 @@ export class MailRow implements VirtualRow<Mail> {
private dateDom!: HTMLElement
private iconsDom!: HTMLElement
private unreadDom!: HTMLElement
private folderIconsDom: Record<MailFolderType, HTMLElement>
private folderIconsDom: Record<MailSetKind, HTMLElement>
private teamLabelDom!: HTMLElement
private checkboxDom!: HTMLInputElement
private checkboxWasVisible = shouldAlwaysShowMultiselectCheckbox()
@ -58,7 +58,7 @@ export class MailRow implements VirtualRow<Mail> {
constructor(private readonly showFolderIcon: boolean, private readonly onSelected: (mail: Mail, selected: boolean) => unknown) {
this.top = 0
this.entity = null
this.folderIconsDom = {} as Record<MailFolderType, HTMLElement>
this.folderIconsDom = {} as Record<MailSetKind, HTMLElement>
}
update(mail: Mail, selected: boolean, isInMultiSelect: boolean): void {
@ -255,7 +255,7 @@ export class MailRow implements VirtualRow<Mail> {
let iconText = ""
if (this.showFolderIcon) {
let folder = locator.mailModel.getMailFolder(mail._id[0])
let folder = locator.mailModel.getMailFolderForMail(mail)
iconText += folder ? this.folderIcon(getMailFolderType(folder)) : ""
}
@ -291,7 +291,7 @@ export class MailRow implements VirtualRow<Mail> {
return iconText
}
private folderIcon(type: MailFolderType): string {
private folderIcon(type: MailSetKind): string {
return iconMap[type]
}
}

View file

@ -3,7 +3,7 @@ import { ViewSlider } from "../../../common/gui/nav/ViewSlider.js"
import { ColumnType, ViewColumn } from "../../../common/gui/base/ViewColumn"
import { lang } from "../../../common/misc/LanguageViewModel"
import { Dialog } from "../../../common/gui/base/Dialog"
import { FeatureType, Keys, MailFolderType } from "../../../common/api/common/TutanotaConstants"
import { FeatureType, Keys, MailSetKind } from "../../../common/api/common/TutanotaConstants"
import { AppHeaderAttrs, Header } from "../../../common/gui/Header.js"
import type { Mail, MailFolder } from "../../../common/api/entities/tutanota/TypeRefs.js"
import { noOp, ofClass } from "@tutao/tutanota-utils"
@ -109,12 +109,12 @@ export class MailView extends BaseTopLevelView implements TopLevelView<MailViewA
this.listColumn = new ViewColumn(
{
view: () => {
const listId = this.mailViewModel.getListId()
const folder = this.mailViewModel.getFolder()
return m(BackgroundColumnLayout, {
backgroundColor: theme.navigation_bg,
desktopToolbar: () =>
m(DesktopListToolbar, m(SelectAllCheckbox, selectionAttrsForList(this.mailViewModel.listModel)), this.renderFilterButton()),
columnLayout: listId
columnLayout: folder
? m(
"",
{
@ -123,9 +123,8 @@ export class MailView extends BaseTopLevelView implements TopLevelView<MailViewA
},
},
m(MailListView, {
key: listId,
key: getElementId(folder),
mailViewModel: this.mailViewModel,
listId: listId,
onSingleSelection: (mail) => {
if (!this.mailViewModel.listModel?.state.inMultiselect) {
this.viewSlider.focus(this.mailColumn)
@ -142,7 +141,7 @@ export class MailView extends BaseTopLevelView implements TopLevelView<MailViewA
}
},
onClearFolder: async () => {
const folder = this.mailViewModel.getSelectedFolder()
const folder = this.mailViewModel.getFolder()
if (folder == null) {
console.warn("Cannot delete folder, no folder is selected")
return
@ -186,7 +185,7 @@ export class MailView extends BaseTopLevelView implements TopLevelView<MailViewA
minWidth: size.second_col_min_width,
maxWidth: size.second_col_max_width,
headerCenter: () => {
const selectedFolder = this.mailViewModel.getSelectedFolder()
const selectedFolder = this.mailViewModel.getFolder()
return selectedFolder ? getFolderName(selectedFolder) : ""
},
},
@ -406,7 +405,7 @@ export class MailView extends BaseTopLevelView implements TopLevelView<MailViewA
exec: () => {
this.showNewMailDialog().catch(ofClass(PermissionError, noOp))
},
enabled: () => !!this.mailViewModel.getSelectedFolder() && isNewMailActionAvailable(),
enabled: () => !!this.mailViewModel.getFolder() && isNewMailActionAvailable(),
help: "newMail_action",
},
{
@ -458,7 +457,7 @@ export class MailView extends BaseTopLevelView implements TopLevelView<MailViewA
{
key: Keys.ONE,
exec: () => {
this.mailViewModel.switchToFolder(MailFolderType.INBOX)
this.mailViewModel.switchToFolder(MailSetKind.INBOX)
return true
},
help: "switchInbox_action",
@ -466,7 +465,7 @@ export class MailView extends BaseTopLevelView implements TopLevelView<MailViewA
{
key: Keys.TWO,
exec: () => {
this.mailViewModel.switchToFolder(MailFolderType.DRAFT)
this.mailViewModel.switchToFolder(MailSetKind.DRAFT)
return true
},
help: "switchDrafts_action",
@ -474,7 +473,7 @@ export class MailView extends BaseTopLevelView implements TopLevelView<MailViewA
{
key: Keys.THREE,
exec: () => {
this.mailViewModel.switchToFolder(MailFolderType.SENT)
this.mailViewModel.switchToFolder(MailSetKind.SENT)
return true
},
help: "switchSentFolder_action",
@ -482,7 +481,7 @@ export class MailView extends BaseTopLevelView implements TopLevelView<MailViewA
{
key: Keys.FOUR,
exec: () => {
this.mailViewModel.switchToFolder(MailFolderType.TRASH)
this.mailViewModel.switchToFolder(MailSetKind.TRASH)
return true
},
help: "switchTrash_action",
@ -490,7 +489,7 @@ export class MailView extends BaseTopLevelView implements TopLevelView<MailViewA
{
key: Keys.FIVE,
exec: () => {
this.mailViewModel.switchToFolder(MailFolderType.ARCHIVE)
this.mailViewModel.switchToFolder(MailSetKind.ARCHIVE)
return true
},
enabled: () => locator.logins.isInternalUserLoggedIn(),
@ -499,7 +498,7 @@ export class MailView extends BaseTopLevelView implements TopLevelView<MailViewA
{
key: Keys.SIX,
exec: () => {
this.mailViewModel.switchToFolder(MailFolderType.SPAM)
this.mailViewModel.switchToFolder(MailSetKind.SPAM)
return true
},
enabled: () => locator.logins.isInternalUserLoggedIn() && !locator.logins.isEnabled(FeatureType.InternalCommunication),
@ -594,7 +593,7 @@ export class MailView extends BaseTopLevelView implements TopLevelView<MailViewA
return m(MailFoldersView, {
mailboxDetail,
expandedFolders: this.expandedState,
mailListToSelectedMail: this.mailViewModel.getMailListToSelectedMail(),
mailFolderToSelectedMail: this.mailViewModel.getMailFolderToSelectedMail(),
onFolderClick: () => {
if (!inEditMode) {
this.viewSlider.focus(this.listColumn)
@ -643,7 +642,7 @@ export class MailView extends BaseTopLevelView implements TopLevelView<MailViewA
this.viewSlider.focusPreviousColumn()
}
this.mailViewModel.showMail(args.listId, args.mailId)
this.mailViewModel.showMailWithFolderId(args.folderId, args.mailId)
}
private async handleFolderDrop(droppedMailId: string, folder: MailFolder) {
@ -677,7 +676,7 @@ export class MailView extends BaseTopLevelView implements TopLevelView<MailViewA
}
private async deleteCustomMailFolder(mailboxDetail: MailboxDetail, folder: MailFolder): Promise<void> {
if (folder.folderType !== MailFolderType.CUSTOM) {
if (folder.folderType !== MailSetKind.CUSTOM) {
throw new Error("Cannot delete non-custom folder: " + String(folder._id))
}

View file

@ -1,14 +1,34 @@
import { ListModel } from "../../../common/misc/ListModel.js"
import { MailboxDetail, MailModel } from "../../../common/mailFunctionality/MailModel.js"
import { EntityClient } from "../../../common/api/common/EntityClient.js"
import { Mail, MailFolder, MailTypeRef } from "../../../common/api/entities/tutanota/TypeRefs.js"
import { firstBiggerThanSecond, GENERATED_MAX_ID, getElementId, isSameId, sortCompareByReverseId } from "../../../common/api/common/utils/EntityUtils.js"
import { assertNotNull, count, debounce, lastThrow, lazyMemoized, mapWith, mapWithout, memoized, ofClass, promiseFilter } from "@tutao/tutanota-utils"
import { Mail, MailFolder, MailSetEntry, MailSetEntryTypeRef, MailTypeRef } from "../../../common/api/entities/tutanota/TypeRefs.js"
import {
elementIdPart,
firstBiggerThanSecond,
GENERATED_MAX_ID,
getElementId,
isSameId,
listIdPart,
sortCompareByReverseId,
} from "../../../common/api/common/utils/EntityUtils.js"
import {
assertNotNull,
count,
debounce,
groupByAndMap,
lastThrow,
lazyMemoized,
mapWith,
mapWithout,
memoized,
ofClass,
promiseFilter,
} from "@tutao/tutanota-utils"
import { ListState } from "../../../common/gui/base/List.js"
import { ConversationPrefProvider, ConversationViewModel, ConversationViewModelFactory } from "./ConversationViewModel.js"
import { CreateMailViewerOptions } from "./MailViewer.js"
import { isOfflineError } from "../../../common/api/common/utils/ErrorUtils.js"
import { MailFolderType } from "../../../common/api/common/TutanotaConstants.js"
import { MailSetKind, OperationType } from "../../../common/api/common/TutanotaConstants.js"
import { WsConnectionState } from "../../../common/api/main/WorkerClient.js"
import { WebsocketConnectivityModel } from "../../../common/misc/WebsocketConnectivityModel.js"
import { ExposedCacheStorage } from "../../../common/api/worker/rest/DefaultEntityRestCache.js"
@ -21,8 +41,7 @@ import { Router } from "../../../common/gui/ScopedRouter.js"
import { ListFetchResult } from "../../../common/gui/base/ListUtils.js"
import { EntityUpdateData, isUpdateForTypeRef } from "../../../common/api/common/utils/EntityUpdateUtils.js"
import { EventController } from "../../../common/api/main/EventController.js"
import { assertSystemFolderOfType, getMailFilterForType, MailFilterType } from "../../../common/mailFunctionality/SharedMailUtils.js"
import { isOfTypeOrSubfolderOf } from "../../../common/mailFunctionality/SharedMailUtils.js"
import { assertSystemFolderOfType, getMailFilterForType, isOfTypeOrSubfolderOf, MailFilterType } from "../../../common/mailFunctionality/SharedMailUtils.js"
import { isSpamOrTrashFolder, isSubfolderOfType } from "../../../common/api/common/CommonMailUtils.js"
export interface MailOpenedListener {
@ -31,7 +50,7 @@ export interface MailOpenedListener {
/** ViewModel for the overall mail view. */
export class MailViewModel {
private _listId: Id | null = null
private _folder: MailFolder | null = null
/** id of the mail we are trying to load based on the URL */
private targetMailId: Id | null = null
/** needed to prevent parallel target loads*/
@ -43,7 +62,7 @@ export class MailViewModel {
* We remember the last URL used for each folder so if we switch between folders we can keep the selected mail.
* There's a similar (but different) hacky mechanism where we store last URL but per each top-level view: navButtonRoutes. This one is per folder.
*/
private mailListToSelectedMail: ReadonlyMap<Id, Id> = new Map()
private mailFolderToSelectedMail: ReadonlyMap<MailFolder, Id> = new Map()
private listStreamSubscription: Stream<unknown> | null = null
private conversationPref: boolean = false
@ -70,9 +89,14 @@ export class MailViewModel {
this.listModel?.setFilter(getMailFilterForType(filter))
}
async showMail(listId?: Id, mailId?: Id) {
async showMailWithFolderId(folderId?: Id, mailId?: Id): Promise<void> {
const folder: MailFolder | null = folderId ? (await this.getMailboxDetails()).folders.getFolderById(folderId) : null
return this.showMail(folder, mailId)
}
async showMail(folder?: MailFolder | null, mailId?: Id) {
// an optimization to not open an email that we already display
if (listId != null && mailId != null && this.conversationViewModel && isSameId(this.conversationViewModel.primaryMail._id, [listId, mailId])) {
if (folder != null && mailId != null && this.conversationViewModel && isSameId(elementIdPart(this.conversationViewModel.primaryMail._id), mailId)) {
return
}
@ -81,26 +105,26 @@ export class MailViewModel {
// target URL
this.targetMailId = typeof mailId === "string" ? mailId : null
let listIdToUse
if (typeof listId === "string") {
const mailboxDetail = await this.mailModel.getMailboxDetailsForMailListId(listId)
let folderToUse
if (folder) {
const mailboxDetail = await this.mailModel.getMailboxDetailsForMailFolder(folder)
if (mailboxDetail) {
listIdToUse = listId
folderToUse = folder
} else {
listIdToUse = await this.getListIdForUserInbox()
folderToUse = await this.getFolderForUserInbox()
}
} else {
listIdToUse = this._listId ?? (await this.getListIdForUserInbox())
folderToUse = this._folder ?? (await this.getFolderForUserInbox())
}
await this.setListId(listIdToUse)
await this.setListId(folderToUse)
// if there is a target id and we are not loading for this id already then start loading towards that id
if (this.targetMailId && this.targetMailId != this.loadingToTargetId) {
this.mailListToSelectedMail = mapWith(this.mailListToSelectedMail, listIdToUse, this.targetMailId)
this.mailFolderToSelectedMail = mapWith(this.mailFolderToSelectedMail, folderToUse, this.targetMailId)
try {
this.loadingToTargetId = this.targetMailId
await this.loadAndSelectMail([listIdToUse, this.targetMailId])
await this.loadAndSelectMail(folderToUse, this.targetMailId)
} finally {
this.loadingToTargetId = null
this.targetMailId = null
@ -108,16 +132,16 @@ export class MailViewModel {
} else {
// update URL if the view was just opened without any url params
// setListId might not have done it if the list didn't change for us internally but is changed for the view
if (listId == null) this.updateUrl()
if (folder == null) this.updateUrl()
}
}
private async loadAndSelectMail([listId, mailId]: IdTuple) {
private async loadAndSelectMail(folder: MailFolder, mailId: Id) {
const foundMail = await this.listModel?.loadAndSelect(
mailId,
() =>
// if we changed the list, stop
this.getListId() !== listId ||
this.getFolder() !== folder ||
// if listModel is gone for some reason, stop
!this.listModel ||
// if the target mail has changed, stop
@ -126,13 +150,13 @@ export class MailViewModel {
(this.listModel.state.items.length > 0 && firstBiggerThanSecond(mailId, getElementId(lastThrow(this.listModel.state.items)))),
)
if (foundMail == null) {
console.log("did not find mail", listId, mailId)
console.log("did not find mail", folder, mailId)
}
}
private async getListIdForUserInbox(): Promise<Id> {
private async getFolderForUserInbox(): Promise<MailFolder> {
const mailboxDetail = await this.mailModel.getUserMailboxDetails()
return assertSystemFolderOfType(mailboxDetail.folders, MailFolderType.INBOX).mails
return assertSystemFolderOfType(mailboxDetail.folders, MailSetKind.INBOX)
}
init() {
@ -155,26 +179,26 @@ export class MailViewModel {
})
get listModel(): ListModel<Mail> | null {
return this._listId ? this._listModel(this._listId) : null
return this._folder ? this.listModelForFolder(getElementId(this._folder)) : null
}
getMailListToSelectedMail(): ReadonlyMap<Id, Id> {
return this.mailListToSelectedMail
getMailFolderToSelectedMail(): ReadonlyMap<MailFolder, Id> {
return this.mailFolderToSelectedMail
}
getListId(): Id | null {
return this._listId
getFolder(): MailFolder | null {
return this._folder
}
private async setListId(id: Id) {
if (id === this._listId) {
private async setListId(folder: MailFolder) {
if (folder === this._folder) {
return
}
// Cancel old load all
this.listModel?.cancelLoadAll()
this._filterType = null
this._listId = id
this._folder = folder
this.listStreamSubscription?.end(true)
this.listStreamSubscription = this.listModel!.stateStream.map((state) => this.onListStateChange(state))
await this.listModel!.loadInitial()
@ -184,49 +208,53 @@ export class MailViewModel {
return this.conversationViewModel
}
private _listModel = memoized((listId: Id) => {
private listModelForFolder = memoized((folderId: Id) => {
return new ListModel<Mail>({
topId: GENERATED_MAX_ID,
fetch: async (startId, count) => {
const { complete, items } = await this.loadMailRange(listId, startId, count)
const folder = assertNotNull(this._folder)
const { complete, items } = await this.loadMailRange(folder, startId, count)
if (complete) {
this.fixCounterIfNeeded(listId, [])
this.fixCounterIfNeeded(folder, [])
}
return { complete, items }
},
loadSingle: (elementId: Id): Promise<Mail | null> => this.entityClient.load(MailTypeRef, [listId, elementId]),
loadSingle: (listId: Id, elementId: Id): Promise<Mail | null> => {
return this.entityClient.load(MailTypeRef, [listId, elementId])
},
sortCompare: sortCompareByReverseId,
autoSelectBehavior: () => this.conversationPrefProvider.getMailAutoSelectBehavior(),
})
})
private fixCounterIfNeeded: (listId: Id, itemsWhenCalled: ReadonlyArray<Mail>) => void = debounce(
private fixCounterIfNeeded: (folder: MailFolder, itemsWhenCalled: ReadonlyArray<Mail>) => void = debounce(
2000,
async (listId: Id, itemsWhenCalled: ReadonlyArray<Mail>) => {
if (this._filterType != null && this.filterType !== MailFilterType.Unread) {
async (folder: MailFolder, itemsWhenCalled: ReadonlyArray<Mail>) => {
const ourFolder = this.getFolder()
if (ourFolder == null || (this._filterType != null && this.filterType !== MailFilterType.Unread)) {
return
}
// If folders are changed, list won't have the data we need.
// Do not rely on counters if we are not connected
if (this.getListId() !== listId || this.connectivityModel.wsConnection()() !== WsConnectionState.connected) {
if (!isSameId(getElementId(ourFolder), getElementId(folder)) || this.connectivityModel.wsConnection()() !== WsConnectionState.connected) {
return
}
// If list was modified in the meantime, we cannot be sure that we will fix counters correctly (e.g. because of the inbox rules)
if (this.listModel?.state.items !== itemsWhenCalled) {
console.log(`list changed, trying again later`)
return this.fixCounterIfNeeded(listId, this.listModel?.state.items ?? [])
return this.fixCounterIfNeeded(folder, this.listModel?.state.items ?? [])
}
const unreadMailsCount = count(this.listModel.state.items, (e) => e.unread)
const counterValue = await this.mailModel.getCounterValue(listId)
const counterValue = await this.mailModel.getCounterValue(folder)
if (counterValue != null && counterValue !== unreadMailsCount) {
console.log(`fixing up counter for list ${listId}`)
await this.mailModel.fixupCounterForMailList(listId, unreadMailsCount)
console.log(`fixing up counter for folder ${folder._id}`)
await this.mailModel.fixupCounterForFolder(folder, unreadMailsCount)
} else {
console.log(`same counter, no fixup on list ${listId}`)
console.log(`same counter, no fixup on folder ${folder._id}`)
}
},
)
@ -235,7 +263,7 @@ export class MailViewModel {
if (!newState.inMultiselect && newState.selectedItems.size === 1) {
const mail = this.listModel!.getSelectedAsArray()[0]
if (!this.conversationViewModel || !isSameId(this.conversationViewModel?.primaryMail._id, mail._id)) {
this.mailListToSelectedMail = mapWith(this.mailListToSelectedMail, assertNotNull(this.getListId()), getElementId(mail))
this.mailFolderToSelectedMail = mapWith(this.mailFolderToSelectedMail, assertNotNull(this.getFolder()), getElementId(mail))
this.createConversationViewModel({
mail,
@ -246,19 +274,20 @@ export class MailViewModel {
} else {
this.conversationViewModel?.dispose()
this.conversationViewModel = null
this.mailListToSelectedMail = mapWithout(this.mailListToSelectedMail, assertNotNull(this.getListId()))
this.mailFolderToSelectedMail = mapWithout(this.mailFolderToSelectedMail, assertNotNull(this.getFolder()))
}
this.updateUrl()
this.updateUi()
}
private updateUrl() {
const listId = this._listId
const mailId = this.targetMailId ?? (listId ? this.getMailListToSelectedMail().get(listId) : null)
const folder = this._folder
const folderId = folder ? getElementId(folder) : null
const mailId = this.targetMailId ?? (folder ? this.getMailFolderToSelectedMail().get(folder) : null)
if (mailId != null) {
this.router.routeTo("/mail/:listId/:mailId", { listId, mailId })
this.router.routeTo("/mail/:folderId/:mailId", { folderId, mailId })
} else {
this.router.routeTo("/mail/:listId", { listId: listId ?? "" })
this.router.routeTo("/mail/:folderId", { folderId: folderId ?? "" })
}
}
@ -267,18 +296,120 @@ export class MailViewModel {
this.conversationViewModel = this.conversationViewModelFactory(viewModelParams)
}
async entityEventsReceived(updates: ReadonlyArray<EntityUpdateData>) {
async entityEventsReceivedForLegacy(updates: ReadonlyArray<EntityUpdateData>) {
for (const update of updates) {
if (isUpdateForTypeRef(MailTypeRef, update) && update.instanceListId === this._listId) {
await this.listModel?.entityEventReceived(update.instanceId, update.operation)
if (isUpdateForTypeRef(MailTypeRef, update) && update.instanceListId === this._folder?.mails) {
await this.listModel?.entityEventReceived(update.instanceListId, update.instanceId, update.operation)
}
}
}
private async loadMailRange(listId: Id, start: Id, count: number): Promise<ListFetchResult<Mail>> {
async entityEventsReceived(updates: ReadonlyArray<EntityUpdateData>) {
const folder = this._folder
const listModel = this.listModel
if (!folder || !listModel) {
return
}
if (!folder.isMailSet) {
return this.entityEventsReceivedForLegacy(updates)
}
let [mailEvent, oldEntryEvent, newEntryEvent]: EntityUpdateData[] = []
for (const event of updates) {
if (isUpdateForTypeRef(MailTypeRef, event)) {
mailEvent = event
} else if (isUpdateForTypeRef(MailSetEntryTypeRef, event)) {
if (event.operation == OperationType.DELETE) {
oldEntryEvent = event
} else {
newEntryEvent = event
}
}
}
if (isSameId(folder.entries, newEntryEvent?.instanceListId)) {
await this.listModel?.entityEventReceived(mailEvent.instanceListId, mailEvent.instanceId, OperationType.CREATE)
} else if (isSameId(folder.entries, oldEntryEvent?.instanceListId)) {
await this.listModel?.entityEventReceived(mailEvent.instanceListId, mailEvent.instanceId, OperationType.DELETE)
} else if (mailEvent && !oldEntryEvent && !newEntryEvent) {
const mail = await this.entityClient.load(MailTypeRef, [mailEvent.instanceListId, mailEvent.instanceId])
if (mail.sets.some((id) => isSameId(elementIdPart(id), getElementId(folder)))) {
await this.listModel?.entityEventReceived(mailEvent.instanceListId, mailEvent.instanceId, mailEvent.operation)
}
}
}
private async loadMailRange(folder: MailFolder, start: Id, count: number): Promise<ListFetchResult<Mail>> {
if (folder.isMailSet) {
return await this.loadMailSetMailRange(folder, start, count)
} else {
return await this.loadLegacyMailRange(folder, start, count)
}
}
private async loadMailSetMailRange(folder: MailFolder, start: string, count: number) {
try {
const loadMailSetEntries = () => this.entityClient.loadRange(MailSetEntryTypeRef, folder.entries, start, count, true)
const loadMails = (listId: Id, mailIds: Array<Id>) => this.entityClient.loadMultiple(MailTypeRef, listId, mailIds)
const mails = await this.acquireMails(loadMailSetEntries, loadMails)
const mailboxDetail = await this.mailModel.getMailboxDetailsForMailFolder(folder)
// For inbox rules there are two points where we might want to apply them. The first one is MailModel which applied inbox rules as they are received
// in real time. The second one is here, when we load emails in inbox. If they are unread we want to apply inbox rules to them. If inbox rule
// applies, the email is moved out of the inbox and we don't return it here.
if (mailboxDetail) {
const mailsToKeepInInbox = await promiseFilter(mails, async (mail) => {
const wasMatched = await this.inboxRuleHandler.findAndApplyMatchingRule(mailboxDetail, mail, true)
return !wasMatched
})
return { items: mailsToKeepInInbox, complete: mails.length < count }
} else {
return { items: mails, complete: mails.length < count }
}
} catch (e) {
// The way the cache works is that it tries to fulfill the API contract of returning as many items as requested as long as it can.
// This is problematic for offline where we might not have the full page of emails loaded (e.g. we delete part as it's too old or we move emails
// around). Because of that cache will try to load additional items from the server in order to return `count` items. If it fails to load them,
// it will not return anything and instead will throw an error.
// This is generally fine but in case of offline we want to display everything that we have cached. For that we fetch directly from the cache,
// give it to the list and let list make another request (and almost certainly fail that request) to show a retry button. This way we both show
// the items we have and also show that we couldn't load everything.
if (isOfflineError(e)) {
const loadMailSetEntries = () => this.cacheStorage.provideFromRange(MailSetEntryTypeRef, folder.entries, start, count, true)
const loadMails = (listId: Id, mailIds: Array<Id>) => this.cacheStorage.provideMultiple(MailTypeRef, listId, mailIds)
const items = await this.acquireMails(loadMailSetEntries, loadMails)
if (items.length === 0) throw e
return { items, complete: false }
} else {
throw e
}
}
}
/**
* Load mails either from remote or from offline storage. Loader functions must be implemented for each use case.
*/
private async acquireMails(loadMailSetEntries: () => Promise<MailSetEntry[]>, loadMails: (listId: Id, mailIds: Array<Id>) => Promise<Mail[]>) {
const mailSetEntries = await loadMailSetEntries()
const mailListIdToMailIds = groupByAndMap(
mailSetEntries,
(mse) => listIdPart(mse.mail),
(mse) => elementIdPart(mse.mail),
)
const mails: Array<Mail> = []
for (const [listId, mailIds] of mailListIdToMailIds) {
mails.push(...(await loadMails(listId, mailIds)))
}
mails.sort((a, b) => b.receivedDate.getTime() - a.receivedDate.getTime())
return mails
}
private async loadLegacyMailRange(folder: MailFolder, start: string, count: number) {
const listId = folder.mails
try {
const items = await this.entityClient.loadRange(MailTypeRef, listId, start, count, true)
const mailboxDetail = await this.mailModel.getMailboxDetailsForMailListId(listId)
const mailboxDetail = await this.mailModel.getMailboxDetailsForMailFolder(folder)
// For inbox rules there are two points where we might want to apply them. The first one is MailModel which applied inbox rules as they are received
// in real time. The second one is here, when we load emails in inbox. If they are unread we want to apply inbox rules to them. If inbox rule
// applies, the email is moved out of the inbox and we don't return it here.
@ -309,46 +440,39 @@ export class MailViewModel {
}
}
async switchToFolder(folderType: Omit<MailFolderType, MailFolderType.CUSTOM>): Promise<void> {
async switchToFolder(folderType: Omit<MailSetKind, MailSetKind.CUSTOM>): Promise<void> {
const mailboxDetail = assertNotNull(await this.getMailboxDetails())
const listId = assertSystemFolderOfType(mailboxDetail.folders, folderType).mails
await this.showMail(listId, this.mailListToSelectedMail.get(listId))
const folder = assertSystemFolderOfType(mailboxDetail.folders, folderType)
await this.showMail(folder, this.mailFolderToSelectedMail.get(folder))
}
async getMailboxDetails(): Promise<MailboxDetail> {
const listId = this.getListId()
return await this.mailboxDetailForListWithFallback(listId)
}
getSelectedFolder(): MailFolder | null {
const listId = this.getListId()
return listId ? this.mailModel.getMailFolder(listId) : null
const folder = this.getFolder()
return await this.mailboxDetailForListWithFallback(folder)
}
async showingDraftsFolder(): Promise<boolean> {
if (!this._listId) return false
const mailboxDetail = await this.mailModel.getMailboxDetailsForMailListId(this._listId)
const selectedFolder = this.getSelectedFolder()
if (!this._folder) return false
const mailboxDetail = await this.mailModel.getMailboxDetailsForMailFolder(this._folder)
const selectedFolder = this.getFolder()
if (selectedFolder && mailboxDetail) {
return isOfTypeOrSubfolderOf(mailboxDetail.folders, selectedFolder, MailFolderType.DRAFT)
return isOfTypeOrSubfolderOf(mailboxDetail.folders, selectedFolder, MailSetKind.DRAFT)
} else {
return false
}
}
async showingTrashOrSpamFolder(): Promise<boolean> {
const listId = this._listId
if (!listId) return false
const folder = await this.mailModel.getMailFolder(listId)
const folder = this.getFolder()
if (!folder) {
return false
}
const mailboxDetail = await this.mailModel.getMailboxDetailsForMailListId(listId)
const mailboxDetail = await this.mailModel.getMailboxDetailsForMailFolder(folder)
return mailboxDetail != null && isSpamOrTrashFolder(mailboxDetail.folders, folder)
}
private async mailboxDetailForListWithFallback(listId?: string | null) {
const mailboxDetailForListId = typeof listId === "string" ? await this.mailModel.getMailboxDetailsForMailListId(listId) : null
private async mailboxDetailForListWithFallback(folder?: MailFolder | null) {
const mailboxDetailForListId = folder ? await this.mailModel.getMailboxDetailsForMailFolder(folder) : null
return mailboxDetailForListId ?? (await this.mailModel.getUserMailboxDetails())
}
@ -359,16 +483,13 @@ export class MailViewModel {
const mailboxDetail = await this.getMailboxDetails()
// the request is handled a little differently if it is the system folder vs a subfolder
if (folder.folderType === MailFolderType.TRASH || folder.folderType === MailFolderType.SPAM) {
if (folder.folderType === MailSetKind.TRASH || folder.folderType === MailSetKind.SPAM) {
return this.mailModel.clearFolder(folder).catch(
ofClass(PreconditionFailedError, () => {
throw new UserError("operationStillActive_msg")
}),
)
} else if (
isSubfolderOfType(mailboxDetail.folders, folder, MailFolderType.TRASH) ||
isSubfolderOfType(mailboxDetail.folders, folder, MailFolderType.SPAM)
) {
} else if (isSubfolderOfType(mailboxDetail.folders, folder, MailSetKind.TRASH) || isSubfolderOfType(mailboxDetail.folders, folder, MailSetKind.SPAM)) {
return this.mailModel.finallyDeleteCustomMailFolder(folder).catch(
ofClass(PreconditionFailedError, () => {
throw new UserError("operationStillActive_msg")

View file

@ -2,7 +2,7 @@ import { px, size } from "../../../common/gui/size"
import m, { Children, Component, Vnode } from "mithril"
import stream from "mithril/stream"
import { windowFacade, windowSizeListener } from "../../../common/misc/WindowFacade"
import { FeatureType, InboxRuleType, Keys, MailFolderType, SpamRuleFieldType, SpamRuleType } from "../../../common/api/common/TutanotaConstants"
import { FeatureType, InboxRuleType, Keys, MailSetKind, SpamRuleFieldType, SpamRuleType } from "../../../common/api/common/TutanotaConstants"
import { File as TutanotaFile, Mail } from "../../../common/api/entities/tutanota/TypeRefs.js"
import { lang } from "../../../common/misc/LanguageViewModel"
import { assertMainOrNode } from "../../../common/api/common/Env"
@ -20,7 +20,6 @@ import { replaceCidsWithInlineImages } from "./MailGuiUtils"
import { getCoordsOfMouseOrTouchEvent } from "../../../common/gui/base/GuiUtils"
import { copyToClipboard } from "../../../common/misc/ClipboardUtils"
import { ContentBlockingStatus, MailViewerViewModel } from "./MailViewerViewModel"
import { getListId } from "../../../common/api/common/utils/EntityUtils"
import { createEmailSenderListElement } from "../../../common/api/entities/sys/TypeRefs.js"
import { UserError } from "../../../common/api/main/UserError"
import { showUserError } from "../../../common/misc/ErrorHandlerImpl"
@ -33,8 +32,7 @@ import { locator } from "../../../common/api/main/CommonLocator.js"
import { PinchZoom } from "../../../common/gui/PinchZoom.js"
import { responsiveCardHMargin, responsiveCardHPadding } from "../../../common/gui/cards.js"
import { Dialog } from "../../../common/gui/base/Dialog.js"
import { createNewContact, getExistingRuleForType } from "../../../common/mailFunctionality/SharedMailUtils.js"
import { isTutanotaTeamMail } from "../../../common/mailFunctionality/SharedMailUtils.js"
import { createNewContact, getExistingRuleForType, isTutanotaTeamMail } from "../../../common/mailFunctionality/SharedMailUtils.js"
assertMainOrNode()
@ -655,9 +653,9 @@ export class MailViewer implements Component<MailViewerAttrs> {
}
private addSpamRule(defaultInboxRuleField: InboxRuleType | null, address: string) {
const folder = this.viewModel.mailModel.getMailFolder(getListId(this.viewModel.mail))
const folder = this.viewModel.mailModel.getMailFolderForMail(this.viewModel.mail)
const spamRuleType = folder && folder.folderType === MailFolderType.SPAM ? SpamRuleType.WHITELIST : SpamRuleType.BLACKLIST
const spamRuleType = folder && folder.folderType === MailSetKind.SPAM ? SpamRuleType.WHITELIST : SpamRuleType.BLACKLIST
let spamRuleField: SpamRuleFieldType
switch (defaultInboxRuleField) {

View file

@ -13,10 +13,10 @@ import {
ExternalImageRule,
FeatureType,
MailAuthenticationStatus,
MailFolderType,
MailMethod,
MailPhishingStatus,
MailReportType,
MailSetKind,
MailState,
OperationType,
} from "../../../common/api/common/TutanotaConstants"
@ -42,7 +42,7 @@ import { lang } from "../../../common/misc/LanguageViewModel"
import { LoginController } from "../../../common/api/main/LoginController"
import m from "mithril"
import { LockedError, NotAuthorizedError, NotFoundError } from "../../../common/api/common/error/RestError"
import { getListId, haveSameId, isSameId } from "../../../common/api/common/utils/EntityUtils"
import { haveSameId, isSameId } from "../../../common/api/common/utils/EntityUtils"
import { getReferencedAttachments, loadInlineImages, moveMails } from "./MailGuiUtils"
import { SanitizedFragment } from "../../../common/misc/HtmlSanitizer"
import { CALENDAR_MIME_TYPE, FileController } from "../../../common/file/FileController"
@ -69,16 +69,18 @@ import { AttachmentType, getAttachmentType } from "../../../common/gui/Attachmen
import type { ContactImporter } from "../../contacts/ContactImporter.js"
import { InlineImages, revokeInlineImages } from "../../../common/mailFunctionality/inlineImagesUtils.js"
import {
getPathToFolderString,
assertSystemFolderOfType,
getDefaultSender,
getEnabledMailAddressesWithUser,
getMailboxName,
getFolderName,
getMailboxName,
getPathToFolderString,
isNoReplyTeamAddress,
isSystemNotification,
isTutanotaTeamMail,
loadMailDetails,
loadMailHeaders,
getDefaultSender,
assertSystemFolderOfType,
} from "../../../common/mailFunctionality/SharedMailUtils.js"
import { isSystemNotification, isTutanotaTeamMail, isNoReplyTeamAddress } from "../../../common/mailFunctionality/SharedMailUtils.js"
import { getDisplayedSender, getMailBodyText, MailAddressAndName } from "../../../common/api/common/CommonMailUtils.js"
export const enum ContentBlockingStatus {
@ -198,7 +200,7 @@ export class MailViewerViewModel {
private showFolder() {
this.folderMailboxText = null
const folder = this.mailModel.getMailFolder(this.mail._id[0])
const folder = this.mailModel.getMailFolderForMail(this.mail)
if (folder) {
this.mailModel.getMailboxDetailsForMail(this.mail).then((mailboxDetails) => {
@ -310,10 +312,10 @@ export class MailViewerViewModel {
return this.folderMailboxText
}
getFolderInfo(): { folderType: MailFolderType; name: string } | null {
const folder = this.mailModel.getMailFolder(getListId(this.mail))
getFolderInfo(): { folderType: MailSetKind; name: string } | null {
const folder = this.mailModel.getMailFolderForMail(this.mail)
if (!folder) return null
return { folderType: folder.folderType as MailFolderType, name: getFolderName(folder) }
return { folderType: folder.folderType as MailSetKind, name: getFolderName(folder) }
}
getSubject(): string {
@ -512,7 +514,7 @@ export class MailViewerViewModel {
if (mailboxDetail == null) {
return
}
const spamFolder = assertSystemFolderOfType(mailboxDetail.folders, MailFolderType.SPAM)
const spamFolder = assertSystemFolderOfType(mailboxDetail.folders, MailSetKind.SPAM)
// do not report moved mails again
await moveMails({ mailModel: this.mailModel, mails: [this.mail], targetMailFolder: spamFolder, isReportable: false })
} catch (e) {

View file

@ -153,7 +153,7 @@ export class SearchModel {
continue
}
if (restriction.listIds.length > 0 && !restriction.listIds.includes(listIdPart(event._id))) {
if (restriction.folderIds.length > 0 && !restriction.folderIds.includes(listIdPart(event._id))) {
// check that the event is in the searched calendar.
continue
}
@ -255,7 +255,7 @@ export function isSameSearchRestriction(a: SearchRestriction, b: SearchRestricti
a.field === b.field &&
isSameAttributeIds &&
(a.eventSeries === b.eventSeries || (a.eventSeries === null && b.eventSeries === true) || (a.eventSeries === true && b.eventSeries === null)) &&
arrayEquals(a.listIds, b.listIds)
arrayEquals(a.folderIds, b.folderIds)
)
}

View file

@ -139,8 +139,8 @@ export function getSearchParameters(
if (restriction.end) {
params.end = restriction.end
}
if (restriction.listIds.length > 0) {
params.list = restriction.listIds
if (restriction.folderIds.length > 0) {
params.folder = restriction.folderIds
}
if (restriction.field) {
params.field = restriction.field
@ -167,14 +167,14 @@ export function createRestriction(
start: number | null,
end: number | null,
field: string | null,
listIds: Array<string>,
folderIds: Array<string>,
eventSeries: boolean | null,
): SearchRestriction {
if (locator.logins.getUserController().isFreeAccount() && searchCategory === SearchCategoryTypes.mail) {
start = null
end = getFreeSearchStartDate().getTime()
field = null
listIds = []
folderIds = []
eventSeries = null
}
@ -184,7 +184,7 @@ export function createRestriction(
end: end,
field: null,
attributeIds: null,
listIds,
folderIds,
eventSeries,
}
@ -226,7 +226,7 @@ export function getRestriction(route: string): SearchRestriction {
let start: number | null = null
let end: number | null = null
let field: string | null = null
let listIds: Array<string> = []
let folderIds: Array<string> = []
let eventSeries: boolean | null = null
if (route.startsWith("/mail") || route.startsWith("/search/mail")) {
@ -249,8 +249,8 @@ export function getRestriction(route: string): SearchRestriction {
field = SEARCH_MAIL_FIELDS.find((f) => f.field === fieldString)?.field ?? null
}
if (Array.isArray(params["list"])) {
listIds = params["list"]
if (Array.isArray(params["folder"])) {
folderIds = params["folder"]
}
} catch (e) {
console.log("invalid query: " + route, e)
@ -274,9 +274,9 @@ export function getRestriction(route: string): SearchRestriction {
end = filterInt(params["end"])
}
const list = params["list"]
if (Array.isArray(list)) {
listIds = list
const folder = params["folder"]
if (Array.isArray(folder)) {
folderIds = folder
}
} catch (e) {
console.log("invalid query: " + route, e)
@ -298,7 +298,7 @@ export function getRestriction(route: string): SearchRestriction {
throw new Error("invalid type " + route)
}
return createRestriction(category, start, end, field, listIds, eventSeries)
return createRestriction(category, start, end, field, folderIds, eventSeries)
}
export function decodeCalendarSearchKey(searchKey: string): { id: Id; start: number } {

View file

@ -3,7 +3,7 @@ import { ViewSlider } from "../../../common/gui/nav/ViewSlider.js"
import { ColumnType, ViewColumn } from "../../../common/gui/base/ViewColumn"
import type { TranslationKey } from "../../../common/misc/LanguageViewModel"
import { lang } from "../../../common/misc/LanguageViewModel"
import { FeatureType, Keys, MailFolderType } from "../../../common/api/common/TutanotaConstants"
import { FeatureType, Keys, MailSetKind } from "../../../common/api/common/TutanotaConstants"
import { assertMainOrNode } from "../../../common/api/common/Env"
import { keyManager, Shortcut } from "../../../common/misc/KeyManager"
import { NavButton, NavButtonColor } from "../../../common/gui/base/NavButton.js"
@ -648,11 +648,12 @@ export class SearchView extends BaseTopLevelView implements TopLevelView<SearchV
const mailboxIndex = mailboxes.indexOf(mailbox)
const mailFolders = mailbox.folders.getIndentedList()
for (const folderInfo of mailFolders) {
if (folderInfo.folder.folderType !== MailFolderType.SPAM) {
if (folderInfo.folder.folderType !== MailSetKind.SPAM) {
const mailboxLabel = mailboxIndex === 0 ? "" : ` (${getGroupInfoDisplayName(mailbox.mailGroupInfo)})`
const folderId = folderInfo.folder.isMailSet ? getElementId(folderInfo.folder) : folderInfo.folder.mails
availableMailFolders.push({
name: getIndentedFolderNameForDropdown(folderInfo) + mailboxLabel,
value: folderInfo.folder.mails,
value: folderId,
})
}
}

View file

@ -4,7 +4,7 @@ import { SearchRestriction, SearchResult } from "../../../common/api/worker/sear
import { EntityEventsListener, EventController } from "../../../common/api/main/EventController.js"
import { CalendarEvent, CalendarEventTypeRef, Contact, ContactTypeRef, Mail, MailTypeRef } from "../../../common/api/entities/tutanota/TypeRefs.js"
import { ListElementEntity, SomeEntity } from "../../../common/api/common/EntityTypes.js"
import { FULL_INDEXED_TIMESTAMP, MailFolderType, NOTHING_INDEXED_TIMESTAMP, OperationType } from "../../../common/api/common/TutanotaConstants.js"
import { FULL_INDEXED_TIMESTAMP, MailSetKind, NOTHING_INDEXED_TIMESTAMP, OperationType } from "../../../common/api/common/TutanotaConstants.js"
import {
assertIsEntity,
assertIsEntity2,
@ -216,7 +216,7 @@ export class SearchViewModel {
}
private listIdMatchesRestriction(listId: string, restriction: SearchRestriction): boolean {
return restriction.listIds.length === 0 || restriction.listIds.includes(listId)
return restriction.folderIds.length === 0 || restriction.folderIds.includes(listId)
}
onNewUrl(args: Record<string, any>, requestedPath: string) {
@ -279,13 +279,13 @@ export class SearchViewModel {
this.selectedMailField = restriction.field
this.startDate = restriction.end ? new Date(restriction.end) : null
this.endDate = restriction.start ? new Date(restriction.start) : null
this.selectedMailFolder = restriction.listIds
this.selectedMailFolder = restriction.folderIds
this.loadAndSelectIfNeeded(args.id)
this.latestMailRestriction = restriction
} else if (isSameTypeRef(restriction.type, CalendarEventTypeRef)) {
this.startDate = restriction.start ? new Date(restriction.start) : null
this.endDate = restriction.end ? new Date(restriction.end) : null
this.selectedCalendar = this.extractCalendarListIds(restriction.listIds)
this.selectedCalendar = this.extractCalendarListIds(restriction.folderIds)
this.includeRepeatingEvents = restriction.eventSeries ?? true
this.lazyCalendarInfos.load()
this.latestCalendarRestriction = restriction
@ -555,8 +555,8 @@ export class SearchViewModel {
// if selected folder no longer exist select another one
const selectedMailFolder = this.selectedMailFolder
if (selectedMailFolder[0] && mailboxes.every((mailbox) => mailbox.folders.getFolderByMailListId(selectedMailFolder[0]) == null)) {
this.selectedMailFolder = [assertNotNull(mailboxes[0].folders.getSystemFolderByType(MailFolderType.INBOX)).mails]
if (selectedMailFolder[0] && mailboxes.every((mailbox) => mailbox.folders.getFolderById(selectedMailFolder[0]) == null)) {
this.selectedMailFolder = [getElementId(assertNotNull(mailboxes[0].folders.getSystemFolderByType(MailSetKind.INBOX)))]
}
}
@ -578,8 +578,8 @@ export class SearchViewModel {
(email) => update.instanceId === elementIdPart(email) && update.instanceListId !== listIdPart(email),
)
if (index >= 0) {
const restrictionLength = this._searchResult.restriction.listIds.length
if ((restrictionLength > 0 && this._searchResult.restriction.listIds.includes(update.instanceListId)) || restrictionLength === 0) {
const restrictionLength = this._searchResult.restriction.folderIds.length
if ((restrictionLength > 0 && this._searchResult.restriction.folderIds.includes(update.instanceListId)) || restrictionLength === 0) {
// We need to update the listId of the updated item, since it was moved to another folder.
const newIdTuple: IdTuple = [update.instanceListId, update.instanceId]
this._searchResult.results[index] = newIdTuple
@ -608,7 +608,7 @@ export class SearchViewModel {
}
this.listModel.getUnfilteredAsArray()
await this.listModel.entityEventReceived(instanceId, operation)
await this.listModel.entityEventReceived(instanceListId, instanceId, operation)
// run the mail or contact update after the update on the list is finished to avoid parallel loading
if (operation === OperationType.UPDATE && this.listModel?.isItemSelected(elementIdPart(id))) {
try {
@ -705,12 +705,12 @@ export class SearchViewModel {
return { items: entries, complete }
},
loadSingle: async (elementId: Id) => {
loadSingle: async (_listId: Id, elementId: Id) => {
const lastResult = this._searchResult
if (!lastResult) {
return null
}
const id = lastResult.results.find((r) => r[1] === elementId)
const id = lastResult.results.find((resultId) => elementIdPart(resultId) === elementId)
if (id) {
return this.entityClient
.load(lastResult.restriction.type, id)
@ -743,7 +743,7 @@ export class SearchViewModel {
if (result && isSameTypeRef(typeRef, result.restriction.type)) {
// The list id must be null/empty, otherwise the user is filtering by list, and it shouldn't be ignored
const ignoreList = isSameTypeRef(typeRef, MailTypeRef) && result.restriction.listIds.length === 0
const ignoreList = isSameTypeRef(typeRef, MailTypeRef) && result.restriction.folderIds.length === 0
return result.results.some((r) => this.compareItemId(r, id, ignoreList))
}

View file

@ -1,7 +1,7 @@
import m from "mithril"
import { Dialog } from "../../common/gui/base/Dialog"
import { lang, TranslationKey } from "../../common/misc/LanguageViewModel"
import { InboxRuleType, MailFolderType } from "../../common/api/common/TutanotaConstants"
import { InboxRuleType, MailSetKind } from "../../common/api/common/TutanotaConstants"
import { isDomainName, isMailAddress, isRegularExpression } from "../../common/misc/FormatValidator"
import { getInboxRuleTypeNameMapping } from "../mail/model/InboxRuleHandler"
import type { InboxRule } from "../../common/api/entities/tutanota/TypeRefs.js"
@ -13,7 +13,7 @@ import { TextField } from "../../common/gui/base/TextField.js"
import { neverNull } from "@tutao/tutanota-utils"
import { LockedError } from "../../common/api/common/error/RestError"
import { showNotAvailableForFreeDialog } from "../../common/misc/SubscriptionDialogs"
import { isSameId } from "../../common/api/common/utils/EntityUtils"
import { elementIdPart, isSameId } from "../../common/api/common/utils/EntityUtils"
import { assertMainOrNode } from "../../common/api/common/Env"
import { locator } from "../../common/api/main/CommonLocator"
import { isOfflineError } from "../../common/api/common/utils/ErrorUtils.js"
@ -41,8 +41,8 @@ export function show(mailBoxDetail: MailboxDetail, ruleOrTemplate: InboxRuleTemp
})
const inboxRuleType = stream(ruleOrTemplate.type)
const inboxRuleValue = stream(ruleOrTemplate.value)
const selectedFolder = ruleOrTemplate.targetFolder == null ? null : mailBoxDetail.folders.getFolderById(ruleOrTemplate.targetFolder)
const inboxRuleTarget = stream(selectedFolder ?? assertSystemFolderOfType(mailBoxDetail.folders, MailFolderType.ARCHIVE))
const selectedFolder = ruleOrTemplate.targetFolder == null ? null : mailBoxDetail.folders.getFolderById(elementIdPart(ruleOrTemplate.targetFolder))
const inboxRuleTarget = stream(selectedFolder ?? assertSystemFolderOfType(mailBoxDetail.folders, MailSetKind.ARCHIVE))
let form = () => [
m(DropDownSelector, {

View file

@ -98,7 +98,7 @@ export class KnowledgeBaseListView implements UpdatableSettingsViewer {
throw new Error("fetch knowledgeBase entry called for specific start id")
}
},
loadSingle: (elementId) => {
loadSingle: (_listId: Id, elementId: Id) => {
return this.entityClient.load<KnowledgeBaseEntry>(KnowledgeBaseEntryTypeRef, [this.getListId(), elementId])
},
autoSelectBehavior: () => ListAutoSelectBehavior.OLDER,
@ -172,7 +172,7 @@ export class KnowledgeBaseListView implements UpdatableSettingsViewer {
async entityEventsReceived(updates: ReadonlyArray<EntityUpdateData>): Promise<any> {
for (const update of updates) {
if (isUpdateForTypeRef(KnowledgeBaseEntryTypeRef, update) && isSameId(this.getListId(), update.instanceListId)) {
await this.listModel.entityEventReceived(update.instanceId, update.operation)
await this.listModel.entityEventReceived(update.instanceListId, update.instanceId, update.operation)
}
}

View file

@ -48,6 +48,7 @@ import { EntityUpdateData, isUpdateForTypeRef } from "../../common/api/common/ut
import { getDefaultSenderFromUser, getFolderName, getMailAddressDisplayText } from "../../common/mailFunctionality/SharedMailUtils.js"
import { UpdatableSettingsViewer } from "../../common/settings/Interfaces.js"
import { mailLocator } from "../mailLocator.js"
import { elementIdPart } from "../../common/api/common/utils/EntityUtils.js"
assertMainOrNode()
@ -479,7 +480,7 @@ export class MailSettingsViewer implements UpdatableSettingsViewer {
}
_getTextForTarget(mailboxDetail: MailboxDetail, targetFolderId: IdTuple): string {
let folder = mailboxDetail.folders.getFolderById(targetFolderId)
let folder = mailboxDetail.folders.getFolderById(elementIdPart(targetFolderId))
if (folder) {
return getFolderName(folder)

View file

@ -821,6 +821,7 @@ function showRenameTemplateListDialog(instance: TemplateGroupInstance) {
group: getEtId(instance.group),
color: "",
name: newName,
defaultAlarmsList: [],
})
logins.getUserController().userSettingsGroupRoot.groupSettings.push(newSettings)
}

View file

@ -97,7 +97,7 @@ export class TemplateListView implements UpdatableSettingsViewer {
throw new Error("fetch template entry called for specific start id")
}
},
loadSingle: (elementId) => {
loadSingle: (_listId: Id, elementId: Id) => {
return this.entityClient.load<EmailTemplate>(EmailTemplateTypeRef, [this.templateListId(), elementId])
},
autoSelectBehavior: () => ListAutoSelectBehavior.OLDER,
@ -170,8 +170,9 @@ export class TemplateListView implements UpdatableSettingsViewer {
async entityEventsReceived(updates: ReadonlyArray<EntityUpdateData>): Promise<void> {
for (const update of updates) {
if (isUpdateForTypeRef(EmailTemplateTypeRef, update) && isSameId(this.templateListId(), update.instanceListId)) {
await this.listModel.entityEventReceived(update.instanceId, update.operation)
const { instanceListId, instanceId, operation } = update
if (isUpdateForTypeRef(EmailTemplateTypeRef, update) && isSameId(this.templateListId(), instanceListId)) {
await this.listModel.entityEventReceived(instanceListId, instanceId, operation)
}
}
// we need to make another search in case items have changed

View file

@ -147,7 +147,7 @@ export class GroupListView implements UpdatableSettingsViewer {
const { instanceListId, instanceId, operation } = update
if (isUpdateForTypeRef(GroupInfoTypeRef, update) && this.listId.getSync() === instanceListId) {
await this.listModel.entityEventReceived(instanceId, operation)
await this.listModel.entityEventReceived(instanceListId, instanceId, operation)
} else if (isUpdateForTypeRef(GroupMemberTypeRef, update)) {
this.localAdminGroupMemberships = locator.logins.getUserController().getLocalAdminGroupMemberships()
this.listModel.reapplyFilter()
@ -171,7 +171,7 @@ export class GroupListView implements UpdatableSettingsViewer {
throw new Error("fetch user group infos called for specific start id")
}
},
loadSingle: async (elementId) => {
loadSingle: async (_listId: Id, elementId: Id) => {
const listId = await this.listId.getAsync()
try {
return await locator.entityClient.load<GroupInfo>(GroupInfoTypeRef, [listId, elementId])

View file

@ -250,7 +250,7 @@ o.spec("EventBusClientTest", function () {
})
o("on counter update it send message to the main thread", async function () {
const counterUpdate = createCounterData({ mailGroupId: "group1", counterValue: 4, listId: "list1" })
const counterUpdate = createCounterData({ mailGroupId: "group1", counterValue: 4, counterId: "list1" })
await ebc.connect(ConnectMode.Initial)
await socket.onmessage?.({
@ -315,9 +315,9 @@ o.spec("EventBusClientTest", function () {
return "entityUpdate;" + JSON.stringify(event)
}
type CounterMessageParams = { mailGroupId: Id; counterValue: number; listId: Id }
type CounterMessageParams = { mailGroupId: Id; counterValue: number; counterId: Id }
function createCounterData({ mailGroupId, counterValue, listId }: CounterMessageParams): WebsocketCounterData {
function createCounterData({ mailGroupId, counterValue, counterId }: CounterMessageParams): WebsocketCounterData {
return createTestEntity(WebsocketCounterDataTypeRef, {
_format: "0",
mailGroup: mailGroupId,
@ -325,7 +325,7 @@ o.spec("EventBusClientTest", function () {
createTestEntity(WebsocketCounterValueTypeRef, {
_id: "counterupdateid",
count: String(counterValue),
mailListId: listId,
counterId,
}),
],
})

View file

@ -8,8 +8,10 @@ import { getDayShifted, getFirstOrThrow, getTypeId, lastThrow, mapNullable, prom
import { DateProvider } from "../../../../../src/common/api/common/DateProvider.js"
import {
BodyTypeRef,
createMailFolderRef,
FileTypeRef,
Mail,
MailBoxTypeRef,
MailDetailsBlob,
MailDetailsBlobTypeRef,
MailDetailsTypeRef,
@ -20,7 +22,7 @@ import { OfflineStorageMigrator } from "../../../../../src/common/api/worker/off
import { InterWindowEventFacadeSendDispatcher } from "../../../../../src/common/native/common/generatedipc/InterWindowEventFacadeSendDispatcher.js"
import * as fs from "node:fs"
import { untagSqlObject } from "../../../../../src/common/api/worker/offline/SqlValue.js"
import { MailFolderType } from "../../../../../src/common/api/common/TutanotaConstants.js"
import { MailSetKind } from "../../../../../src/common/api/common/TutanotaConstants.js"
import { BlobElementEntity, ElementEntity, ListElementEntity, SomeEntity } from "../../../../../src/common/api/common/EntityTypes.js"
import { resolveTypeReference } from "../../../../../src/common/api/common/EntityFunctions.js"
import { Type as TypeId } from "../../../../../src/common/api/common/EntityConstants.js"
@ -53,7 +55,7 @@ const nativePath = buildOptions.sqliteNativePath
const database = "./testdatabase.sqlite"
export const offlineDatabaseTestKey = Uint8Array.from([3957386659, 354339016, 3786337319, 3366334248])
o.spec("OfflineStorage", function () {
o.spec("OfflineStorageDb", function () {
const now = new Date("2022-01-01 00:00:00 UTC")
const timeRangeDays = 10
const userId = "userId"
@ -189,6 +191,29 @@ o.spec("OfflineStorage", function () {
const rangeAfter = await storage.getRangeForList(MailTypeRef, listId)
o(rangeAfter).equals(null)
})
o("provideMultiple", async function () {
const listId = "listId1"
const elementId1 = "id1"
const elementId2 = "id2"
const storableMail1 = createTestEntity(MailTypeRef, { _id: [listId, elementId1] })
const storableMail2 = createTestEntity(MailTypeRef, { _id: [listId, elementId2] })
await storage.init({ userId: elementId1, databaseKey, timeRangeDays, forceNewDatabase: false })
let mails = await storage.provideMultiple(MailTypeRef, listId, [elementId1])
o(mails).deepEquals([])
await storage.put(storableMail1)
mails = await storage.provideMultiple(MailTypeRef, listId, [elementId1, elementId2])
o(mails).deepEquals([storableMail1])
await storage.put(storableMail2)
mails = await storage.provideMultiple(MailTypeRef, listId, [elementId1, elementId2])
o(mails).deepEquals([storableMail1, storableMail2])
})
})
o.spec("BlobElementType", function () {
@ -251,10 +276,13 @@ o.spec("OfflineStorage", function () {
await storage.init({ userId, databaseKey, timeRangeDays, forceNewDatabase: false })
await insertEntity(
createTestEntity(MailFolderTypeRef, { _id: ["mailFolderList", spamFolderId], mails: spamListId, folderType: MailFolderType.SPAM }),
createTestEntity(MailBoxTypeRef, { _id: "mailboxId", currentMailBag: null, folders: createMailFolderRef({ folders: "mailFolderList" }) }),
)
await insertEntity(
createTestEntity(MailFolderTypeRef, { _id: ["mailFolderList", trashFolderId], mails: trashListId, folderType: MailFolderType.TRASH }),
createTestEntity(MailFolderTypeRef, { _id: ["mailFolderList", spamFolderId], mails: spamListId, folderType: MailSetKind.SPAM }),
)
await insertEntity(
createTestEntity(MailFolderTypeRef, { _id: ["mailFolderList", trashFolderId], mails: trashListId, folderType: MailSetKind.TRASH }),
)
})
@ -281,7 +309,13 @@ o.spec("OfflineStorage", function () {
o("modified ranges will be shrunk", async function () {
const upper = offsetId(2)
const lower = offsetId(-2)
await insertEntity(createTestEntity(MailFolderTypeRef, { _id: ["mailFolderListId", "mailFolderId"], mails: listId }))
await insertEntity(
createTestEntity(MailFolderTypeRef, {
_id: ["mailFolderList", "mailFolderId"],
folderType: MailSetKind.INBOX,
mails: listId,
}),
)
await insertRange(MailTypeRef, listId, lower, upper)
// Here we clear the excluded data
@ -349,7 +383,7 @@ o.spec("OfflineStorage", function () {
_id: ["mailFolderList", trashSubfolderId],
parentFolder: ["mailFolderList", trashFolderId],
mails: trashSubfolderListId,
folderType: MailFolderType.CUSTOM,
folderType: MailSetKind.CUSTOM,
}),
)
await insertEntity(spamMail)
@ -409,7 +443,7 @@ o.spec("OfflineStorage", function () {
const afterMailDetails = createTestEntity(MailDetailsBlobTypeRef, { _id: afterMailDetailsId, details: createTestEntity(MailDetailsTypeRef) })
await insertEntity(
createTestEntity(MailFolderTypeRef, { _id: ["mailFolderList", "folderId"], mails: inboxMailList, folderType: MailFolderType.INBOX }),
createTestEntity(MailFolderTypeRef, { _id: ["mailFolderList", "folderId"], mails: inboxMailList, folderType: MailSetKind.INBOX }),
)
await insertEntity(mailBefore)
await insertEntity(mailAfter)
@ -435,7 +469,7 @@ o.spec("OfflineStorage", function () {
const mail2 = createTestEntity(MailTypeRef, { _id: [inboxMailList, offsetId(-3)], mailDetails: mailDetailsId2 })
await insertEntity(
createTestEntity(MailFolderTypeRef, { _id: ["mailFolderList", "folderId"], mails: inboxMailList, folderType: MailFolderType.INBOX }),
createTestEntity(MailFolderTypeRef, { _id: ["mailFolderList", "folderId"], mails: inboxMailList, folderType: MailSetKind.INBOX }),
)
await insertEntity(mail1)
await insertEntity(mail2)
@ -469,7 +503,7 @@ o.spec("OfflineStorage", function () {
})
await insertEntity(
createTestEntity(MailFolderTypeRef, { _id: ["mailFolderList", "folderId"], mails: inboxMailList, folderType: MailFolderType.INBOX }),
createTestEntity(MailFolderTypeRef, { _id: ["mailFolderList", "folderId"], mails: inboxMailList, folderType: MailSetKind.INBOX }),
)
await insertEntity(mailBefore)
await insertEntity(mailAfter)
@ -519,11 +553,17 @@ o.spec("OfflineStorage", function () {
const oldIds = new IdGenerator(offsetId(-5))
const newIds = new IdGenerator(offsetId(5))
const userMailbox = createTestEntity(MailBoxTypeRef, {
_id: "mailboxId",
currentMailBag: null,
folders: createMailFolderRef({ folders: "mailFolderList" }),
})
const inboxListId = oldIds.getNext()
const inboxFolder = createTestEntity(MailFolderTypeRef, {
_id: [userId, oldIds.getNext()],
_id: ["mailFolderList", oldIds.getNext()],
mails: inboxListId,
folderType: MailFolderType.INBOX,
folderType: MailSetKind.INBOX,
})
const { mails: oldInboxMails, mailDetailsBlobs: oldInboxMailDetailsBlobs } = createMailList(
3,
@ -543,9 +583,9 @@ o.spec("OfflineStorage", function () {
const trashListId = oldIds.getNext()
const trashFolder = createTestEntity(MailFolderTypeRef, {
_id: [userId, oldIds.getNext()],
_id: ["mailFolderList", oldIds.getNext()],
mails: trashListId,
folderType: MailFolderType.TRASH,
folderType: MailSetKind.TRASH,
})
const { mails: trashMails, mailDetailsBlobs: trashMailDetailsBlobs } = createMailList(
3,
@ -557,12 +597,13 @@ o.spec("OfflineStorage", function () {
const spamListId = oldIds.getNext()
const spamFolder = createTestEntity(MailFolderTypeRef, {
_id: [userId, oldIds.getNext()],
_id: ["mailFolderList", oldIds.getNext()],
mails: spamListId,
folderType: MailFolderType.SPAM,
folderType: MailSetKind.SPAM,
})
const everyEntity = [
userMailbox,
inboxFolder,
trashFolder,
spamFolder,

View file

@ -388,7 +388,6 @@ o.spec("MailIndexer test", () => {
const indexer = mock(new MailIndexer(null as any, db, null as any, null as any, null as any, dateProvider, mailFacade), (mocked) => {
mocked.indexMailboxes = spy(() => Promise.resolve())
mocked.mailIndexingEnabled = false
mocked._excludedListIds = []
mocked._getSpamFolder = (membership) => {
o(membership).deepEquals(user.memberships[0])
@ -398,11 +397,10 @@ o.spec("MailIndexer test", () => {
await indexer.enableMailIndexing(user)
o(indexer.indexMailboxes.invocations[0]).deepEquals([user, beforeNowInterval])
o(indexer.mailIndexingEnabled).equals(true)
o(indexer._excludedListIds).deepEquals([spamFolder.mails])
o(JSON.stringify(metadata)).equals(
JSON.stringify({
[MetaData.mailIndexingEnabled]: true,
[MetaData.excludedListIds]: [spamFolder.mails],
[MetaData.excludedListIds]: [],
}),
)
})
@ -414,7 +412,7 @@ o.spec("MailIndexer test", () => {
if (key == MetaData.mailIndexingEnabled) {
return Promise.resolve(true)
} else if (key == MetaData.excludedListIds) {
return Promise.resolve([1, 2])
return Promise.resolve([])
}
throw new Error("wrong key / os")
@ -429,12 +427,10 @@ o.spec("MailIndexer test", () => {
const indexer: any = new MailIndexer(null as any, db, null as any, null as any, null as any, dateProvider, mailFacade)
indexer.indexMailboxes = spy()
indexer.mailIndexingEnabled = false
indexer._excludedListIds = []
let user = createTestEntity(UserTypeRef)
await await indexer.enableMailIndexing(user)
o(indexer.indexMailboxes.callCount).equals(0)
o(indexer.mailIndexingEnabled).equals(true)
o(indexer._excludedListIds).deepEquals([1, 2])
})
o("disableMailIndexing", function () {
let db: Db = {
@ -445,10 +441,8 @@ o.spec("MailIndexer test", () => {
} as any
const indexer: any = new MailIndexer(null as any, db, null as any, null as any, null as any, dateProvider, mailFacade)
indexer.mailIndexingEnabled = true
indexer._excludedListIds = [1]
indexer.disableMailIndexing()
o(indexer.mailIndexingEnabled).equals(false)
o(indexer._excludedListIds).deepEquals([])
// @ts-ignore
o(db.dbFacade.deleteDatabase.callCount).equals(1)
})

View file

@ -20,13 +20,15 @@ import {
timestampToGeneratedId,
} from "../../../../../src/common/api/common/utils/EntityUtils.js"
import type { Base64 } from "@tutao/tutanota-utils"
import { downcast, groupBy, numberRange, splitInChunks } from "@tutao/tutanota-utils"
import { groupBy, numberRange, splitInChunks } from "@tutao/tutanota-utils"
import { appendBinaryBlocks } from "../../../../../src/common/api/worker/search/SearchIndexEncoding.js"
import { createSearchIndexDbStub, DbStub, DbStubTransaction } from "./DbStub.js"
import type { BrowserData } from "../../../../../src/common/misc/ClientConstants.js"
import { browserDataStub, createTestEntity } from "../../../TestUtils.js"
import { aes256RandomKey, fixedIv } from "@tutao/tutanota-crypto"
import { ElementDataOS, SearchIndexMetaDataOS, SearchIndexOS } from "../../../../../src/common/api/worker/search/IndexTables.js"
import { object, when } from "testdouble"
import { EntityClient } from "../../../../../src/common/api/common/EntityClient.js"
type SearchIndexEntryWithType = SearchIndexEntry & {
typeInfo: TypeInfo
@ -39,8 +41,9 @@ let dbKey
const contactTypeInfo = typeRefToTypeInfo(ContactTypeRef)
const mailTypeInfo = typeRefToTypeInfo(MailTypeRef)
const browserData: BrowserData = browserDataStub
const entityClinet = downcast({})
const entityClient: EntityClient = object()
o.spec("SearchFacade test", () => {
let mail = createTestEntity(MailTypeRef)
let user = createTestEntity(UserTypeRef)
let id1 = "L0YED5d----1"
let id2 = "L0YED5d----2"
@ -65,7 +68,7 @@ o.spec("SearchFacade test", () => {
} as any,
[],
browserData,
entityClinet,
entityClient,
)
}
@ -138,7 +141,7 @@ o.spec("SearchFacade test", () => {
end: end ?? null,
field: null,
attributeIds: attributeIds ?? null,
listIds: listId != null ? [listId] : [],
folderIds: listId != null ? [listId] : [],
eventSeries: true,
}
}
@ -239,16 +242,38 @@ o.spec("SearchFacade test", () => {
[["listId2", id2]],
)
})
o("find listId", () => {
o("find folderId legacy MailFolders (non-static mail listIds)", () => {
let mail1 = createTestEntity(MailTypeRef, { _id: ["mailListId1", id1] })
when(entityClient.load(MailTypeRef, mail1._id)).thenReturn(Promise.resolve(mail1))
let mail2 = createTestEntity(MailTypeRef, { _id: ["mailListId2", id2] })
when(entityClient.load(MailTypeRef, mail2._id)).thenReturn(Promise.resolve(mail2))
return testSearch(
[createKeyToIndexEntries("test", [createMailEntry(id1, 0, [0]), createMailEntry(id2, 0, [0])])],
[
["listId1", id1],
["listId2", id2],
],
[mail1._id, mail2._id],
"test",
createMailRestriction(null, "listId2"),
[["listId2", id2]],
createMailRestriction(null, listIdPart(mail2._id)),
[mail2._id],
)
})
o("find folderId new MailSets (static mail listIds)", () => {
const mail1 = createTestEntity(MailTypeRef, {
_id: ["mailListId", id1],
sets: [["setListId", "folderId1"]],
})
when(entityClient.load(MailTypeRef, mail1._id)).thenReturn(Promise.resolve(mail1))
const mail2 = createTestEntity(MailTypeRef, {
_id: ["mailListId", id2],
sets: [["setListId", "folderId2"]],
})
when(entityClient.load(MailTypeRef, mail2._id)).thenReturn(Promise.resolve(mail2))
return testSearch(
[createKeyToIndexEntries("test", [createMailEntry(id1, 0, [0]), createMailEntry(id2, 0, [0])])],
[mail1._id, mail2._id],
"test",
createMailRestriction(null, elementIdPart(mail2.sets[0])),
[mail2._id],
)
})
o("find with start time", () => {

View file

@ -2,7 +2,7 @@ import o from "@tutao/otest"
import { Notifications } from "../../../src/common/gui/Notifications.js"
import type { Spy } from "@tutao/tutanota-test-utils"
import { spy } from "@tutao/tutanota-test-utils"
import { MailFolderType, OperationType } from "../../../src/common/api/common/TutanotaConstants.js"
import { MailSetKind, OperationType } from "../../../src/common/api/common/TutanotaConstants.js"
import { MailFolderTypeRef, MailTypeRef } from "../../../src/common/api/entities/tutanota/TypeRefs.js"
import { EntityClient } from "../../../src/common/api/common/EntityClient.js"
import { EntityRestClientMock } from "../api/worker/rest/EntityRestClientMock.js"
@ -18,30 +18,31 @@ import { createTestEntity } from "../TestUtils.js"
import { EntityUpdateData } from "../../../src/common/api/common/utils/EntityUpdateUtils.js"
import { MailboxDetail, MailModel } from "../../../src/common/mailFunctionality/MailModel.js"
import { InboxRuleHandler } from "../../../src/mail-app/mail/model/InboxRuleHandler.js"
import { getElementId, getListId } from "../../../src/common/api/common/utils/EntityUtils.js"
o.spec("MailModelTest", function () {
let notifications: Partial<Notifications>
let showSpy: Spy
let model: MailModel
const inboxFolder = createTestEntity(MailFolderTypeRef, { _id: ["folderListId", "inboxId"] })
const inboxFolder = createTestEntity(MailFolderTypeRef, { _id: ["folderListId", "inboxId"], isMailSet: false })
inboxFolder.mails = "instanceListId"
inboxFolder.folderType = MailFolderType.INBOX
const anotherFolder = createTestEntity(MailFolderTypeRef, { _id: ["folderListId", "archiveId"] })
inboxFolder.folderType = MailSetKind.INBOX
const anotherFolder = createTestEntity(MailFolderTypeRef, { _id: ["folderListId", "archiveId"], isMailSet: false })
anotherFolder.mails = "anotherListId"
anotherFolder.folderType = MailFolderType.ARCHIVE
anotherFolder.folderType = MailSetKind.ARCHIVE
let mailboxDetails: Partial<MailboxDetail>[]
let logins: LoginController
let inboxRuleHandler: InboxRuleHandler
const restClient: EntityRestClientMock = new EntityRestClientMock()
o.beforeEach(function () {
mailboxDetails = [
{
folders: new FolderSystem([inboxFolder]),
folders: new FolderSystem([inboxFolder, anotherFolder]),
},
]
notifications = {}
showSpy = notifications.showNotification = spy()
const restClient = new EntityRestClientMock()
const connectivityModel = object<WebsocketConnectivityModel>()
const mailFacade = nodemocker.mock<MailFacade>("mailFacade", {}).set()
logins = object()
@ -55,10 +56,13 @@ o.spec("MailModelTest", function () {
model.mailboxDetails(mailboxDetails as MailboxDetail[])
})
o("doesn't send notification for another folder", async function () {
const mail = createTestEntity(MailTypeRef, { _id: [anotherFolder.mails, "mailId"], sets: [] })
restClient.addListInstances(mail)
await model.entityEventsReceived(
[
makeUpdate({
instanceListId: anotherFolder.mails,
instanceListId: getListId(mail),
instanceId: getElementId(mail),
operation: OperationType.CREATE,
}),
],
@ -67,14 +71,18 @@ o.spec("MailModelTest", function () {
o(showSpy.invocations.length).equals(0)
})
o("doesn't send notification for move operation", async function () {
const mail = createTestEntity(MailTypeRef, { _id: [inboxFolder.mails, "mailId"], sets: [] })
restClient.addListInstances(mail)
await model.entityEventsReceived(
[
makeUpdate({
instanceListId: anotherFolder.mails,
instanceListId: getListId(mail),
instanceId: getElementId(mail),
operation: OperationType.DELETE,
}),
makeUpdate({
instanceListId: inboxFolder.mails,
instanceListId: getListId(mail),
instanceId: getElementId(mail),
operation: OperationType.CREATE,
}),
],
@ -83,7 +91,7 @@ o.spec("MailModelTest", function () {
o(showSpy.invocations.length).equals(0)
})
function makeUpdate(arg: { instanceListId: string; operation: OperationType }): EntityUpdateData {
function makeUpdate(arg: { instanceListId: string; instanceId: Id; operation: OperationType }): EntityUpdateData {
return Object.assign(
{},
{

View file

@ -1,38 +1,41 @@
import o from "@tutao/otest"
import { createMailFolder, MailFolderTypeRef } from "../../../../src/common/api/entities/tutanota/TypeRefs.js"
import { MailFolderType } from "../../../../src/common/api/common/TutanotaConstants.js"
import { MailFolderTypeRef, MailTypeRef } from "../../../../src/common/api/entities/tutanota/TypeRefs.js"
import { MailSetKind } from "../../../../src/common/api/common/TutanotaConstants.js"
import { FolderSystem } from "../../../../src/common/api/common/mail/FolderSystem.js"
import { createTestEntity } from "../../TestUtils.js"
import { getElementId } from "../../../../src/common/api/common/utils/EntityUtils.js"
o.spec("FolderSystem", function () {
const listId = "listId"
const inbox = createTestEntity(MailFolderTypeRef, { _id: [listId, "inbox"], folderType: MailFolderType.INBOX })
const archive = createTestEntity(MailFolderTypeRef, { _id: [listId, "archive"], folderType: MailFolderType.ARCHIVE })
const inbox = createTestEntity(MailFolderTypeRef, { _id: [listId, "inbox"], folderType: MailSetKind.INBOX })
const archive = createTestEntity(MailFolderTypeRef, { _id: [listId, "archive"], folderType: MailSetKind.ARCHIVE })
const customFolder = createTestEntity(MailFolderTypeRef, {
_id: [listId, "custom"],
folderType: MailFolderType.CUSTOM,
folderType: MailSetKind.CUSTOM,
name: "X",
})
const customSubfolder = createTestEntity(MailFolderTypeRef, {
_id: [listId, "customSub"],
folderType: MailFolderType.CUSTOM,
folderType: MailSetKind.CUSTOM,
parentFolder: customFolder._id,
name: "AA",
mails: "customSubMailList",
})
const customSubSubfolder = createTestEntity(MailFolderTypeRef, {
_id: [listId, "customSubSub"],
folderType: MailFolderType.CUSTOM,
folderType: MailSetKind.CUSTOM,
parentFolder: customSubfolder._id,
name: "B",
})
const customSubSubfolderAnother = createTestEntity(MailFolderTypeRef, {
_id: [listId, "customSubSubAnother"],
folderType: MailFolderType.CUSTOM,
folderType: MailSetKind.CUSTOM,
parentFolder: customSubfolder._id,
name: "A",
})
const mail = createTestEntity(MailTypeRef, { _id: ["mailListId", "inbox"], sets: [customSubfolder._id] })
const allFolders = [archive, inbox, customFolder, customSubfolder, customSubSubfolder, customSubSubfolderAnother]
o("correctly builds the subtrees", function () {
@ -74,12 +77,12 @@ o.spec("FolderSystem", function () {
o("indented list sorts stepsiblings correctly", function () {
const customFolderAnother = createTestEntity(MailFolderTypeRef, {
_id: [listId, "customAnother"],
folderType: MailFolderType.CUSTOM,
folderType: MailSetKind.CUSTOM,
name: "Another top-level custom",
})
const customFolderAnotherSub = createTestEntity(MailFolderTypeRef, {
_id: [listId, "customAnotherSub"],
folderType: MailFolderType.CUSTOM,
folderType: MailSetKind.CUSTOM,
parentFolder: customFolderAnother._id,
name: "Y",
})
@ -110,25 +113,24 @@ o.spec("FolderSystem", function () {
o("getSystemFolderByType", function () {
const system = new FolderSystem(allFolders)
o(system.getSystemFolderByType(MailFolderType.ARCHIVE)).deepEquals(archive)
o(system.getSystemFolderByType(MailSetKind.ARCHIVE)).deepEquals(archive)
})
o("getFolderById", function () {
const system = new FolderSystem(allFolders)
o(system.getFolderById(archive._id)).deepEquals(archive)
o(system.getFolderById(getElementId(archive))).deepEquals(archive)
})
o("getFolderById not there returns null", function () {
const system = new FolderSystem(allFolders)
o(system.getFolderById([listId, "randomId"])).equals(null)
o(system.getFolderById("randomId")).equals(null)
})
o("getFolderByMailListId", function () {
o("getFolderByMail", function () {
const system = new FolderSystem(allFolders)
o(system.getFolderByMailListId(customSubfolder.mails)).equals(customSubfolder)
o(system.getFolderByMail(mail)).equals(customSubfolder)
})
o("getCustomFoldersOfParent", function () {

View file

@ -16,7 +16,7 @@ import { EntityRestClientMock } from "../../api/worker/rest/EntityRestClientMock
import { EntityEventsListener, EventController } from "../../../../src/common/api/main/EventController.js"
import { defer, DeferredObject, delay, noOp } from "@tutao/tutanota-utils"
import { matchers, object, when } from "testdouble"
import { MailFolderType, MailState, OperationType } from "../../../../src/common/api/common/TutanotaConstants.js"
import { MailSetKind, MailState, OperationType } from "../../../../src/common/api/common/TutanotaConstants.js"
import { isSameId } from "../../../../src/common/api/common/utils/EntityUtils.js"
import { createTestEntity } from "../../TestUtils.js"
import { MailboxDetail, MailModel } from "../../../../src/common/mailFunctionality/MailModel.js"
@ -173,11 +173,11 @@ o.spec("ConversationViewModel", function () {
const trashDraftMail = addMail("trashDraftMail")
trashDraftMail.state = MailState.DRAFT
const trash = createTestEntity(MailFolderTypeRef, { _id: [listId, "trashFolder"], folderType: MailFolderType.TRASH })
const trash = createTestEntity(MailFolderTypeRef, { _id: [listId, "trashFolder"], folderType: MailSetKind.TRASH })
entityRestClientMock.addListInstances(trash)
when(mailModel.getMailboxDetailsForMail(matchers.anything())).thenResolve(mailboxDetail)
when(mailModel.getMailFolder(listId)).thenReturn(trash)
when(mailModel.getMailFolderForMail(trashDraftMail)).thenReturn(trash)
conversation.pop() // since this mail shouldn't actually be a part of the conversation
@ -195,11 +195,11 @@ o.spec("ConversationViewModel", function () {
const trashDraftMail = addMail("trashDraftMail")
trashDraftMail.state = MailState.DRAFT
const trash = createTestEntity(MailFolderTypeRef, { _id: [listId, "trashFolder"], folderType: MailFolderType.TRASH })
const trash = createTestEntity(MailFolderTypeRef, { _id: [listId, "trashFolder"], folderType: MailSetKind.TRASH })
entityRestClientMock.addListInstances(trash)
when(mailModel.getMailboxDetailsForMail(trashDraftMail)).thenResolve(mailboxDetail)
when(mailModel.getMailFolder(listId)).thenReturn(trash)
when(mailModel.getMailFolderForMail(trashDraftMail)).thenReturn(trash)
await makeViewModel(trashDraftMail)
@ -311,7 +311,7 @@ o.spec("ConversationViewModel", function () {
await loadingDefer.promise
conversation.pop()
const trash = createTestEntity(MailFolderTypeRef, { _id: ["newListId", "trashFolder"], folderType: MailFolderType.TRASH })
const trash = createTestEntity(MailFolderTypeRef, { _id: ["folderListId", "trashFolder"], folderType: MailSetKind.TRASH })
entityRestClientMock.addListInstances(trash)
// adding new mail (is the same mail, just moved to trash)
const newTrashDraftMail = addMail("trashDraftMail")
@ -320,7 +320,7 @@ o.spec("ConversationViewModel", function () {
conversation.pop()
when(mailModel.getMailboxDetailsForMail(matchers.anything())).thenResolve(mailboxDetail)
when(mailModel.getMailFolder("newListId")).thenReturn(trash)
when(mailModel.getMailFolderForMail(newTrashDraftMail)).thenReturn(trash)
await eventCallback(
[

View file

@ -1,6 +1,6 @@
import o from "@tutao/otest"
import { ListModel, ListModelConfig } from "../../../src/common/misc/ListModel.js"
import { GENERATED_MAX_ID, getElementId, sortCompareById, timestampToGeneratedId } from "../../../src/common/api/common/utils/EntityUtils.js"
import { GENERATED_MAX_ID, getElementId, getListId, sortCompareById, timestampToGeneratedId } from "../../../src/common/api/common/utils/EntityUtils.js"
import { defer, DeferredObject } from "@tutao/tutanota-utils"
import { KnowledgeBaseEntry, KnowledgeBaseEntryTypeRef } from "../../../src/common/api/entities/tutanota/TypeRefs.js"
import { ListFetchResult } from "../../../src/common/gui/base/ListUtils.js"
@ -413,7 +413,7 @@ o.spec("ListModel", function () {
o("when the active item is deleted selectPrevious single will still select previous item relative to it", async function () {
await setItems(items)
listModel.onSingleInclusiveSelection(itemB)
await listModel.entityEventReceived(getElementId(itemB), OperationType.DELETE)
await listModel.entityEventReceived(getListId(itemB), getElementId(itemB), OperationType.DELETE)
// start state:
//
// A
@ -435,7 +435,7 @@ o.spec("ListModel", function () {
o("when the active item is deleted selectPrevious multiselect will still select previous item relative to it", async function () {
await setItems(items)
listModel.onSingleInclusiveSelection(itemB)
await listModel.entityEventReceived(getElementId(itemB), OperationType.DELETE)
await listModel.entityEventReceived(getListId(itemB), getElementId(itemB), OperationType.DELETE)
// start state:
//
// A
@ -457,7 +457,7 @@ o.spec("ListModel", function () {
o("when the active item is deleted selectNext single will still select next item relative to it", async function () {
await setItems(items)
listModel.onSingleInclusiveSelection(itemB)
await listModel.entityEventReceived(getElementId(itemB), OperationType.DELETE)
await listModel.entityEventReceived(getListId(itemB), getElementId(itemB), OperationType.DELETE)
// start state:
//
// A
@ -479,7 +479,7 @@ o.spec("ListModel", function () {
o("when the active item is deleted selectNext multiselect will still select next item relative to it", async function () {
await setItems(items)
listModel.onSingleInclusiveSelection(itemB)
await listModel.entityEventReceived(getElementId(itemB), OperationType.DELETE)
await listModel.entityEventReceived(getListId(itemB), getElementId(itemB), OperationType.DELETE)
// start state:
//
// A
@ -714,7 +714,7 @@ o.spec("ListModel", function () {
o("in single select, the active element is next entity when active element gets deleted", async function () {
await setItems(items)
listModel.onSingleSelection(itemB)
await listModel.entityEventReceived(getElementId(itemB), OperationType.DELETE)
await listModel.entityEventReceived(getListId(itemB), getElementId(itemB), OperationType.DELETE)
o(listModel.state.activeIndex).equals(1)
})
@ -722,7 +722,7 @@ o.spec("ListModel", function () {
o("in single select, the active element is not changed when a different entity is deleted", async function () {
await setItems(items)
listModel.onSingleSelection(itemC)
await listModel.entityEventReceived(getElementId(itemA), OperationType.DELETE)
await listModel.entityEventReceived(getListId(itemA), getElementId(itemA), OperationType.DELETE)
o(listModel.state.activeIndex).equals(1)
})
@ -730,7 +730,7 @@ o.spec("ListModel", function () {
o("in multiselect, next element is not selected when element is removed", async function () {
await setItems(items)
listModel.onSingleInclusiveSelection(itemB)
await listModel.entityEventReceived(getElementId(itemB), OperationType.DELETE)
await listModel.entityEventReceived(getListId(itemB), getElementId(itemB), OperationType.DELETE)
o(listModel.state.inMultiselect).equals(true)
o(listModel.state.activeIndex).equals(null)
@ -743,7 +743,7 @@ o.spec("ListModel", function () {
const newConfig: ListModelConfig<KnowledgeBaseEntry> = {
...defaultListConfig,
async loadSingle(elementId: Id): Promise<KnowledgeBaseEntry | null> {
async loadSingle(_listId: Id, elementId: Id): Promise<KnowledgeBaseEntry | null> {
if (elementId === getElementId(itemD)) {
return updatedItemD
} else {
@ -755,7 +755,7 @@ o.spec("ListModel", function () {
listModel = new ListModel<KnowledgeBaseEntry>(newConfig)
await setItems(items)
await listModel.entityEventReceived(getElementId(itemD), OperationType.UPDATE)
await listModel.entityEventReceived(getListId(itemD), getElementId(itemD), OperationType.UPDATE)
o(listModel.state.items).deepEquals([itemA, itemB, itemC, updatedItemD])
})
@ -765,7 +765,7 @@ o.spec("ListModel", function () {
const newConfig: ListModelConfig<KnowledgeBaseEntry> = {
...defaultListConfig,
async loadSingle(elementId: Id): Promise<KnowledgeBaseEntry | null> {
async loadSingle(_listId: Id, elementId: Id): Promise<KnowledgeBaseEntry | null> {
if (elementId === getElementId(itemD)) {
return updatedItemD
} else {
@ -780,7 +780,7 @@ o.spec("ListModel", function () {
listModel = new ListModel<KnowledgeBaseEntry>(newConfig)
await setItems(items)
await listModel.entityEventReceived(getElementId(itemD), OperationType.UPDATE)
await listModel.entityEventReceived(getListId(itemD), getElementId(itemD), OperationType.UPDATE)
o(listModel.state.items).deepEquals([itemA, updatedItemD, itemB, itemC])
})

View file

@ -410,6 +410,7 @@ mod tests {
),
],
),
"sets"=> JsonElement::Array(vec![]),
}
}
}

View file

@ -4294,7 +4294,7 @@ impl Entity for WebsocketCounterData {
pub struct WebsocketCounterValue {
pub _id: CustomId,
pub count: i64,
pub mailListId: GeneratedId,
pub counterId: GeneratedId,
}
impl Entity for WebsocketCounterValue {

View file

@ -693,6 +693,22 @@ impl Entity for CustomerAccountCreateData {
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize, Debug)]
pub struct DefaultAlarmInfo {
pub _id: CustomId,
pub trigger: String,
}
impl Entity for DefaultAlarmInfo {
fn type_ref() -> TypeRef {
TypeRef {
app: "tutanota",
type_: "DefaultAlarmInfo",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize, Debug)]
pub struct DeleteGroupData {
pub _format: i64,
@ -1144,6 +1160,7 @@ pub struct GroupSettings {
pub _id: CustomId,
pub color: String,
pub name: Option<String>,
pub defaultAlarmsList: Vec<DefaultAlarmInfo>,
pub group: GeneratedId,
}
@ -1395,6 +1412,7 @@ pub struct Mail {
pub mailDetails: Option<IdTuple>,
pub mailDetailsDraft: Option<IdTuple>,
pub sender: MailAddress,
pub sets: Vec<IdTuple>,
pub errors: Option<Errors>,
}
@ -1443,6 +1461,22 @@ impl Entity for MailAddressProperties {
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize, Debug)]
pub struct MailBag {
pub _id: CustomId,
pub mails: GeneratedId,
}
impl Entity for MailBag {
fn type_ref() -> TypeRef {
TypeRef {
app: "tutanota",
type_: "MailBag",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize, Debug)]
pub struct MailBox {
pub _format: i64,
@ -1453,6 +1487,8 @@ pub struct MailBox {
pub _ownerKeyVersion: Option<i64>,
pub _permissions: GeneratedId,
pub lastInfoDate: DateTime,
pub archivedMailBags: Vec<MailBag>,
pub currentMailBag: Option<MailBag>,
pub folders: Option<MailFolderRef>,
pub mailDetailsDrafts: Option<MailDetailsDraftsRef>,
pub receivedAttachments: GeneratedId,
@ -1564,7 +1600,10 @@ pub struct MailFolder {
pub _ownerKeyVersion: Option<i64>,
pub _permissions: GeneratedId,
pub folderType: i64,
pub isLabel: bool,
pub isMailSet: bool,
pub name: String,
pub entries: GeneratedId,
pub mails: GeneratedId,
pub parentFolder: Option<IdTuple>,
pub errors: Option<Errors>,
@ -1596,6 +1635,25 @@ impl Entity for MailFolderRef {
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize, Debug)]
pub struct MailSetEntry {
pub _format: i64,
pub _id: IdTuple,
pub _ownerGroup: Option<GeneratedId>,
pub _permissions: GeneratedId,
pub mail: IdTuple,
}
impl Entity for MailSetEntry {
fn type_ref() -> TypeRef {
TypeRef {
app: "tutanota",
type_: "MailSetEntry",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize, Debug)]
pub struct MailboxGroupRoot {
pub _format: i64,
@ -1668,6 +1726,7 @@ impl Entity for MailboxServerProperties {
pub struct MoveMailData {
pub _format: i64,
pub mails: Vec<IdTuple>,
pub sourceFolder: Option<IdTuple>,
pub targetFolder: IdTuple,
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -42,5 +42,6 @@
"name": "Aa4w50xGAXcmNXIN55Y7Cr/m9n5AXDNHmIQ9Cph7mffUCzqnQtYfrs8hAa53J5iyyliZ1nSwdCDs4hCXc2KwzXc=",
"contact": null
},
"toRecipients": []
"toRecipients": [],
"sets": []
}

View file

@ -35,5 +35,6 @@
"address": "bed-free@tutanota.de",
"name": "Aa9WzeZU8eovYX3G6i+5pAk5odHi3cSFvkzQmdQjeyJIBHzw3rf1xkpWCt0TTpZkhA1bw5aqxaJe/EXSHX5PUHg=",
"contact": null
}
},
"sets": []
}

View file

@ -42,5 +42,6 @@
"name": "AbmMklsiI2yKGMdbpQBc0eX8dPc1hGNeL7NNa5Wdypurp60v0uP+/Do7fBQnalJX/4K09/znZKIUcbapkifHqJc=",
"contact": null
},
"toRecipients": []
"toRecipients": [],
"sets": []
}

View file

@ -0,0 +1,44 @@
#![allow(non_snake_case, unused_imports)]
use super::*;
use serde::{Serialize, Deserialize};
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct CustomerAccountPosting {
pub _id: CustomId,
pub amount: i64,
pub invoiceNumber: Option<String>,
#[serde(rename = "type")]
pub r#type: i64,
pub valueDate: DateTime,
}
impl Entity for CustomerAccountPosting {
fn type_ref() -> TypeRef {
TypeRef {
app: "accounting",
type_: "CustomerAccountPosting",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct CustomerAccountReturn {
pub _format: i64,
pub _ownerGroup: Option<GeneratedId>,
#[serde(with = "serde_bytes")]
pub _ownerPublicEncSessionKey: Option<Vec<u8>>,
pub _publicCryptoProtocolVersion: Option<i64>,
pub balance: i64,
pub outstandingBookingsPrice: i64,
pub postings: Vec<CustomerAccountPosting>,
}
impl Entity for CustomerAccountReturn {
fn type_ref() -> TypeRef {
TypeRef {
app: "accounting",
type_: "CustomerAccountReturn",
}
}
}

View file

@ -0,0 +1,19 @@
#![allow(non_snake_case, unused_imports)]
use super::*;
use serde::{Serialize, Deserialize};
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct PersistenceResourcePostReturn {
pub _format: i64,
pub generatedId: Option<GeneratedId>,
pub permissionListId: GeneratedId,
}
impl Entity for PersistenceResourcePostReturn {
fn type_ref() -> TypeRef {
TypeRef {
app: "base",
type_: "PersistenceResourcePostReturn",
}
}
}

View file

@ -0,0 +1,4 @@
#![allow(non_snake_case, unused_imports)]
use super::*;
use serde::{Serialize, Deserialize};

View file

@ -0,0 +1,153 @@
#![allow(non_snake_case, unused_imports)]
use super::*;
use serde::{Serialize, Deserialize};
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct ApprovalMail {
pub _format: i64,
pub _id: IdTuple,
pub _ownerGroup: Option<GeneratedId>,
pub _permissions: GeneratedId,
pub date: Option<DateTime>,
pub range: Option<String>,
pub text: String,
pub customer: Option<GeneratedId>,
}
impl Entity for ApprovalMail {
fn type_ref() -> TypeRef {
TypeRef {
app: "monitor",
type_: "ApprovalMail",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct CounterValue {
pub _id: CustomId,
pub counterId: GeneratedId,
pub value: i64,
}
impl Entity for CounterValue {
fn type_ref() -> TypeRef {
TypeRef {
app: "monitor",
type_: "CounterValue",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct ErrorReportData {
pub _id: CustomId,
pub additionalInfo: String,
pub appVersion: String,
pub clientType: i64,
pub errorClass: String,
pub errorMessage: Option<String>,
pub stackTrace: String,
pub time: DateTime,
pub userId: Option<String>,
pub userMessage: Option<String>,
}
impl Entity for ErrorReportData {
fn type_ref() -> TypeRef {
TypeRef {
app: "monitor",
type_: "ErrorReportData",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct ErrorReportFile {
pub _id: CustomId,
pub content: String,
pub name: String,
}
impl Entity for ErrorReportFile {
fn type_ref() -> TypeRef {
TypeRef {
app: "monitor",
type_: "ErrorReportFile",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct ReadCounterData {
pub _format: i64,
pub columnName: Option<GeneratedId>,
pub counterType: i64,
pub rowName: String,
}
impl Entity for ReadCounterData {
fn type_ref() -> TypeRef {
TypeRef {
app: "monitor",
type_: "ReadCounterData",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct ReadCounterReturn {
pub _format: i64,
pub value: Option<i64>,
pub counterValues: Vec<CounterValue>,
}
impl Entity for ReadCounterReturn {
fn type_ref() -> TypeRef {
TypeRef {
app: "monitor",
type_: "ReadCounterReturn",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct ReportErrorIn {
pub _format: i64,
pub data: ErrorReportData,
pub files: Vec<ErrorReportFile>,
}
impl Entity for ReportErrorIn {
fn type_ref() -> TypeRef {
TypeRef {
app: "monitor",
type_: "ReportErrorIn",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct WriteCounterData {
pub _format: i64,
pub column: GeneratedId,
pub counterType: Option<i64>,
pub row: String,
pub value: i64,
}
impl Entity for WriteCounterData {
fn type_ref() -> TypeRef {
TypeRef {
app: "monitor",
type_: "WriteCounterData",
}
}
}

View file

@ -0,0 +1,227 @@
#![allow(non_snake_case, unused_imports)]
use super::*;
use serde::{Serialize, Deserialize};
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct BlobAccessTokenPostIn {
pub _format: i64,
pub archiveDataType: Option<i64>,
pub read: Option<BlobReadData>,
pub write: Option<BlobWriteData>,
}
impl Entity for BlobAccessTokenPostIn {
fn type_ref() -> TypeRef {
TypeRef {
app: "storage",
type_: "BlobAccessTokenPostIn",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct BlobAccessTokenPostOut {
pub _format: i64,
pub blobAccessInfo: BlobServerAccessInfo,
}
impl Entity for BlobAccessTokenPostOut {
fn type_ref() -> TypeRef {
TypeRef {
app: "storage",
type_: "BlobAccessTokenPostOut",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct BlobArchiveRef {
pub _format: i64,
pub _id: IdTuple,
pub _ownerGroup: Option<GeneratedId>,
pub _permissions: GeneratedId,
pub archive: GeneratedId,
}
impl Entity for BlobArchiveRef {
fn type_ref() -> TypeRef {
TypeRef {
app: "storage",
type_: "BlobArchiveRef",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct BlobGetIn {
pub _format: i64,
pub archiveId: GeneratedId,
pub blobId: Option<GeneratedId>,
pub blobIds: Vec<BlobId>,
}
impl Entity for BlobGetIn {
fn type_ref() -> TypeRef {
TypeRef {
app: "storage",
type_: "BlobGetIn",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct BlobId {
pub _id: CustomId,
pub blobId: GeneratedId,
}
impl Entity for BlobId {
fn type_ref() -> TypeRef {
TypeRef {
app: "storage",
type_: "BlobId",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct BlobPostOut {
pub _format: i64,
pub blobReferenceToken: String,
}
impl Entity for BlobPostOut {
fn type_ref() -> TypeRef {
TypeRef {
app: "storage",
type_: "BlobPostOut",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct BlobReadData {
pub _id: CustomId,
pub archiveId: GeneratedId,
pub instanceListId: Option<GeneratedId>,
pub instanceIds: Vec<InstanceId>,
}
impl Entity for BlobReadData {
fn type_ref() -> TypeRef {
TypeRef {
app: "storage",
type_: "BlobReadData",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct BlobReferenceDeleteIn {
pub _format: i64,
pub archiveDataType: i64,
pub instanceId: GeneratedId,
pub instanceListId: Option<GeneratedId>,
pub blobs: Vec<sys::Blob>,
}
impl Entity for BlobReferenceDeleteIn {
fn type_ref() -> TypeRef {
TypeRef {
app: "storage",
type_: "BlobReferenceDeleteIn",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct BlobReferencePutIn {
pub _format: i64,
pub archiveDataType: i64,
pub instanceId: GeneratedId,
pub instanceListId: Option<GeneratedId>,
pub referenceTokens: Vec<sys::BlobReferenceTokenWrapper>,
}
impl Entity for BlobReferencePutIn {
fn type_ref() -> TypeRef {
TypeRef {
app: "storage",
type_: "BlobReferencePutIn",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct BlobServerAccessInfo {
pub _id: CustomId,
pub blobAccessToken: String,
pub expires: DateTime,
pub servers: Vec<BlobServerUrl>,
}
impl Entity for BlobServerAccessInfo {
fn type_ref() -> TypeRef {
TypeRef {
app: "storage",
type_: "BlobServerAccessInfo",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct BlobServerUrl {
pub _id: CustomId,
pub url: String,
}
impl Entity for BlobServerUrl {
fn type_ref() -> TypeRef {
TypeRef {
app: "storage",
type_: "BlobServerUrl",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct BlobWriteData {
pub _id: CustomId,
pub archiveOwnerGroup: GeneratedId,
}
impl Entity for BlobWriteData {
fn type_ref() -> TypeRef {
TypeRef {
app: "storage",
type_: "BlobWriteData",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct InstanceId {
pub _id: CustomId,
pub instanceId: Option<GeneratedId>,
}
impl Entity for InstanceId {
fn type_ref() -> TypeRef {
TypeRef {
app: "storage",
type_: "InstanceId",
}
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,146 @@
#![allow(non_snake_case, unused_imports)]
use super::*;
use serde::{Serialize, Deserialize};
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct UsageTestAssignment {
pub _id: CustomId,
pub name: String,
pub sendPings: bool,
pub testId: GeneratedId,
pub variant: Option<i64>,
pub stages: Vec<UsageTestStage>,
}
impl Entity for UsageTestAssignment {
fn type_ref() -> TypeRef {
TypeRef {
app: "usage",
type_: "UsageTestAssignment",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct UsageTestAssignmentIn {
pub _format: i64,
pub testDeviceId: Option<GeneratedId>,
}
impl Entity for UsageTestAssignmentIn {
fn type_ref() -> TypeRef {
TypeRef {
app: "usage",
type_: "UsageTestAssignmentIn",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct UsageTestAssignmentOut {
pub _format: i64,
pub testDeviceId: GeneratedId,
pub assignments: Vec<UsageTestAssignment>,
}
impl Entity for UsageTestAssignmentOut {
fn type_ref() -> TypeRef {
TypeRef {
app: "usage",
type_: "UsageTestAssignmentOut",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct UsageTestMetricConfig {
pub _id: CustomId,
pub name: String,
#[serde(rename = "type")]
pub r#type: i64,
pub configValues: Vec<UsageTestMetricConfigValue>,
}
impl Entity for UsageTestMetricConfig {
fn type_ref() -> TypeRef {
TypeRef {
app: "usage",
type_: "UsageTestMetricConfig",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct UsageTestMetricConfigValue {
pub _id: CustomId,
pub key: String,
pub value: String,
}
impl Entity for UsageTestMetricConfigValue {
fn type_ref() -> TypeRef {
TypeRef {
app: "usage",
type_: "UsageTestMetricConfigValue",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct UsageTestMetricData {
pub _id: CustomId,
pub name: String,
pub value: String,
}
impl Entity for UsageTestMetricData {
fn type_ref() -> TypeRef {
TypeRef {
app: "usage",
type_: "UsageTestMetricData",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct UsageTestParticipationIn {
pub _format: i64,
pub stage: i64,
pub testDeviceId: GeneratedId,
pub testId: GeneratedId,
pub metrics: Vec<UsageTestMetricData>,
}
impl Entity for UsageTestParticipationIn {
fn type_ref() -> TypeRef {
TypeRef {
app: "usage",
type_: "UsageTestParticipationIn",
}
}
}
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
pub struct UsageTestStage {
pub _id: CustomId,
pub maxPings: i64,
pub minPings: i64,
pub name: String,
pub metrics: Vec<UsageTestMetricConfig>,
}
impl Entity for UsageTestStage {
fn type_ref() -> TypeRef {
TypeRef {
app: "usage",
type_: "UsageTestStage",
}
}
}

View file

@ -0,0 +1,140 @@
{
"CustomerAccountPosting": {
"name": "CustomerAccountPosting",
"since": 3,
"type": "AGGREGATED_TYPE",
"id": 79,
"rootId": "CmFjY291bnRpbmcATw",
"versioned": false,
"encrypted": false,
"values": {
"_id": {
"final": true,
"name": "_id",
"id": 80,
"since": 3,
"type": "CustomId",
"cardinality": "One",
"encrypted": false
},
"amount": {
"final": true,
"name": "amount",
"id": 84,
"since": 3,
"type": "Number",
"cardinality": "One",
"encrypted": true
},
"invoiceNumber": {
"final": true,
"name": "invoiceNumber",
"id": 83,
"since": 3,
"type": "String",
"cardinality": "ZeroOrOne",
"encrypted": true
},
"type": {
"final": true,
"name": "type",
"id": 81,
"since": 3,
"type": "Number",
"cardinality": "One",
"encrypted": true
},
"valueDate": {
"final": true,
"name": "valueDate",
"id": 82,
"since": 3,
"type": "Date",
"cardinality": "One",
"encrypted": true
}
},
"associations": {},
"app": "accounting",
"version": "7"
},
"CustomerAccountReturn": {
"name": "CustomerAccountReturn",
"since": 3,
"type": "DATA_TRANSFER_TYPE",
"id": 86,
"rootId": "CmFjY291bnRpbmcAVg",
"versioned": false,
"encrypted": true,
"values": {
"_format": {
"final": false,
"name": "_format",
"id": 87,
"since": 3,
"type": "Number",
"cardinality": "One",
"encrypted": false
},
"_ownerGroup": {
"final": true,
"name": "_ownerGroup",
"id": 88,
"since": 3,
"type": "GeneratedId",
"cardinality": "ZeroOrOne",
"encrypted": false
},
"_ownerPublicEncSessionKey": {
"final": true,
"name": "_ownerPublicEncSessionKey",
"id": 89,
"since": 3,
"type": "Bytes",
"cardinality": "ZeroOrOne",
"encrypted": false
},
"_publicCryptoProtocolVersion": {
"final": true,
"name": "_publicCryptoProtocolVersion",
"id": 96,
"since": 7,
"type": "Number",
"cardinality": "ZeroOrOne",
"encrypted": false
},
"balance": {
"final": true,
"name": "balance",
"id": 94,
"since": 5,
"type": "Number",
"cardinality": "One",
"encrypted": true
},
"outstandingBookingsPrice": {
"final": false,
"name": "outstandingBookingsPrice",
"id": 92,
"since": 4,
"type": "Number",
"cardinality": "One",
"encrypted": false
}
},
"associations": {
"postings": {
"final": false,
"name": "postings",
"id": 90,
"since": 3,
"type": "AGGREGATION",
"cardinality": "Any",
"refType": "CustomerAccountPosting",
"dependency": null
}
},
"app": "accounting",
"version": "7"
}
}

View file

@ -0,0 +1,43 @@
{
"PersistenceResourcePostReturn": {
"name": "PersistenceResourcePostReturn",
"since": 1,
"type": "DATA_TRANSFER_TYPE",
"id": 0,
"rootId": "BGJhc2UAAA",
"versioned": false,
"encrypted": false,
"values": {
"_format": {
"final": false,
"name": "_format",
"id": 1,
"since": 1,
"type": "Number",
"cardinality": "One",
"encrypted": false
},
"generatedId": {
"final": false,
"name": "generatedId",
"id": 2,
"since": 1,
"type": "GeneratedId",
"cardinality": "ZeroOrOne",
"encrypted": false
},
"permissionListId": {
"final": false,
"name": "permissionListId",
"id": 3,
"since": 1,
"type": "GeneratedId",
"cardinality": "One",
"encrypted": false
}
},
"associations": {},
"app": "base",
"version": "1"
}
}

View file

@ -0,0 +1 @@
{}

View file

@ -0,0 +1,472 @@
{
"ApprovalMail": {
"name": "ApprovalMail",
"since": 14,
"type": "LIST_ELEMENT_TYPE",
"id": 221,
"rootId": "B21vbml0b3IAAN0",
"versioned": false,
"encrypted": false,
"values": {
"_format": {
"final": false,
"name": "_format",
"id": 225,
"since": 14,
"type": "Number",
"cardinality": "One",
"encrypted": false
},
"_id": {
"final": true,
"name": "_id",
"id": 223,
"since": 14,
"type": "CustomId",
"cardinality": "One",
"encrypted": false
},
"_ownerGroup": {
"final": true,
"name": "_ownerGroup",
"id": 226,
"since": 14,
"type": "GeneratedId",
"cardinality": "ZeroOrOne",
"encrypted": false
},
"_permissions": {
"final": true,
"name": "_permissions",
"id": 224,
"since": 14,
"type": "GeneratedId",
"cardinality": "One",
"encrypted": false
},
"date": {
"final": true,
"name": "date",
"id": 228,
"since": 14,
"type": "Date",
"cardinality": "ZeroOrOne",
"encrypted": false
},
"range": {
"final": true,
"name": "range",
"id": 227,
"since": 14,
"type": "String",
"cardinality": "ZeroOrOne",
"encrypted": false
},
"text": {
"final": true,
"name": "text",
"id": 229,
"since": 14,
"type": "String",
"cardinality": "One",
"encrypted": false
}
},
"associations": {
"customer": {
"final": true,
"name": "customer",
"id": 230,
"since": 14,
"type": "ELEMENT_ASSOCIATION",
"cardinality": "ZeroOrOne",
"refType": "Customer",
"dependency": null
}
},
"app": "monitor",
"version": "28"
},
"CounterValue": {
"name": "CounterValue",
"since": 22,
"type": "AGGREGATED_TYPE",
"id": 300,
"rootId": "B21vbml0b3IAASw",
"versioned": false,
"encrypted": false,
"values": {
"_id": {
"final": true,
"name": "_id",
"id": 301,
"since": 22,
"type": "CustomId",
"cardinality": "One",
"encrypted": false
},
"counterId": {
"final": false,
"name": "counterId",
"id": 302,
"since": 22,
"type": "GeneratedId",
"cardinality": "One",
"encrypted": false
},
"value": {
"final": false,
"name": "value",
"id": 303,
"since": 22,
"type": "Number",
"cardinality": "One",
"encrypted": false
}
},
"associations": {},
"app": "monitor",
"version": "28"
},
"ErrorReportData": {
"name": "ErrorReportData",
"since": 23,
"type": "AGGREGATED_TYPE",
"id": 316,
"rootId": "B21vbml0b3IAATw",
"versioned": false,
"encrypted": false,
"values": {
"_id": {
"final": true,
"name": "_id",
"id": 317,
"since": 23,
"type": "CustomId",
"cardinality": "One",
"encrypted": false
},
"additionalInfo": {
"final": true,
"name": "additionalInfo",
"id": 326,
"since": 23,
"type": "String",
"cardinality": "One",
"encrypted": false
},
"appVersion": {
"final": true,
"name": "appVersion",
"id": 319,
"since": 23,
"type": "String",
"cardinality": "One",
"encrypted": false
},
"clientType": {
"final": true,
"name": "clientType",
"id": 320,
"since": 23,
"type": "Number",
"cardinality": "One",
"encrypted": false
},
"errorClass": {
"final": true,
"name": "errorClass",
"id": 322,
"since": 23,
"type": "String",
"cardinality": "One",
"encrypted": false
},
"errorMessage": {
"final": true,
"name": "errorMessage",
"id": 323,
"since": 23,
"type": "String",
"cardinality": "ZeroOrOne",
"encrypted": false
},
"stackTrace": {
"final": true,
"name": "stackTrace",
"id": 324,
"since": 23,
"type": "String",
"cardinality": "One",
"encrypted": false
},
"time": {
"final": true,
"name": "time",
"id": 318,
"since": 23,
"type": "Date",
"cardinality": "One",
"encrypted": false
},
"userId": {
"final": true,
"name": "userId",
"id": 321,
"since": 23,
"type": "String",
"cardinality": "ZeroOrOne",
"encrypted": false
},
"userMessage": {
"final": true,
"name": "userMessage",
"id": 325,
"since": 23,
"type": "String",
"cardinality": "ZeroOrOne",
"encrypted": false
}
},
"associations": {},
"app": "monitor",
"version": "28"
},
"ErrorReportFile": {
"name": "ErrorReportFile",
"since": 23,
"type": "AGGREGATED_TYPE",
"id": 305,
"rootId": "B21vbml0b3IAATE",
"versioned": false,
"encrypted": false,
"values": {
"_id": {
"final": true,
"name": "_id",
"id": 306,
"since": 23,
"type": "CustomId",
"cardinality": "One",
"encrypted": false
},
"content": {
"final": true,
"name": "content",
"id": 308,
"since": 23,
"type": "String",
"cardinality": "One",
"encrypted": false
},
"name": {
"final": true,
"name": "name",
"id": 307,
"since": 23,
"type": "String",
"cardinality": "One",
"encrypted": false
}
},
"associations": {},
"app": "monitor",
"version": "28"
},
"ReadCounterData": {
"name": "ReadCounterData",
"since": 1,
"type": "DATA_TRANSFER_TYPE",
"id": 12,
"rootId": "B21vbml0b3IADA",
"versioned": false,
"encrypted": false,
"values": {
"_format": {
"final": false,
"name": "_format",
"id": 13,
"since": 1,
"type": "Number",
"cardinality": "One",
"encrypted": false
},
"columnName": {
"final": false,
"name": "columnName",
"id": 15,
"since": 1,
"type": "GeneratedId",
"cardinality": "ZeroOrOne",
"encrypted": false
},
"counterType": {
"final": false,
"name": "counterType",
"id": 299,
"since": 22,
"type": "Number",
"cardinality": "One",
"encrypted": false
},
"rowName": {
"final": false,
"name": "rowName",
"id": 14,
"since": 1,
"type": "String",
"cardinality": "One",
"encrypted": false
}
},
"associations": {},
"app": "monitor",
"version": "28"
},
"ReadCounterReturn": {
"name": "ReadCounterReturn",
"since": 1,
"type": "DATA_TRANSFER_TYPE",
"id": 16,
"rootId": "B21vbml0b3IAEA",
"versioned": false,
"encrypted": false,
"values": {
"_format": {
"final": false,
"name": "_format",
"id": 17,
"since": 1,
"type": "Number",
"cardinality": "One",
"encrypted": false
},
"value": {
"final": false,
"name": "value",
"id": 18,
"since": 1,
"type": "Number",
"cardinality": "ZeroOrOne",
"encrypted": false
}
},
"associations": {
"counterValues": {
"final": false,
"name": "counterValues",
"id": 304,
"since": 22,
"type": "AGGREGATION",
"cardinality": "Any",
"refType": "CounterValue",
"dependency": null
}
},
"app": "monitor",
"version": "28"
},
"ReportErrorIn": {
"name": "ReportErrorIn",
"since": 23,
"type": "DATA_TRANSFER_TYPE",
"id": 335,
"rootId": "B21vbml0b3IAAU8",
"versioned": false,
"encrypted": false,
"values": {
"_format": {
"final": false,
"name": "_format",
"id": 336,
"since": 23,
"type": "Number",
"cardinality": "One",
"encrypted": false
}
},
"associations": {
"data": {
"final": false,
"name": "data",
"id": 337,
"since": 23,
"type": "AGGREGATION",
"cardinality": "One",
"refType": "ErrorReportData",
"dependency": null
},
"files": {
"final": false,
"name": "files",
"id": 338,
"since": 23,
"type": "AGGREGATION",
"cardinality": "Any",
"refType": "ErrorReportFile",
"dependency": null
}
},
"app": "monitor",
"version": "28"
},
"WriteCounterData": {
"name": "WriteCounterData",
"since": 4,
"type": "DATA_TRANSFER_TYPE",
"id": 49,
"rootId": "B21vbml0b3IAMQ",
"versioned": false,
"encrypted": false,
"values": {
"_format": {
"final": false,
"name": "_format",
"id": 50,
"since": 4,
"type": "Number",
"cardinality": "One",
"encrypted": false
},
"column": {
"final": false,
"name": "column",
"id": 52,
"since": 4,
"type": "GeneratedId",
"cardinality": "One",
"encrypted": false
},
"counterType": {
"final": false,
"name": "counterType",
"id": 215,
"since": 12,
"type": "Number",
"cardinality": "ZeroOrOne",
"encrypted": false
},
"row": {
"final": false,
"name": "row",
"id": 51,
"since": 4,
"type": "String",
"cardinality": "One",
"encrypted": false
},
"value": {
"final": false,
"name": "value",
"id": 53,
"since": 4,
"type": "Number",
"cardinality": "One",
"encrypted": false
}
},
"associations": {},
"app": "monitor",
"version": "28"
}
}

View file

@ -0,0 +1,588 @@
{
"BlobAccessTokenPostIn": {
"name": "BlobAccessTokenPostIn",
"since": 1,
"type": "DATA_TRANSFER_TYPE",
"id": 77,
"rootId": "B3N0b3JhZ2UATQ",
"versioned": false,
"encrypted": false,
"values": {
"_format": {
"final": false,
"name": "_format",
"id": 78,
"since": 1,
"type": "Number",
"cardinality": "One",
"encrypted": false
},
"archiveDataType": {
"final": false,
"name": "archiveDataType",
"id": 180,
"since": 4,
"type": "Number",
"cardinality": "ZeroOrOne",
"encrypted": false
}
},
"associations": {
"read": {
"final": true,
"name": "read",
"id": 181,
"since": 4,
"type": "AGGREGATION",
"cardinality": "ZeroOrOne",
"refType": "BlobReadData",
"dependency": null
},
"write": {
"final": false,
"name": "write",
"id": 80,
"since": 1,
"type": "AGGREGATION",
"cardinality": "ZeroOrOne",
"refType": "BlobWriteData",
"dependency": null
}
},
"app": "storage",
"version": "9"
},
"BlobAccessTokenPostOut": {
"name": "BlobAccessTokenPostOut",
"since": 1,
"type": "DATA_TRANSFER_TYPE",
"id": 81,
"rootId": "B3N0b3JhZ2UAUQ",
"versioned": false,
"encrypted": false,
"values": {
"_format": {
"final": false,
"name": "_format",
"id": 82,
"since": 1,
"type": "Number",
"cardinality": "One",
"encrypted": false
}
},
"associations": {
"blobAccessInfo": {
"final": false,
"name": "blobAccessInfo",
"id": 161,
"since": 4,
"type": "AGGREGATION",
"cardinality": "One",
"refType": "BlobServerAccessInfo",
"dependency": null
}
},
"app": "storage",
"version": "9"
},
"BlobArchiveRef": {
"name": "BlobArchiveRef",
"since": 4,
"type": "LIST_ELEMENT_TYPE",
"id": 129,
"rootId": "B3N0b3JhZ2UAAIE",
"versioned": false,
"encrypted": false,
"values": {
"_format": {
"final": false,
"name": "_format",
"id": 133,
"since": 4,
"type": "Number",
"cardinality": "One",
"encrypted": false
},
"_id": {
"final": true,
"name": "_id",
"id": 131,
"since": 4,
"type": "GeneratedId",
"cardinality": "One",
"encrypted": false
},
"_ownerGroup": {
"final": true,
"name": "_ownerGroup",
"id": 134,
"since": 4,
"type": "GeneratedId",
"cardinality": "ZeroOrOne",
"encrypted": false
},
"_permissions": {
"final": true,
"name": "_permissions",
"id": 132,
"since": 4,
"type": "GeneratedId",
"cardinality": "One",
"encrypted": false
}
},
"associations": {
"archive": {
"final": false,
"name": "archive",
"id": 135,
"since": 4,
"type": "ELEMENT_ASSOCIATION",
"cardinality": "One",
"refType": "Archive",
"dependency": null
}
},
"app": "storage",
"version": "9"
},
"BlobGetIn": {
"name": "BlobGetIn",
"since": 1,
"type": "DATA_TRANSFER_TYPE",
"id": 50,
"rootId": "B3N0b3JhZ2UAMg",
"versioned": false,
"encrypted": false,
"values": {
"_format": {
"final": false,
"name": "_format",
"id": 51,
"since": 1,
"type": "Number",
"cardinality": "One",
"encrypted": false
},
"archiveId": {
"final": false,
"name": "archiveId",
"id": 52,
"since": 1,
"type": "GeneratedId",
"cardinality": "One",
"encrypted": false
},
"blobId": {
"final": false,
"name": "blobId",
"id": 110,
"since": 3,
"type": "GeneratedId",
"cardinality": "ZeroOrOne",
"encrypted": false
}
},
"associations": {
"blobIds": {
"final": true,
"name": "blobIds",
"id": 193,
"since": 8,
"type": "AGGREGATION",
"cardinality": "Any",
"refType": "BlobId",
"dependency": null
}
},
"app": "storage",
"version": "9"
},
"BlobId": {
"name": "BlobId",
"since": 4,
"type": "AGGREGATED_TYPE",
"id": 144,
"rootId": "B3N0b3JhZ2UAAJA",
"versioned": false,
"encrypted": false,
"values": {
"_id": {
"final": true,
"name": "_id",
"id": 145,
"since": 4,
"type": "CustomId",
"cardinality": "One",
"encrypted": false
},
"blobId": {
"final": false,
"name": "blobId",
"id": 146,
"since": 4,
"type": "GeneratedId",
"cardinality": "One",
"encrypted": false
}
},
"associations": {},
"app": "storage",
"version": "9"
},
"BlobPostOut": {
"name": "BlobPostOut",
"since": 4,
"type": "DATA_TRANSFER_TYPE",
"id": 125,
"rootId": "B3N0b3JhZ2UAfQ",
"versioned": false,
"encrypted": false,
"values": {
"_format": {
"final": false,
"name": "_format",
"id": 126,
"since": 4,
"type": "Number",
"cardinality": "One",
"encrypted": false
},
"blobReferenceToken": {
"final": false,
"name": "blobReferenceToken",
"id": 127,
"since": 4,
"type": "String",
"cardinality": "One",
"encrypted": false
}
},
"associations": {},
"app": "storage",
"version": "9"
},
"BlobReadData": {
"name": "BlobReadData",
"since": 4,
"type": "AGGREGATED_TYPE",
"id": 175,
"rootId": "B3N0b3JhZ2UAAK8",
"versioned": false,
"encrypted": false,
"values": {
"_id": {
"final": true,
"name": "_id",
"id": 176,
"since": 4,
"type": "CustomId",
"cardinality": "One",
"encrypted": false
},
"archiveId": {
"final": false,
"name": "archiveId",
"id": 177,
"since": 4,
"type": "GeneratedId",
"cardinality": "One",
"encrypted": false
},
"instanceListId": {
"final": true,
"name": "instanceListId",
"id": 178,
"since": 4,
"type": "GeneratedId",
"cardinality": "ZeroOrOne",
"encrypted": false
}
},
"associations": {
"instanceIds": {
"final": true,
"name": "instanceIds",
"id": 179,
"since": 4,
"type": "AGGREGATION",
"cardinality": "Any",
"refType": "InstanceId",
"dependency": null
}
},
"app": "storage",
"version": "9"
},
"BlobReferenceDeleteIn": {
"name": "BlobReferenceDeleteIn",
"since": 1,
"type": "DATA_TRANSFER_TYPE",
"id": 100,
"rootId": "B3N0b3JhZ2UAZA",
"versioned": false,
"encrypted": false,
"values": {
"_format": {
"final": false,
"name": "_format",
"id": 101,
"since": 1,
"type": "Number",
"cardinality": "One",
"encrypted": false
},
"archiveDataType": {
"final": false,
"name": "archiveDataType",
"id": 124,
"since": 4,
"type": "Number",
"cardinality": "One",
"encrypted": false
},
"instanceId": {
"final": false,
"name": "instanceId",
"id": 103,
"since": 1,
"type": "GeneratedId",
"cardinality": "One",
"encrypted": false
},
"instanceListId": {
"final": false,
"name": "instanceListId",
"id": 102,
"since": 1,
"type": "GeneratedId",
"cardinality": "ZeroOrOne",
"encrypted": false
}
},
"associations": {
"blobs": {
"final": true,
"name": "blobs",
"id": 105,
"since": 1,
"type": "AGGREGATION",
"cardinality": "Any",
"refType": "Blob",
"dependency": "sys"
}
},
"app": "storage",
"version": "9"
},
"BlobReferencePutIn": {
"name": "BlobReferencePutIn",
"since": 1,
"type": "DATA_TRANSFER_TYPE",
"id": 94,
"rootId": "B3N0b3JhZ2UAXg",
"versioned": false,
"encrypted": false,
"values": {
"_format": {
"final": false,
"name": "_format",
"id": 95,
"since": 1,
"type": "Number",
"cardinality": "One",
"encrypted": false
},
"archiveDataType": {
"final": false,
"name": "archiveDataType",
"id": 123,
"since": 4,
"type": "Number",
"cardinality": "One",
"encrypted": false
},
"instanceId": {
"final": false,
"name": "instanceId",
"id": 107,
"since": 2,
"type": "GeneratedId",
"cardinality": "One",
"encrypted": false
},
"instanceListId": {
"final": false,
"name": "instanceListId",
"id": 97,
"since": 1,
"type": "GeneratedId",
"cardinality": "ZeroOrOne",
"encrypted": false
}
},
"associations": {
"referenceTokens": {
"final": true,
"name": "referenceTokens",
"id": 122,
"since": 4,
"type": "AGGREGATION",
"cardinality": "Any",
"refType": "BlobReferenceTokenWrapper",
"dependency": "sys"
}
},
"app": "storage",
"version": "9"
},
"BlobServerAccessInfo": {
"name": "BlobServerAccessInfo",
"since": 4,
"type": "AGGREGATED_TYPE",
"id": 157,
"rootId": "B3N0b3JhZ2UAAJ0",
"versioned": false,
"encrypted": false,
"values": {
"_id": {
"final": true,
"name": "_id",
"id": 158,
"since": 4,
"type": "CustomId",
"cardinality": "One",
"encrypted": false
},
"blobAccessToken": {
"final": false,
"name": "blobAccessToken",
"id": 159,
"since": 4,
"type": "String",
"cardinality": "One",
"encrypted": false
},
"expires": {
"final": false,
"name": "expires",
"id": 192,
"since": 6,
"type": "Date",
"cardinality": "One",
"encrypted": false
}
},
"associations": {
"servers": {
"final": false,
"name": "servers",
"id": 160,
"since": 4,
"type": "AGGREGATION",
"cardinality": "Any",
"refType": "BlobServerUrl",
"dependency": null
}
},
"app": "storage",
"version": "9"
},
"BlobServerUrl": {
"name": "BlobServerUrl",
"since": 4,
"type": "AGGREGATED_TYPE",
"id": 154,
"rootId": "B3N0b3JhZ2UAAJo",
"versioned": false,
"encrypted": false,
"values": {
"_id": {
"final": true,
"name": "_id",
"id": 155,
"since": 4,
"type": "CustomId",
"cardinality": "One",
"encrypted": false
},
"url": {
"final": false,
"name": "url",
"id": 156,
"since": 4,
"type": "String",
"cardinality": "One",
"encrypted": false
}
},
"associations": {},
"app": "storage",
"version": "9"
},
"BlobWriteData": {
"name": "BlobWriteData",
"since": 1,
"type": "AGGREGATED_TYPE",
"id": 73,
"rootId": "B3N0b3JhZ2UASQ",
"versioned": false,
"encrypted": false,
"values": {
"_id": {
"final": true,
"name": "_id",
"id": 74,
"since": 1,
"type": "CustomId",
"cardinality": "One",
"encrypted": false
},
"archiveOwnerGroup": {
"final": false,
"name": "archiveOwnerGroup",
"id": 75,
"since": 1,
"type": "GeneratedId",
"cardinality": "One",
"encrypted": false
}
},
"associations": {},
"app": "storage",
"version": "9"
},
"InstanceId": {
"name": "InstanceId",
"since": 4,
"type": "AGGREGATED_TYPE",
"id": 172,
"rootId": "B3N0b3JhZ2UAAKw",
"versioned": false,
"encrypted": false,
"values": {
"_id": {
"final": true,
"name": "_id",
"id": 173,
"since": 4,
"type": "CustomId",
"cardinality": "One",
"encrypted": false
},
"instanceId": {
"final": true,
"name": "instanceId",
"id": 174,
"since": 4,
"type": "GeneratedId",
"cardinality": "ZeroOrOne",
"encrypted": false
}
},
"associations": {},
"app": "storage",
"version": "9"
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,403 @@
{
"UsageTestAssignment": {
"name": "UsageTestAssignment",
"since": 1,
"type": "AGGREGATED_TYPE",
"id": 56,
"rootId": "BXVzYWdlADg",
"versioned": false,
"encrypted": false,
"values": {
"_id": {
"final": true,
"name": "_id",
"id": 57,
"since": 1,
"type": "CustomId",
"cardinality": "One",
"encrypted": false
},
"name": {
"final": false,
"name": "name",
"id": 59,
"since": 1,
"type": "String",
"cardinality": "One",
"encrypted": false
},
"sendPings": {
"final": false,
"name": "sendPings",
"id": 61,
"since": 1,
"type": "Boolean",
"cardinality": "One",
"encrypted": false
},
"testId": {
"final": true,
"name": "testId",
"id": 58,
"since": 1,
"type": "GeneratedId",
"cardinality": "One",
"encrypted": false
},
"variant": {
"final": true,
"name": "variant",
"id": 60,
"since": 1,
"type": "Number",
"cardinality": "ZeroOrOne",
"encrypted": false
}
},
"associations": {
"stages": {
"final": false,
"name": "stages",
"id": 62,
"since": 1,
"type": "AGGREGATION",
"cardinality": "Any",
"refType": "UsageTestStage",
"dependency": null
}
},
"app": "usage",
"version": "2"
},
"UsageTestAssignmentIn": {
"name": "UsageTestAssignmentIn",
"since": 1,
"type": "DATA_TRANSFER_TYPE",
"id": 53,
"rootId": "BXVzYWdlADU",
"versioned": false,
"encrypted": false,
"values": {
"_format": {
"final": false,
"name": "_format",
"id": 54,
"since": 1,
"type": "Number",
"cardinality": "One",
"encrypted": false
},
"testDeviceId": {
"final": false,
"name": "testDeviceId",
"id": 55,
"since": 1,
"type": "GeneratedId",
"cardinality": "ZeroOrOne",
"encrypted": false
}
},
"associations": {},
"app": "usage",
"version": "2"
},
"UsageTestAssignmentOut": {
"name": "UsageTestAssignmentOut",
"since": 1,
"type": "DATA_TRANSFER_TYPE",
"id": 63,
"rootId": "BXVzYWdlAD8",
"versioned": false,
"encrypted": false,
"values": {
"_format": {
"final": false,
"name": "_format",
"id": 64,
"since": 1,
"type": "Number",
"cardinality": "One",
"encrypted": false
},
"testDeviceId": {
"final": false,
"name": "testDeviceId",
"id": 65,
"since": 1,
"type": "GeneratedId",
"cardinality": "One",
"encrypted": false
}
},
"associations": {
"assignments": {
"final": false,
"name": "assignments",
"id": 66,
"since": 1,
"type": "AGGREGATION",
"cardinality": "Any",
"refType": "UsageTestAssignment",
"dependency": null
}
},
"app": "usage",
"version": "2"
},
"UsageTestMetricConfig": {
"name": "UsageTestMetricConfig",
"since": 1,
"type": "AGGREGATED_TYPE",
"id": 12,
"rootId": "BXVzYWdlAAw",
"versioned": false,
"encrypted": false,
"values": {
"_id": {
"final": true,
"name": "_id",
"id": 13,
"since": 1,
"type": "CustomId",
"cardinality": "One",
"encrypted": false
},
"name": {
"final": true,
"name": "name",
"id": 14,
"since": 1,
"type": "String",
"cardinality": "One",
"encrypted": false
},
"type": {
"final": true,
"name": "type",
"id": 15,
"since": 1,
"type": "Number",
"cardinality": "One",
"encrypted": false
}
},
"associations": {
"configValues": {
"final": false,
"name": "configValues",
"id": 16,
"since": 1,
"type": "AGGREGATION",
"cardinality": "Any",
"refType": "UsageTestMetricConfigValue",
"dependency": null
}
},
"app": "usage",
"version": "2"
},
"UsageTestMetricConfigValue": {
"name": "UsageTestMetricConfigValue",
"since": 1,
"type": "AGGREGATED_TYPE",
"id": 8,
"rootId": "BXVzYWdlAAg",
"versioned": false,
"encrypted": false,
"values": {
"_id": {
"final": true,
"name": "_id",
"id": 9,
"since": 1,
"type": "CustomId",
"cardinality": "One",
"encrypted": false
},
"key": {
"final": false,
"name": "key",
"id": 10,
"since": 1,
"type": "String",
"cardinality": "One",
"encrypted": false
},
"value": {
"final": false,
"name": "value",
"id": 11,
"since": 1,
"type": "String",
"cardinality": "One",
"encrypted": false
}
},
"associations": {},
"app": "usage",
"version": "2"
},
"UsageTestMetricData": {
"name": "UsageTestMetricData",
"since": 1,
"type": "AGGREGATED_TYPE",
"id": 17,
"rootId": "BXVzYWdlABE",
"versioned": false,
"encrypted": false,
"values": {
"_id": {
"final": true,
"name": "_id",
"id": 18,
"since": 1,
"type": "CustomId",
"cardinality": "One",
"encrypted": false
},
"name": {
"final": true,
"name": "name",
"id": 19,
"since": 1,
"type": "String",
"cardinality": "One",
"encrypted": false
},
"value": {
"final": true,
"name": "value",
"id": 20,
"since": 1,
"type": "String",
"cardinality": "One",
"encrypted": false
}
},
"associations": {},
"app": "usage",
"version": "2"
},
"UsageTestParticipationIn": {
"name": "UsageTestParticipationIn",
"since": 1,
"type": "DATA_TRANSFER_TYPE",
"id": 80,
"rootId": "BXVzYWdlAFA",
"versioned": false,
"encrypted": false,
"values": {
"_format": {
"final": false,
"name": "_format",
"id": 81,
"since": 1,
"type": "Number",
"cardinality": "One",
"encrypted": false
},
"stage": {
"final": false,
"name": "stage",
"id": 83,
"since": 1,
"type": "Number",
"cardinality": "One",
"encrypted": false
},
"testDeviceId": {
"final": false,
"name": "testDeviceId",
"id": 84,
"since": 1,
"type": "GeneratedId",
"cardinality": "One",
"encrypted": false
},
"testId": {
"final": false,
"name": "testId",
"id": 82,
"since": 1,
"type": "GeneratedId",
"cardinality": "One",
"encrypted": false
}
},
"associations": {
"metrics": {
"final": false,
"name": "metrics",
"id": 85,
"since": 1,
"type": "AGGREGATION",
"cardinality": "Any",
"refType": "UsageTestMetricData",
"dependency": null
}
},
"app": "usage",
"version": "2"
},
"UsageTestStage": {
"name": "UsageTestStage",
"since": 1,
"type": "AGGREGATED_TYPE",
"id": 35,
"rootId": "BXVzYWdlACM",
"versioned": false,
"encrypted": false,
"values": {
"_id": {
"final": true,
"name": "_id",
"id": 36,
"since": 1,
"type": "CustomId",
"cardinality": "One",
"encrypted": false
},
"maxPings": {
"final": false,
"name": "maxPings",
"id": 88,
"since": 2,
"type": "Number",
"cardinality": "One",
"encrypted": false
},
"minPings": {
"final": false,
"name": "minPings",
"id": 87,
"since": 2,
"type": "Number",
"cardinality": "One",
"encrypted": false
},
"name": {
"final": false,
"name": "name",
"id": 37,
"since": 1,
"type": "String",
"cardinality": "One",
"encrypted": false
}
},
"associations": {
"metrics": {
"final": false,
"name": "metrics",
"id": 38,
"since": 1,
"type": "AGGREGATION",
"cardinality": "Any",
"refType": "UsageTestMetricConfig",
"dependency": null
}
},
"app": "usage",
"version": "2"
}
}