Tutanota model v42, calendar invites

Improve event preview

Improve progress when saving event

Improve dropdowns

Improve dropdown placement and large dialog on mobile

Use new dropdown algorithm for old dropdowns.

Fix client tests, move color functions
This commit is contained in:
ivk 2019-08-22 18:24:32 +02:00
parent 665da21127
commit 8d73456d03
No known key found for this signature in database
GPG key ID: C103E7EF5463D318
258 changed files with 8309 additions and 2851 deletions

631
src/mail/SendMailModel.js Normal file
View file

@ -0,0 +1,631 @@
// @flow
import type {ConversationTypeEnum, MailMethodEnum} from "../api/common/TutanotaConstants"
import {ConversationType, MAX_ATTACHMENT_SIZE, OperationType, ReplyType} from "../api/common/TutanotaConstants"
import {load, setup, update} from "../api/main/Entity"
import {worker} from "../api/main/WorkerClient"
import type {RecipientInfo} from "../api/common/RecipientInfo"
import {isExternal} from "../api/common/RecipientInfo"
import {
AccessBlockedError,
LockedError,
NotAuthorizedError,
NotFoundError,
PreconditionFailedError,
TooManyRequestsError
} from "../api/common/error/RestError"
import {UserError} from "../api/common/error/UserError"
import {assertMainOrNode} from "../api/Env"
import {getPasswordStrength} from "../misc/PasswordUtils"
import {assertNotNull, downcast, neverNull} from "../api/common/utils/Utils"
import {
createRecipientInfo,
getDefaultSender,
getEmailSignature,
getEnabledMailAddressesWithUser,
getMailboxName,
getSenderNameForUser,
resolveRecipientInfo,
resolveRecipientInfoContact
} from "./MailUtils"
import type {File as TutanotaFile} from "../api/entities/tutanota/File"
import {FileTypeRef} from "../api/entities/tutanota/File"
import {ConversationEntryTypeRef} from "../api/entities/tutanota/ConversationEntry"
import type {Mail} from "../api/entities/tutanota/Mail"
import {MailTypeRef} from "../api/entities/tutanota/Mail"
import type {Contact} from "../api/entities/tutanota/Contact"
import {ContactTypeRef} from "../api/entities/tutanota/Contact"
import {isSameId, stringToCustomId} from "../api/common/EntityFunctions"
import {FileNotFoundError} from "../api/common/error/FileNotFoundError"
import type {LoginController} from "../api/main/LoginController"
import type {MailAddress} from "../api/entities/tutanota/MailAddress"
import type {MailboxDetail} from "./MailModel"
import {MailModel} from "./MailModel"
import {LazyContactListId} from "../contacts/ContactUtils"
import {RecipientNotResolvedError} from "../api/common/error/RecipientNotResolvedError"
import stream from "mithril/stream/stream.js"
import type {EntityEventsListener} from "../api/main/EventController"
import {EventController, isUpdateForTypeRef} from "../api/main/EventController"
import type {InlineImages} from "./MailViewer"
import {isMailAddress} from "../misc/FormatValidator"
import {createApprovalMail} from "../api/entities/monitor/ApprovalMail"
import type {EncryptedMailAddress} from "../api/entities/tutanota/EncryptedMailAddress"
import {remove} from "../api/common/utils/ArrayUtils"
import type {ContactModel} from "../contacts/ContactModel"
import {getAvailableLanguageCode, lang} from "../misc/LanguageViewModel"
import {RecipientsNotFoundError} from "../api/common/error/RecipientsNotFoundError"
import {checkApprovalStatus} from "../misc/LoginUtils"
assertMainOrNode()
export type Recipient = {name: ?string, address: string, contact?: ?Contact}
export type RecipientList = $ReadOnlyArray<Recipient>
export type Recipients = {to?: RecipientList, cc?: RecipientList, bcc?: RecipientList}
// Because MailAddress does not have contact of the right type (event when renamed on Recipient) MailAddress <: Recipient does not hold
function toRecipient({address, name}: MailAddress): Recipient {
return {name, address}
}
type EditorAttachment = TutanotaFile | DataFile | FileReference
type RecipientField = "to" | "cc" | "bcc"
/**
* Model which allows sending mails interactively - including resolving of recipients and handling of drafts.
*/
export class SendMailModel {
draft: ?Mail;
recipientsChanged: Stream<void>;
_logins: LoginController;
_contactModel: ContactModel;
_mailModel: MailModel;
_eventController: EventController;
_senderAddress: string;
_selectedNotificationLanguage: string;
_toRecipients: Array<RecipientInfo>;
_ccRecipients: Array<RecipientInfo>;
_bccRecipients: Array<RecipientInfo>;
_replyTos: Array<RecipientInfo>;
_subject: Stream<string>;
_body: string; // only defined till the editor is initialized
_conversationType: ConversationTypeEnum;
_previousMessageId: ?Id; // only needs to be the correct value if this is a new email. if we are editing a draft, conversationType is not used
_confidentialButtonState: boolean;
_attachments: Array<EditorAttachment>; // contains either Files from Tutanota or DataFiles of locally loaded files. these map 1:1 to the _attachmentButtons
_mailChanged: boolean;
_previousMail: ?Mail;
_entityEventReceived: EntityEventsListener;
_mailboxDetails: MailboxDetail;
_objectURLs: Array<string>;
_blockExternalContent: boolean;
_mentionedInlineImages: Array<string>
// TODO handle inline images when using SendMailModel for MailEditor
/** HTML elements which correspond to inline images. We need them to check that they are removed/remove them later */
_inlineImageElements: Array<HTMLElement>;
/**
* Creates a new draft message. Invoke initAsResponse or initFromDraft if this message should be a response
* to an existing message or edit an existing draft.
*
*/
constructor(logins: LoginController, mailModel: MailModel, contactModel: ContactModel, eventController: EventController,
mailboxDetails: MailboxDetail) {
this._logins = logins
this._mailModel = mailModel
this._contactModel = contactModel
this._eventController = eventController
this._conversationType = ConversationType.NEW
this._toRecipients = []
this._ccRecipients = []
this._bccRecipients = []
this._replyTos = []
this._attachments = []
this._mailChanged = false
this._previousMail = null
this.draft = null
this._mailboxDetails = mailboxDetails
this._objectURLs = []
this._blockExternalContent = true
this._mentionedInlineImages = []
this._inlineImageElements = []
this.recipientsChanged = stream(undefined)
let props = this._logins.getUserController().props
this._senderAddress = getDefaultSender(logins, this._mailboxDetails)
this._entityEventReceived = (updates) => {
for (let update of updates) {
this._handleEntityEvent(update)
}
}
this._eventController.addEntityListener(this._entityEventReceived)
// TODO allow selecting notification language when changing MailEditor
// let sortedLanguages = languages.slice().sort((a, b) => lang.get(a.textId).localeCompare(lang.get(b.textId)))
this._selectedNotificationLanguage = getAvailableLanguageCode(props.notificationMailLanguage || lang.code)
// getTemplateLanguages(this._logins, sortedLanguages)
// .then((filteredLanguages) => {
// if (filteredLanguages.length > 0) {
// const languageCodes = filteredLanguages.map(l => l.code)
// this._selectedNotificationLanguage = _getSubstitutedLanguageCode(props.notificationMailLanguage
// || lang.code, languageCodes) || languageCodes[0]
// sortedLanguages = filteredLanguages
// }
// })
this._confidentialButtonState = !props.defaultUnconfidential
this._subject = stream("")
// TODO detect changes
this._subject.map(() => this._mailChanged = true)
this._mailChanged = false
}
setSubject(subject: string) {
this._subject(subject)
}
selectSender(senderAddress: string) {
this._senderAddress = senderAddress
}
getPasswordStrength(recipientInfo: RecipientInfo) {
const contact = assertNotNull(recipientInfo.contact)
let reserved = getEnabledMailAddressesWithUser(this._mailboxDetails, this._logins.getUserController().userGroupInfo).concat(
getMailboxName(this._logins, this._mailboxDetails),
recipientInfo.mailAddress,
recipientInfo.name
)
return Math.min(100, getPasswordStrength(contact.presharedPassword || "", reserved) / 0.8)
}
initAsResponse({
previousMail, conversationType, senderMailAddress, recipients, attachments, subject, bodyText, replyTos,
addSignature, inlineImages, blockExternalContent
}: {
previousMail: Mail,
conversationType: ConversationTypeEnum,
senderMailAddress: string,
recipients: Recipients,
attachments: TutanotaFile[],
subject: string,
bodyText: string,
replyTos: EncryptedMailAddress[],
addSignature: boolean,
inlineImages?: ?Promise<InlineImages>,
blockExternalContent: boolean
}): Promise<void> {
this._blockExternalContent = blockExternalContent
if (addSignature) {
bodyText = "<br/><br/><br/>" + bodyText
let signature = getEmailSignature()
if (this._logins.getUserController().isInternalUser() && signature) {
bodyText = signature + bodyText
}
}
let previousMessageId: ?string = null
return load(ConversationEntryTypeRef, previousMail.conversationEntry)
.then(ce => {
previousMessageId = ce.messageId
})
.catch(NotFoundError, e => {
console.log("could not load conversation entry", e);
})
.then(() => {
return this._setMailData(previousMail, previousMail.confidential, conversationType, previousMessageId, senderMailAddress,
recipients, attachments, subject, bodyText, replyTos)
})
}
initWithTemplate(recipients: Recipients, subject: string, bodyText: string, confidential: ?boolean, senderMailAddress?: string): Promise<void> {
const sender = senderMailAddress ? senderMailAddress : this._senderAddress
this._setMailData(null, confidential, ConversationType.NEW, null, sender, recipients, [], subject, bodyText, [])
return Promise.resolve()
}
initFromDraft({draftMail, attachments, bodyText, inlineImages, blockExternalContent}: {
draftMail: Mail,
attachments: TutanotaFile[],
bodyText: string,
blockExternalContent: boolean,
inlineImages?: Promise<InlineImages>
}): Promise<void> {
let conversationType: ConversationTypeEnum = ConversationType.NEW
let previousMessageId: ?string = null
let previousMail: ?Mail = null
this.draft = draftMail
this._blockExternalContent = blockExternalContent
return load(ConversationEntryTypeRef, draftMail.conversationEntry).then(ce => {
conversationType = downcast(ce.conversationType)
if (ce.previous) {
return load(ConversationEntryTypeRef, ce.previous).then(previousCe => {
previousMessageId = previousCe.messageId
if (previousCe.mail) {
return load(MailTypeRef, previousCe.mail).then(mail => {
previousMail = mail
})
}
}).catch(NotFoundError, e => {
// ignore
})
}
}).then(() => {
const {confidential, sender, toRecipients, ccRecipients, bccRecipients, subject, replyTos} = draftMail
const recipients: Recipients = {
to: toRecipients.map(toRecipient),
cc: ccRecipients.map(toRecipient),
bcc: bccRecipients.map(toRecipient),
}
// We don't want to wait for the editor to be initialized, otherwise it will never be shown
return this._setMailData(previousMail, confidential, conversationType, previousMessageId, sender.address, recipients, attachments,
subject, bodyText, replyTos)
})
}
_setMailData(previousMail: ?Mail, confidential: ?boolean, conversationType: ConversationTypeEnum, previousMessageId: ?string,
senderMailAddress: string, recipients: Recipients, attachments: $ReadOnlyArray<TutanotaFile>, subject: string,
body: string, replyTos: EncryptedMailAddress[]): Promise<void> {
this._previousMail = previousMail
this._conversationType = conversationType
this._previousMessageId = previousMessageId
if (confidential != null) {
this._confidentialButtonState = confidential
}
this._senderAddress = senderMailAddress
this._subject(subject)
this._attachments = []
this.attachFiles(attachments)
const makeRecipientInfo = (r: Recipient) => this._createRecipientInfo(r.name, r.address, r.contact, false)
const {to = [], cc = [], bcc = []} = recipients
this._toRecipients = to.filter(r => isMailAddress(r.address, false))
.map(makeRecipientInfo)
this._ccRecipients = cc.filter(r => isMailAddress(r.address, false))
.map(makeRecipientInfo)
this._bccRecipients = bcc.filter(r => isMailAddress(r.address, false))
.map(makeRecipientInfo)
this._replyTos = replyTos.map(ema => {
const ri = createRecipientInfo(ema.address, ema.name, null)
if (this._logins.isInternalUserLoggedIn()) {
resolveRecipientInfoContact(ri, this._contactModel, this._logins.getUserController().user)
.then(() => this.recipientsChanged(undefined))
}
return ri
})
this._mailChanged = false
return Promise.resolve()
}
_createRecipientInfo(name: ?string, address: string, contact: ?Contact, resolveLazily: boolean): RecipientInfo {
const ri = createRecipientInfo(address, name, contact)
if (!resolveLazily) {
if (this._logins.isInternalUserLoggedIn()) {
resolveRecipientInfoContact(ri, this._contactModel, this._logins.getUserController().user)
.then(() => this.recipientsChanged(undefined))
}
resolveRecipientInfo(this._mailModel, ri).then(() => this.recipientsChanged(undefined))
}
return ri
}
addRecipient(type: RecipientField, recipient: Recipient, resolveLazily: boolean = false): RecipientInfo {
const recipientInfo = this._createRecipientInfo(recipient.name, recipient.address, recipient.contact, resolveLazily)
this._recipientList(type).push(recipientInfo)
this._mailChanged = true
this.recipientsChanged(undefined)
return recipientInfo
}
removeRecipient(type: RecipientField, recipient: RecipientInfo) {
remove(this._recipientList(type), recipient)
this.recipientsChanged(undefined)
}
setPassword(recipient: RecipientInfo, password: string) {
if (recipient.contact) {
recipient.contact.presharedPassword = password
}
this.recipientsChanged(undefined)
return recipient
}
_recipientList(type: RecipientField): Array<RecipientInfo> {
if (type === "to") {
return this._toRecipients
} else if (type === "cc") {
return this._ccRecipients
} else if (type === "bcc") {
return this._bccRecipients
}
throw new Error()
}
dispose() {
this._eventController.removeEntityListener(this._entityEventReceived)
}
/** @throws UserError in case files are too big to add */
attachFiles(files: $ReadOnlyArray<EditorAttachment>): void {
let totalSize = 0
this._attachments.forEach(file => {
totalSize += Number(file.size)
})
const tooBigFiles: Array<string> = [];
files.forEach(file => {
if (totalSize + Number(file.size) > MAX_ATTACHMENT_SIZE) {
tooBigFiles.push(file.name)
} else {
totalSize += Number(file.size)
this._attachments.push(file)
}
})
if (tooBigFiles.length > 0) {
throw new UserError(() => lang.get("tooBigAttachment_msg") + tooBigFiles.join(", "))
}
this._mailChanged = true
}
/**
* Saves the draft.
* @param saveAttachments True if also the attachments shall be saved, false otherwise.
* @returns {Promise} When finished.
* @throws FileNotFoundError when one of the attachments could not be opened
* @throws PreconditionFailedError when the draft is locked
*/
saveDraft(body: string, saveAttachments: boolean, mailMethod: MailMethodEnum): Promise<void> {
const attachments = (saveAttachments) ? this._attachments : null
const {draft} = this
return Promise.resolve(draft == null
? this._createDraft(body, attachments, mailMethod)
: this._updateDraft(body, attachments, draft)
).then((draft) => {
this.draft = draft
return Promise.map(draft.attachments, fileId => load(FileTypeRef, fileId)).then(attachments => {
this._attachments = [] // attachFiles will push to existing files but we want to overwrite them
this.attachFiles(attachments)
this._mailChanged = false
})
})
}
_getSenderName() {
return getSenderNameForUser(this._mailboxDetails, this._logins.getUserController())
}
_updateDraft(body: string, attachments: ?$ReadOnlyArray<EditorAttachment>, draft: Mail) {
return worker
.updateMailDraft(this._subject(), body, this._senderAddress, this._getSenderName(), this._toRecipients,
this._ccRecipients, this._bccRecipients, attachments, this.isConfidential(), draft)
.catch(LockedError, (e) => {
console.log("updateDraft: operation is still active", e)
throw new UserError("operationStillActive_msg")
})
.catch(NotFoundError, () => {
console.log("draft has been deleted, creating new one")
return this._createDraft(body, attachments, downcast(draft.method))
})
}
_createDraft(body: string, attachments: ?$ReadOnlyArray<EditorAttachment>, mailMethod: MailMethodEnum): Promise<Mail> {
return worker.createMailDraft(this._subject(), body,
this._senderAddress, this._getSenderName(), this._toRecipients, this._ccRecipients, this._bccRecipients, this._conversationType,
this._previousMessageId, attachments, this.isConfidential(), this._replyTos, mailMethod)
}
isConfidential(): boolean {
return this._confidentialButtonState || !this._containsExternalRecipients()
}
setConfidential(confidentialButtonState: boolean): void {
this._confidentialButtonState = confidentialButtonState
}
_containsExternalRecipients(): boolean {
return (this._allRecipients().find(r => isExternal(r)) != null)
}
/**
* @param calendarFileMethods map from file id to calendar method
* @reject {RecipientNotResolvedError}
* @reject {RecipientsNotFoundError}
* @reject {TooManyRequestsError}
* @reject {AccessBlockedError}
* @reject {FileNotFoundError}
* @reject {PreconditionFailedError}
* @reject {LockedError}
* @reject {UserError}
*/
send(body: string, mailMethod: MailMethodEnum): Promise<*> {
return Promise
.resolve()
.then(() => {
if (this._toRecipients.length === 0 && this._ccRecipients.length === 0 && this._bccRecipients.length === 0) {
throw new UserError("noRecipients_msg")
}
})
.then(() => {
return this
._waitForResolvedRecipients() // Resolve all added recipients before trying to send it
.then((recipients) => {
if (recipients.length === 1 && recipients[0].mailAddress.toLowerCase().trim() === "approval@tutao.de") {
return [recipients, true]
} else {
return this.saveDraft(body, /*saveAttachments*/true, mailMethod)
.return([recipients, false])
}
})
.then(([resolvedRecipients, isApprovalMail]) => {
if (isApprovalMail) {
return this._sendApprovalMail(body)
} else {
let externalRecipients = resolvedRecipients.filter(r => isExternal(r))
if (this._confidentialButtonState && externalRecipients.length > 0
&& externalRecipients.some(r => r.contact
&& (r.contact.presharedPassword == null || r.contact.presharedPassword.trim() === ""))) {
throw new UserError("noPreSharedPassword_msg")
}
let sendMailConfirm = Promise.resolve(true)
return sendMailConfirm.then(ok => {
if (ok) {
return this._updateContacts(resolvedRecipients)
.then(() => worker.sendMailDraft(
neverNull(this.draft),
resolvedRecipients,
this._selectedNotificationLanguage,
))
.then(() => this._updatePreviousMail())
.then(() => this._updateExternalLanguage())
.catch(LockedError, () => {throw new UserError("operationStillActive_msg")})
}
})
}
})
.catch(RecipientNotResolvedError, () => {throw new UserError("tooManyAttempts_msg")})
.catch(RecipientsNotFoundError, (e) => {
let invalidRecipients = e.message.join("\n")
throw new UserError(() => lang.get("invalidRecipients_msg") + "\n" + invalidRecipients)
})
.catch(TooManyRequestsError, () => {throw new UserError("tooManyMails_msg")})
.catch(AccessBlockedError, e => {
// special case: the approval status is set to SpamSender, but the update has not been received yet, so use SpamSender as default
return checkApprovalStatus(true, "4")
.then(() => {
console.log("could not send mail (blocked access)", e)
})
})
.catch(FileNotFoundError, () => {throw new UserError("couldNotAttachFile_msg")})
.catch(PreconditionFailedError, () => {throw new UserError("operationStillActive_msg")})
})
}
_sendApprovalMail(body: string) {
const listId = "---------c--";
const m = createApprovalMail({
_id: [listId, stringToCustomId(this._senderAddress)],
_ownerGroup: this._logins.getUserController().user.userGroup.group,
text: `Subject: ${this._subject()}<br>${body}`,
})
return setup(listId, m)
.catch(NotAuthorizedError, e => console.log("not authorized for approval message"))
}
_updateExternalLanguage() {
let props = this._logins.getUserController().props
if (props.notificationMailLanguage !== this._selectedNotificationLanguage) {
props.notificationMailLanguage = this._selectedNotificationLanguage
update(props)
}
}
_updatePreviousMail(): Promise<void> {
if (this._previousMail) {
if (this._previousMail.replyType === ReplyType.NONE && this._conversationType === ConversationType.REPLY) {
this._previousMail.replyType = ReplyType.REPLY
} else if (this._previousMail.replyType === ReplyType.NONE
&& this._conversationType === ConversationType.FORWARD) {
this._previousMail.replyType = ReplyType.FORWARD
} else if (this._previousMail.replyType === ReplyType.FORWARD
&& this._conversationType === ConversationType.REPLY) {
this._previousMail.replyType = ReplyType.REPLY_FORWARD
} else if (this._previousMail.replyType === ReplyType.REPLY
&& this._conversationType === ConversationType.FORWARD) {
this._previousMail.replyType = ReplyType.REPLY_FORWARD
} else {
return Promise.resolve()
}
return update(this._previousMail).catch(NotFoundError, e => {
// ignore
})
} else {
return Promise.resolve();
}
}
_updateContacts(resolvedRecipients: RecipientInfo[]): Promise<any> {
return Promise.all(resolvedRecipients.map(r => {
const {contact} = r
if (contact) {
if (!contact._id
&& (!this._logins.getUserController().props.noAutomaticContacts || (isExternal(r) && this._confidentialButtonState))
) {
if (isExternal(r) && this._confidentialButtonState) {
contact.presharedPassword = this._getPassword(r).trim()
}
return LazyContactListId.getAsync().then(listId => {
return setup(listId, contact)
})
} else if (contact._id
&& isExternal(r)
&& this._confidentialButtonState
&& contact.presharedPassword !== this._getPassword(r).trim()
) {
contact.presharedPassword = this._getPassword(r).trim()
return update(contact)
} else {
return Promise.resolve()
}
} else {
return Promise.resolve()
}
}))
}
_getPassword(r: RecipientInfo): string {
return r.contact && r.contact.presharedPassword || ""
}
_allRecipients(): Array<RecipientInfo> {
return this._toRecipients
.concat(this._ccRecipients)
.concat(this._bccRecipients)
}
/**
* Makes sure the recipient type and contact are resolved.
*/
_waitForResolvedRecipients(): Promise<RecipientInfo[]> {
return Promise.all(this._allRecipients().map(recipientInfo => {
return resolveRecipientInfo(this._mailModel, recipientInfo).then(recipientInfo => {
if (recipientInfo.resolveContactPromise) {
return recipientInfo.resolveContactPromise.return(recipientInfo)
} else {
return recipientInfo
}
})
})).catch(TooManyRequestsError, () => {
throw new RecipientNotResolvedError()
})
}
_handleEntityEvent(update: EntityUpdateData): void {
const {operation, instanceId, instanceListId} = update
if (isUpdateForTypeRef(ContactTypeRef, update)
&& (operation === OperationType.UPDATE || operation === OperationType.DELETE)) {
let contactId: IdTuple = [neverNull(instanceListId), instanceId]
this._allRecipients().forEach(recipient => {
if (recipient.contact && recipient.contact._id && isSameId(recipient.contact._id, contactId)) {
if (operation === OperationType.UPDATE) {
// TODO
// this._updateBubble(bubbles, bubble, contactId)
} else {
// TODO
// this._removeBubble(bubble)
}
}
})
}
}
}