Skip to content

Commit c273956

Browse files
committed
WIP: Autosave drafts
Closes #4419
1 parent 99c000c commit c273956

File tree

11 files changed

+344
-33
lines changed

11 files changed

+344
-33
lines changed

packages/tutanota-utils/lib/DateUtils.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,16 @@
33
* As functions here do not use Luxon it cannot be used for calculating things in different time zones, they
44
* are dependent on the system time zone.
55
*/
6-
export const DAY_IN_MILLIS = 1000 * 60 * 60 * 24
6+
7+
/**
8+
* Convert the number of seconds to milliseconds.
9+
* @param seconds seconds to convert
10+
*/
11+
export function secondsToMillis(seconds: number): number {
12+
return seconds * 1000
13+
}
14+
15+
export const DAY_IN_MILLIS = secondsToMillis(60 * 60 * 24)
716

817
export const YEAR_IN_MILLIS = DAY_IN_MILLIS * 365
918

packages/tutanota-utils/lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export {
8282
isValidDate,
8383
millisToDays,
8484
daysToMillis,
85+
secondsToMillis,
8586
TIMESTAMP_ZERO_YEAR,
8687
} from "./DateUtils.js"
8788
export {

src/calendar-app/calendarLocator.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,7 @@ class CalendarLocator implements CommonLocator {
336336
recipientsModel,
337337
dateProvider,
338338
mailboxProperties,
339+
this.configFacade,
339340
async (mail: Mail) => {
340341
return false
341342
},

src/common/api/worker/facades/lazy/ConfigurationDatabase.ts

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
Aes128Key,
77
Aes256Key,
88
aes256RandomKey,
9+
aesDecrypt,
910
aesEncrypt,
1011
AesKey,
1112
decryptKey,
@@ -14,14 +15,15 @@ import {
1415
unauthenticatedAesDecrypt,
1516
} from "@tutao/tutanota-crypto"
1617
import { UserFacade } from "../UserFacade.js"
17-
import { EncryptedDbKeyBaseMetaData, EncryptedIndexerMetaData, Metadata, ObjectStoreName } from "../../search/IndexTables.js"
18+
import { EncryptedDbKeyBaseMetaData, EncryptedIndexerMetaData, LocalDraftDataOS, Metadata, ObjectStoreName } from "../../search/IndexTables.js"
1819
import { DbError } from "../../../common/error/DbError.js"
1920
import { checkKeyVersionConstraints, KeyLoaderFacade } from "../KeyLoaderFacade.js"
20-
import type { QueuedBatch } from "../../EventQueue.js"
2121
import { _encryptKeyWithVersionedKey, VersionedKey } from "../../crypto/CryptoWrapper.js"
2222
import { EntityUpdateData, isUpdateForTypeRef } from "../../../common/utils/EntityUpdateUtils"
23+
import * as cborg from "cborg"
24+
import { customTypeDecoders, customTypeEncoders } from "../../offline/OfflineStorage"
2325

24-
const VERSION: number = 2
26+
const VERSION: number = 3
2527
const DB_KEY_PREFIX: string = "ConfigStorage"
2628
const ExternalImageListOS: ObjectStoreName = "ExternalAllowListOS"
2729
export const ConfigurationMetaDataOS: ObjectStoreName = "MetaDataOS"
@@ -43,6 +45,26 @@ export async function decryptLegacyItem(encryptedAddress: Uint8Array, key: Aes25
4345
return utf8Uint8ArrayToString(unauthenticatedAesDecrypt(key, concat(iv, encryptedAddress)))
4446
}
4547

48+
const LOCAL_DRAFT_VERSION: number = 1
49+
export type LocalDraftAddress = {
50+
name: string
51+
address: string
52+
}
53+
export type LocalDraftData = {
54+
version: number
55+
56+
mailId: IdTuple | null
57+
mailGroupId: Id
58+
59+
subject: string
60+
body: string
61+
62+
senderAddress: string
63+
to: LocalDraftAddress[]
64+
cc: LocalDraftAddress[]
65+
bcc: LocalDraftAddress[]
66+
}
67+
4668
/**
4769
* A local configuration database that can be used as an alternative to DeviceConfig:
4870
* Ideal for cases where the configuration values should be stored encrypted,
@@ -65,6 +87,66 @@ export class ConfigurationDatabase {
6587
})
6688
}
6789

90+
async setDraftData(draftUpdateDataWithoutVersion: Omit<LocalDraftData, "version">): Promise<void> {
91+
const { db, metaData } = await this.db.getAsync()
92+
if (!db.indexingSupported) return
93+
94+
const draftUpdateData: LocalDraftData = Object.assign({}, draftUpdateDataWithoutVersion, { version: LOCAL_DRAFT_VERSION })
95+
96+
try {
97+
const transaction = await db.createTransaction(false, [LocalDraftDataOS])
98+
const encoded = cborg.encode(draftUpdateData, { typeEncoders: customTypeEncoders })
99+
const encryptedData = aesEncrypt(metaData.key, encoded, metaData.iv)
100+
await transaction.put(LocalDraftDataOS, "current", encryptedData) // FIXME
101+
} catch (e) {
102+
if (e instanceof DbError) {
103+
console.error("failed to save draft:", e.message)
104+
return
105+
}
106+
throw e
107+
}
108+
}
109+
110+
async getDraftData(): Promise<LocalDraftData | null> {
111+
const { db, metaData } = await this.db.getAsync()
112+
if (!db.indexingSupported) return null
113+
114+
try {
115+
const transaction = await db.createTransaction(false, [LocalDraftDataOS])
116+
const data = await transaction.get<Uint8Array>(LocalDraftDataOS, "current") // FIXME
117+
if (data == null) {
118+
return null
119+
}
120+
121+
const decryptedData = aesDecrypt(metaData.key, data)
122+
const encoded = cborg.decode(decryptedData, { tags: customTypeDecoders })
123+
124+
return encoded as LocalDraftData
125+
} catch (e) {
126+
if (e instanceof DbError) {
127+
console.error("failed to load draft:", e.message)
128+
return null
129+
}
130+
throw e
131+
}
132+
}
133+
134+
async clearDraftData(): Promise<void> {
135+
const { db } = await this.db.getAsync()
136+
if (!db.indexingSupported) return
137+
138+
try {
139+
const transaction = await db.createTransaction(false, [LocalDraftDataOS])
140+
await transaction.delete(LocalDraftDataOS, "current") // FIXME
141+
} catch (e) {
142+
if (e instanceof DbError) {
143+
console.error("failed to load draft:", e.message)
144+
return
145+
}
146+
throw e
147+
}
148+
}
149+
68150
async addExternalImageRule(address: string, rule: ExternalImageRule): Promise<void> {
69151
const { db, metaData } = await this.db.getAsync()
70152
if (!db.indexingSupported) return
@@ -96,11 +178,14 @@ export class ConfigurationDatabase {
96178
async loadConfigDb(user: User, keyLoaderFacade: KeyLoaderFacade): Promise<ConfigDb> {
97179
const id = this.getDbId(user._id)
98180
const db = new DbFacade(VERSION, async (event, db, dbFacade) => {
181+
console.log(`MIGRATING DB FOR VERSION ${event.oldVersion}`)
182+
99183
if (event.oldVersion === 0) {
100184
db.createObjectStore(ConfigurationMetaDataOS)
101185
db.createObjectStore(ExternalImageListOS, {
102186
keyPath: "address",
103187
})
188+
db.createObjectStore(LocalDraftDataOS)
104189
}
105190
const metaData =
106191
(await loadEncryptionMetadata(dbFacade, id, keyLoaderFacade, ConfigurationMetaDataOS)) ||
@@ -118,7 +203,12 @@ export class ConfigurationDatabase {
118203
await deleteTransaction.delete(ExternalImageListOS, entry.key)
119204
}
120205
}
206+
207+
if (event.oldVersion < 3) {
208+
db.createObjectStore(LocalDraftDataOS)
209+
}
121210
})
211+
122212
const metaData =
123213
(await loadEncryptionMetadata(db, id, keyLoaderFacade, ConfigurationMetaDataOS)) ||
124214
(await initializeDb(db, id, keyLoaderFacade, ConfigurationMetaDataOS))

src/common/api/worker/search/IndexTables.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export const MetaDataOS: ObjectStoreName = "MetaData"
1010
export const GroupDataOS: ObjectStoreName = "GroupMetaData"
1111
export const SearchTermSuggestionsOS: ObjectStoreName = "SearchTermSuggestions"
1212
export const SearchIndexWordsIndex: IndexName = "SearchIndexWords"
13+
export const LocalDraftDataOS: ObjectStoreName = "LocalDraftData"
1314

1415
export const Metadata = Object.freeze({
1516
userEncDbKey: "userEncDbKey",

src/common/mailFunctionality/SendMailModel.ts

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,8 @@ import { ContactModel } from "../contactsFunctionality/ContactModel.js"
8282
import { getContactDisplayName } from "../contactsFunctionality/ContactUtils.js"
8383
import { getMailBodyText } from "../api/common/CommonMailUtils.js"
8484
import { KeyVerificationMismatchError } from "../api/common/error/KeyVerificationMismatchError"
85-
import { showMultiRecipientsKeyVerificationRecoveryDialog } from "../settings/keymanagement/KeyVerificationRecoveryDialog"
8685
import { EventInviteEmailType } from "../../calendar-app/calendar/view/CalendarNotificationSender"
86+
import type { ConfigurationDatabase } from "../api/worker/facades/lazy/ConfigurationDatabase"
8787

8888
assertMainOrNode()
8989

@@ -174,6 +174,7 @@ export class SendMailModel {
174174
private readonly recipientsModel: RecipientsModel,
175175
private readonly dateProvider: DateProvider,
176176
private mailboxProperties: MailboxProperties,
177+
private readonly configurationDatabase: ConfigurationDatabase,
177178
private readonly needNewDraft: (mail: Mail) => Promise<boolean>,
178179
) {
179180
const userProps = logins.getUserController().props
@@ -278,6 +279,32 @@ export class SendMailModel {
278279
return this.mailChangedAt > this.mailSavedAt
279280
}
280281

282+
async clearLocalAutosave(): Promise<void> {
283+
await this.configurationDatabase.clearDraftData()
284+
}
285+
286+
async makeLocalAutosave(): Promise<void> {
287+
const body = await this.getSanitizedBody()
288+
const subject = this.getSubject()
289+
290+
const to = (await this.toRecipientsResolved()).map(({ name, address }) => ({ name, address }))
291+
const cc = (await this.ccRecipientsResolved()).map(({ name, address }) => ({ name, address }))
292+
const bcc = (await this.bccRecipientsResolved()).map(({ name, address }) => ({ name, address }))
293+
294+
await this.configurationDatabase.setDraftData({
295+
body,
296+
subject,
297+
to,
298+
cc,
299+
bcc,
300+
mailGroupId: this.mailboxDetails.mailGroup._id,
301+
senderAddress: this.senderAddress,
302+
303+
// will be null if it is a new (unsaved) draft
304+
mailId: this.getDraft()?._id ?? null,
305+
})
306+
}
307+
281308
/**
282309
* update the changed state of the mail.
283310
* will only be reset when saving.
@@ -693,6 +720,24 @@ export class SendMailModel {
693720
})
694721
}
695722

723+
private async createDraftData(body: string, attachments: ReadonlyArray<Attachment> | null, mailMethod: MailMethod): Promise<Mail> {
724+
return this.mailFacade.createDraft({
725+
subject: this.getSubject(),
726+
bodyText: body,
727+
senderMailAddress: this.senderAddress,
728+
senderName: this.getSenderName(),
729+
toRecipients: await this.toRecipientsResolved(),
730+
ccRecipients: await this.ccRecipientsResolved(),
731+
bccRecipients: await this.bccRecipientsResolved(),
732+
conversationType: this.conversationType,
733+
previousMessageId: this.previousMessageId,
734+
attachments: attachments,
735+
confidential: this.isConfidential(),
736+
replyTos: await this.replyTosResolved(),
737+
method: mailMethod,
738+
})
739+
}
740+
696741
isConfidential(): boolean {
697742
return this.confidential || !this.containsExternalRecipients()
698743
}
@@ -906,17 +951,7 @@ export class SendMailModel {
906951
const attachments = saveAttachments ? this.attachments : null
907952

908953
// We also want to create new drafts for drafts edited from trash or spam folder
909-
const { getHtmlSanitizer } = await import("../misc/HtmlSanitizer.js")
910-
const unsanitized_body = this.getBody()
911-
const body = getHtmlSanitizer().sanitizeHTML(unsanitized_body, {
912-
// store the draft always with external links preserved. this reverts
913-
// the draft-src and draft-srcset attribute stow.
914-
blockExternalContent: false,
915-
// since we're not displaying this, this is fine.
916-
allowRelativeLinks: true,
917-
// do not touch inline images, we just want to store this.
918-
usePlaceholderForInlineImages: false,
919-
}).html
954+
const body = await this.getSanitizedBody()
920955

921956
this.draft =
922957
this.draft == null || (await this.needNewDraft(this.draft))
@@ -949,6 +984,21 @@ export class SendMailModel {
949984
}
950985
}
951986

987+
private async getSanitizedBody(): Promise<string> {
988+
const unsanitized_body = this.getBody()
989+
990+
const { getHtmlSanitizer } = await import("../misc/HtmlSanitizer.js")
991+
return getHtmlSanitizer().sanitizeHTML(unsanitized_body, {
992+
// store the draft always with external links preserved. this reverts
993+
// the draft-src and draft-srcset attribute stow.
994+
blockExternalContent: false,
995+
// since we're not displaying this, this is fine.
996+
allowRelativeLinks: true,
997+
// do not touch inline images, we just want to store this.
998+
usePlaceholderForInlineImages: false,
999+
}).html
1000+
}
1001+
9521002
private sendApprovalMail(body: string): Promise<unknown> {
9531003
const listId = "---------c--"
9541004
const m = createApprovalMail({

src/mail-app/app.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { ContactModel } from "../common/contactsFunctionality/ContactModel.js"
3939
import { CacheMode } from "../common/api/worker/rest/EntityRestClient"
4040
import { SessionType } from "../common/api/common/SessionType.js"
4141
import { UndoModel } from "./UndoModel"
42+
import { OpenLocallySavedDraftAction } from "./mail/editor/OpenLocallySavedDraftAction"
4243

4344
assertMainOrNodeBoot()
4445
bootFinished()
@@ -205,6 +206,11 @@ import("./translations/en.js")
205206
})
206207
}
207208

209+
mailLocator.logins.addPostLoginAction(async () => {
210+
const { OpenLocallySavedDraftAction } = await import("./mail/editor/OpenLocallySavedDraftAction.js")
211+
return new OpenLocallySavedDraftAction(mailLocator.configFacade, mailLocator.mailboxModel, mailLocator.entityClient, mailLocator)
212+
})
213+
208214
if (isDesktop()) {
209215
mailLocator.logins.addPostLoginAction(async () => {
210216
return {

0 commit comments

Comments
 (0)