tutanota/src/mail/editor/SendMailModel.ts

1126 lines
37 KiB
TypeScript
Raw Normal View History

import {ApprovalStatus, ConversationType, MailFolderType, MailMethod, MAX_ATTACHMENT_SIZE, OperationType, ReplyType} from "../../api/common/TutanotaConstants"
2021-02-03 17:13:38 +01:00
import type {RecipientInfo} from "../../api/common/RecipientInfo"
import {isExternal, makeRecipientDetails} from "../../api/common/RecipientInfo"
import {
2021-12-28 13:53:11 +01:00
AccessBlockedError,
LockedError,
NotAuthorizedError,
NotFoundError,
PayloadTooLargeError,
PreconditionFailedError,
TooManyRequestsError,
2021-02-03 17:13:38 +01:00
} from "../../api/common/error/RestError"
import {UserError} from "../../api/main/UserError"
import {getPasswordStrengthForUser, isSecurePassword, PASSWORD_MIN_SECURE_VALUE} from "../../misc/PasswordUtils"
import type {lazy} from "@tutao/tutanota-utils"
import {cleanMatch, deduplicate, downcast, getFromMap, neverNull, noOp, ofClass, promiseMap, remove, typedValues} from "@tutao/tutanota-utils"
2021-12-28 13:53:11 +01:00
import {
checkAttachmentSize,
createRecipientInfo,
getDefaultSender,
getEnabledMailAddressesWithUser,
getSenderNameForUser,
getTemplateLanguages,
RecipientField,
2021-12-28 13:53:11 +01:00
recipientInfoToDraftRecipient,
recipientInfoToEncryptedMailAddress,
resolveRecipientInfo,
resolveRecipientInfoContact,
2021-02-03 17:13:38 +01:00
} from "../model/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 {FileNotFoundError} from "../../api/common/error/FileNotFoundError"
import type {LoginController} from "../../api/main/LoginController"
import {logins} from "../../api/main/LoginController"
import type {MailAddress} from "../../api/entities/tutanota/MailAddress"
import type {MailboxDetail, MailModel} from "../model/MailModel"
2021-02-03 17:13:38 +01:00
import {RecipientNotResolvedError} from "../../api/common/error/RecipientNotResolvedError"
import stream from "mithril/stream"
import Stream from "mithril/stream"
2021-02-03 17:13:38 +01:00
import type {EntityEventsListener, EntityUpdateData} from "../../api/main/EventController"
import {EventController, isUpdateForTypeRef} from "../../api/main/EventController"
import {isMailAddress} from "../../misc/FormatValidator"
import {createApprovalMail} from "../../api/entities/monitor/ApprovalMail"
import type {EncryptedMailAddress} from "../../api/entities/tutanota/EncryptedMailAddress"
import type {ContactModel} from "../../contacts/model/ContactModel"
2021-05-27 15:14:41 +02:00
import type {Language, TranslationKey, TranslationText} from "../../misc/LanguageViewModel"
2021-02-03 17:13:38 +01:00
import {_getSubstitutedLanguageCode, getAvailableLanguageCode, lang, languages} from "../../misc/LanguageViewModel"
import type {IUserController} from "../../api/main/UserController"
import {RecipientsNotFoundError} from "../../api/common/error/RecipientsNotFoundError"
import {checkApprovalStatus} from "../../misc/LoginUtils"
import {EntityClient} from "../../api/common/EntityClient"
import {locator} from "../../api/main/MainLocator"
import {getContactDisplayName} from "../../contacts/model/ContactUtils"
2021-12-23 14:03:23 +01:00
import {getListId, isSameId, stringToCustomId} from "../../api/common/utils/EntityUtils"
import {CustomerPropertiesTypeRef} from "../../api/entities/sys/CustomerProperties"
2021-07-02 14:34:05 +02:00
import type {InlineImages} from "../view/MailViewer"
import {cloneInlineImages, revokeInlineImages} from "../view/MailGuiUtils"
import {MailBodyTooLargeError} from "../../api/common/error/MailBodyTooLargeError"
import type {MailFacade} from "../../api/worker/facades/MailFacade"
import {assertMainOrNode} from "../../api/common/Env"
2021-12-23 14:03:23 +01:00
import {DataFile} from "../../api/common/DataFile";
2021-12-28 13:53:11 +01:00
import {FileReference} from "../../api/common/utils/FileUtils"
assertMainOrNode()
2021-12-23 14:03:23 +01:00
export const TOO_MANY_VISIBLE_RECIPIENTS = 10
export type Recipient = {
2021-12-28 13:53:11 +01:00
name: string | null
address: string
contact?: Contact | null
2021-12-23 14:03:23 +01:00
}
export type RecipientList = ReadonlyArray<Recipient>
export type Recipients = {
2021-12-28 13:53:11 +01:00
to?: RecipientList
cc?: RecipientList
bcc?: RecipientList
2021-12-23 14:03:23 +01:00
}
2021-12-28 13:53:11 +01:00
2021-12-23 14:03:23 +01:00
export function makeRecipient(address: string, name: string | null, contact: Contact | null): Recipient {
2021-12-28 13:53:11 +01:00
return {
name,
address,
contact,
}
}
2021-12-28 13:53:11 +01:00
export function makeRecipients(to: RecipientList, cc: RecipientList, bcc: RecipientList): Recipients {
2021-12-28 13:53:11 +01:00
return {
to,
cc,
bcc,
}
}
2021-12-28 13:53:11 +01:00
// Because MailAddress does not have contact of the right type (event when renamed on Recipient) MailAddress <: Recipient does not hold
export function mailAddressToRecipient({address, name}: MailAddress): Recipient {
2021-12-28 13:53:11 +01:00
return {
name,
address,
}
}
2021-12-28 13:53:11 +01:00
export type Attachment = TutanotaFile | DataFile | FileReference
export type ResponseMailParameters = {
2021-12-28 13:53:11 +01:00
previousMail: Mail
conversationType: ConversationType
senderMailAddress: string
toRecipients: MailAddress[]
ccRecipients: MailAddress[]
bccRecipients: MailAddress[]
attachments: TutanotaFile[]
subject: string
bodyText: string
replyTos: EncryptedMailAddress[]
}
/**
* Simple blocking wait handler implementation which just ignores messages and can be used to silently save a draft without showing progress.
*/
export function noopBlockingWaitHandler<T>(messageIdOrMessageFunction: TranslationKey | lazy<string>, action: Promise<T>): Promise<T> {
2021-12-28 13:53:11 +01:00
return action
}
/**
* Model which allows sending mails interactively - including resolving of recipients and handling of drafts.
*/
export class SendMailModel {
private _mailFacade: MailFacade
private _entity: EntityClient
private _logins: LoginController
private _mailModel: MailModel
private _contactModel: ContactModel
private _eventController: EventController
private _mailboxDetails: MailboxDetail
private _conversationType: ConversationType
private _subject: string // we're setting subject to the value of the subject TextField in the MailEditor
2021-12-28 13:53:11 +01:00
private _body: string
// Isn't private because used by MinimizedEditorOverlay, refactor?
draft: Mail | null
private _recipients: Map<RecipientField, Array<RecipientInfo>>
private _senderAddress: string
private _isConfidential: boolean
private _attachments: Array<Attachment> // contains either Files from Tutanota or DataFiles of locally loaded files. these map 1:1 to the _attachmentButtons
2021-12-28 13:53:11 +01:00
private _replyTos: Array<RecipientInfo>
private _previousMessageId: Id | null // only needs to be the correct value if this is a new email. if we are editing a draft, conversationType is not used
2021-12-28 13:53:11 +01:00
private _previousMail: Mail | null
private _selectedNotificationLanguage: string
private _availableNotificationTemplateLanguages!: Array<Language>
private _entityEventReceived: EntityEventsListener
private _mailChanged: boolean
private _passwords: Map<string, string>
2021-12-28 13:53:11 +01:00
onMailChanged: Stream<boolean>
onRecipientDeleted: Stream<{field: RecipientField, recipient: RecipientInfo} | null>
2021-12-28 13:53:11 +01:00
onBeforeSend: () => unknown
loadedInlineImages: InlineImages
// The promise for the draft currently being saved
private currentSavePromise: Promise<void> | null = null
// If saveDraft is called while the previous call is still running, then flag to call again afterwards
private doSaveAgain: boolean = false
2021-12-28 13:53:11 +01:00
/**
* creates a new empty draft message. calling an init method will fill in all the blank data
*/
constructor(
mailFacade: MailFacade,
logins: LoginController,
mailModel: MailModel,
contactModel: ContactModel,
eventController: EventController,
entity: EntityClient,
mailboxDetails: MailboxDetail,
2021-12-28 13:53:11 +01:00
) {
this._mailFacade = mailFacade
this._entity = entity
this._logins = logins
this._mailModel = mailModel
this._contactModel = contactModel
this._eventController = eventController
this._mailboxDetails = mailboxDetails
const userProps = logins.getUserController().props
this._conversationType = ConversationType.NEW
this._subject = ""
this._body = ""
this.draft = null
2021-12-28 13:53:11 +01:00
this._recipients = new Map()
this._senderAddress = this._getDefaultSender()
this._isConfidential = !userProps.defaultUnconfidential
this._attachments = []
this._replyTos = []
this._previousMessageId = null
this._previousMail = null
this._selectedNotificationLanguage = getAvailableLanguageCode(userProps.notificationMailLanguage || lang.code)
this.updateAvailableNotificationTemplateLanguages()
this._entityEventReceived = updates => {
return promiseMap(updates, update => {
return this._handleEntityEvent(update)
}).then(noOp)
}
this._eventController.addEntityListener(this._entityEventReceived)
this._passwords = new Map()
this._mailChanged = false
this.onMailChanged = stream(false)
this.onRecipientDeleted = stream(null)
this.onBeforeSend = noOp
this.loadedInlineImages = new Map()
}
/**
* Sort list of all languages alphabetically
* then we see if the user has custom notification templates
* in which case we replace the list with just the templates that the user has specified
*/
updateAvailableNotificationTemplateLanguages(): Promise<void> {
this._availableNotificationTemplateLanguages = languages.slice().sort((a, b) => lang.get(a.textId).localeCompare(lang.get(b.textId)))
return getTemplateLanguages(this._availableNotificationTemplateLanguages, this._entity, this._logins).then(filteredLanguages => {
if (filteredLanguages.length > 0) {
const languageCodes = filteredLanguages.map(l => l.code)
this._selectedNotificationLanguage =
_getSubstitutedLanguageCode(this._logins.getUserController().props.notificationMailLanguage || lang.code, languageCodes) || languageCodes[0]
2021-12-28 13:53:11 +01:00
this._availableNotificationTemplateLanguages = filteredLanguages
}
})
}
logins(): LoginController {
return this._logins
}
user(): IUserController {
return this.logins().getUserController()
}
contacts(): ContactModel {
return this._contactModel
}
mails(): MailModel {
return this._mailModel
}
mailFacade(): MailFacade {
return this._mailFacade
}
events(): EventController {
return this._eventController
}
entity(): EntityClient {
return this._entity
}
getPreviousMail(): Mail | null {
return this._previousMail
}
getMailboxDetails(): MailboxDetail {
return this._mailboxDetails
}
getConversationType(): ConversationType {
return this._conversationType
}
setPassword(mailAddress: string, password: string) {
this._passwords.set(mailAddress, password)
this.setMailChanged(true)
}
getPassword(mailAddress: string): string {
return this._passwords.get(mailAddress) || ""
}
getSubject(): string {
return this._subject
}
setSubject(subject: string) {
this._mailChanged = subject !== this._subject
this._subject = subject
}
getBody(): string {
return this._body
}
setBody(body: string) {
this._body = body
this.setMailChanged(true)
}
setSender(senderAddress: string) {
this._senderAddress = senderAddress
this.setMailChanged(true)
}
getSender(): string {
return this._senderAddress
}
/**
* Returns the strength indicator for the recipients password
* @param recipientInfo
* @returns value between 0 and 100
*/
getPasswordStrength(recipientInfo: RecipientInfo): number {
return getPasswordStrengthForUser(this.getPassword(recipientInfo.mailAddress), recipientInfo, this._mailboxDetails, this._logins)
}
getEnabledMailAddresses(): Array<string> {
return getEnabledMailAddressesWithUser(this._mailboxDetails, this.user().userGroupInfo)
}
hasMailChanged(): boolean {
return this._mailChanged
}
setMailChanged(hasChanged: boolean) {
this._mailChanged = hasChanged
this.onMailChanged(hasChanged) // if this method is called wherever state gets changed, onMailChanged should function properly
}
/**
*
* @param recipients
* @param subject
* @param bodyText
* @param attachments
* @param confidential
* @param senderMailAddress
* @returns {Promise<SendMailModel>}
*/
initWithTemplate(
recipients: Recipients,
subject: string,
bodyText: string,
attachments?: ReadonlyArray<Attachment>,
confidential?: boolean,
senderMailAddress?: string,
2021-12-28 13:53:11 +01:00
): Promise<SendMailModel> {
return this._init({
conversationType: ConversationType.NEW,
subject,
bodyText,
recipients,
attachments,
confidential,
senderMailAddress,
})
}
async initAsResponse(args: ResponseMailParameters, inlineImages: InlineImages): Promise<SendMailModel> {
2021-12-28 13:53:11 +01:00
const {
previousMail,
conversationType,
senderMailAddress,
toRecipients,
ccRecipients,
bccRecipients,
attachments,
subject,
bodyText,
replyTos
} = args
const recipients = {
to: toRecipients.map(mailAddressToRecipient),
cc: ccRecipients.map(mailAddressToRecipient),
bcc: bccRecipients.map(mailAddressToRecipient),
}
let previousMessageId: string | null = null
await this._entity
.load(ConversationEntryTypeRef, previousMail.conversationEntry)
.then(ce => {
previousMessageId = ce.messageId
})
.catch(
ofClass(NotFoundError, e => {
console.log("could not load conversation entry", e)
}),
)
2021-12-28 13:53:11 +01:00
// if we reuse the same image references, changing the displayed mail in mail view will cause the minimized draft to lose
// that reference, because it will be revoked
this.loadedInlineImages = cloneInlineImages(inlineImages)
2021-12-28 13:53:11 +01:00
return this._init({
conversationType,
subject,
bodyText,
recipients,
senderMailAddress,
confidential: previousMail.confidential,
attachments,
replyTos,
previousMail,
previousMessageId,
})
}
async initWithDraft(draft: Mail, attachments: TutanotaFile[], bodyText: string, inlineImages: InlineImages): Promise<SendMailModel> {
2021-12-28 13:53:11 +01:00
let previousMessageId: string | null = null
let previousMail: Mail | null = null
const conversationEntry = await this._entity.load(ConversationEntryTypeRef, draft.conversationEntry)
const conversationType = downcast<ConversationType>(conversationEntry.conversationType)
if (conversationEntry.previous) {
try {
const previousEntry = await this._entity.load(ConversationEntryTypeRef, conversationEntry.previous)
previousMessageId = previousEntry.messageId
if (previousEntry.mail) {
previousMail = await this._entity.load(MailTypeRef, previousEntry.mail)
}
} catch (e) {
if (e instanceof NotFoundError) {
// ignore
} else {
throw e
}
2021-12-28 13:53:11 +01:00
}
}
2021-12-28 13:53:11 +01:00
// if we reuse the same image references, changing the displayed mail in mail view will cause the minimized draft to lose
// that reference, because it will be revoked
this.loadedInlineImages = cloneInlineImages(inlineImages)
2021-12-28 13:53:11 +01:00
const {confidential, sender, toRecipients, ccRecipients, bccRecipients, subject, replyTos} = draft
const recipients: Recipients = {
to: toRecipients.map(mailAddressToRecipient),
cc: ccRecipients.map(mailAddressToRecipient),
bcc: bccRecipients.map(mailAddressToRecipient),
}
return this._init({
conversationType: conversationType,
subject,
bodyText,
recipients,
draft,
senderMailAddress: sender.address,
confidential,
attachments,
replyTos,
previousMail,
previousMessageId,
})
}
_init({
conversationType,
subject,
bodyText,
draft,
recipients,
senderMailAddress,
confidential,
attachments,
replyTos,
previousMail,
previousMessageId,
}: {
conversationType: ConversationType
subject: string
bodyText: string
recipients: Recipients
confidential: boolean | null | undefined
draft?: Mail | null | undefined
2021-12-28 13:53:11 +01:00
senderMailAddress?: string
attachments?: ReadonlyArray<Attachment>
replyTos?: EncryptedMailAddress[]
previousMail?: Mail | null | undefined
previousMessageId?: string | null | undefined
2021-12-28 13:53:11 +01:00
}): Promise<SendMailModel> {
this._conversationType = conversationType
this._subject = subject
this._body = bodyText
this.draft = draft || null
2021-12-28 13:53:11 +01:00
const {to = [], cc = [], bcc = []} = recipients
const makeRecipientInfo = (r: Recipient) => {
const [recipient] = this._createAndResolveRecipientInfo(r.name, r.address, r.contact, false)
if (recipient.resolveContactPromise) {
recipient.resolveContactPromise.then(() => (this._mailChanged = false))
} else {
this._mailChanged = false
}
return recipient
}
const recipientsTransform = (recipientList: RecipientList) => {
return deduplicate(
recipientList.filter(r => isMailAddress(r.address, false)),
(a, b) => a.address === b.address,
2021-12-28 13:53:11 +01:00
).map(makeRecipientInfo)
}
this._recipients.set(RecipientField.TO, recipientsTransform(to))
2021-12-28 13:53:11 +01:00
this._recipients.set(RecipientField.CC, recipientsTransform(cc))
2021-12-28 13:53:11 +01:00
this._recipients.set(RecipientField.BCC, recipientsTransform(bcc))
2021-12-28 13:53:11 +01:00
this._senderAddress = senderMailAddress || this._getDefaultSender()
this._isConfidential = confidential == null ? !this.user().props.defaultUnconfidential : confidential
this._attachments = []
if (attachments) {
this.attachFiles(attachments)
this._mailChanged = false
}
this._replyTos = (replyTos || []).map(ema => {
const ri = createRecipientInfo(ema.address, ema.name, null)
if (this._logins.isInternalUserLoggedIn()) {
resolveRecipientInfoContact(ri, this._contactModel, this.user().user).then(() => {
this.onMailChanged(true)
})
}
return ri
})
this._previousMail = previousMail || null
this._previousMessageId = previousMessageId || null
this._mailChanged = false
return Promise.resolve(this)
}
_getDefaultSender(): string {
return getDefaultSender(this._logins, this._mailboxDetails)
}
getRecipientList(type: RecipientField): Array<RecipientInfo> {
return getFromMap(this._recipients, type, () => [])
}
toRecipients(): Array<RecipientInfo> {
return this.getRecipientList(RecipientField.TO)
2021-12-28 13:53:11 +01:00
}
ccRecipients(): Array<RecipientInfo> {
return this.getRecipientList(RecipientField.CC)
2021-12-28 13:53:11 +01:00
}
bccRecipients(): Array<RecipientInfo> {
return this.getRecipientList(RecipientField.BCC)
2021-12-28 13:53:11 +01:00
}
/**
* Either creates and inserts a new recipient to the list if a recipient with the same mail address doesn't already exist
* Otherwise it returns the existing recipient info - recipients which also have the same contact are prioritized
*
* Note: Duplication is only avoided per recipient field (to, cc, bcc), but a recipient may be duplicated between them
* @param type
* @param recipient
* @param skipResolveContact
* @param notify: whether or not to notify onRecipientAdded listeners
* @returns {RecipientInfo}
*/
addOrGetRecipient(type: RecipientField, recipient: Recipient, skipResolveContact: boolean = false): [RecipientInfo, Promise<RecipientInfo>] {
// if recipients with same mail address exist
// if one of them also has the same contact, use that one
// else use an arbitrary one
// else make a new one and give it to the model
const sameAddressRecipients = this.getRecipientList(type).filter(r => r.mailAddress === recipient.address)
const perfectMatch = sameAddressRecipients.find(r => recipient.contact && r.contact && isSameId(recipient.contact._id, r.contact._id))
let recipientInfo = perfectMatch || sameAddressRecipients[0]
// if the contact has a password, add it to the password map, but don't override it if one exists for that mailaddress already
if (recipient.contact && !this._passwords.has(recipient.address)) {
this._passwords.set(recipient.address, recipient.contact.presharedPassword || "")
}
// make a new recipient info if we don't have one for that recipient
if (!recipientInfo) {
let p: Promise<RecipientInfo>
;[recipientInfo, p] = this._createAndResolveRecipientInfo(recipient.name, recipient.address, recipient.contact, skipResolveContact)
this.getRecipientList(type).push(recipientInfo)
this.setMailChanged(true)
return [recipientInfo, p]
} else {
return [recipientInfo, Promise.resolve(recipientInfo)]
}
}
_createAndResolveRecipientInfo(
name: string | null,
address: string,
contact: Contact | null | undefined,
skipResolveContact: boolean,
2021-12-28 13:53:11 +01:00
): [RecipientInfo, Promise<RecipientInfo>] {
const ri = createRecipientInfo(address, name, contact ?? null)
2021-12-28 13:53:11 +01:00
let p: Promise<RecipientInfo>
if (!skipResolveContact) {
if (this._logins.isInternalUserLoggedIn()) {
resolveRecipientInfoContact(ri, this._contactModel, this.user().user).then(contact => {
if (!this._passwords.has(address)) {
this.setPassword(address, contact.presharedPassword || "")
}
})
}
p = resolveRecipientInfo(this._mailFacade, ri).then(resolved => {
this.setMailChanged(true)
return resolved
})
} else {
p = Promise.resolve(ri)
}
return [ri, p]
}
removeRecipient(recipient: RecipientInfo, type: RecipientField, notify: boolean = true): boolean {
const didRemove = remove(this.getRecipientList(type), recipient)
this.setMailChanged(didRemove)
if (didRemove && notify) {
this.onRecipientDeleted({
field: type,
recipient,
})
}
return didRemove
}
dispose() {
this._eventController.removeEntityListener(this._entityEventReceived)
revokeInlineImages(this.loadedInlineImages)
}
/**
* @param files
* @throws UserError in the case that any files were too big to attach. Small enough files will still have been attached
*/
getAttachments(): Array<Attachment> {
return this._attachments
}
/** @throws UserError in case files are too big to add */
attachFiles(files: ReadonlyArray<Attachment>): void {
let sizeLeft = MAX_ATTACHMENT_SIZE - this._attachments.reduce((total, file) => total + Number(file.size), 0)
const sizeCheckResult = checkAttachmentSize(files, sizeLeft)
this._attachments.push(...sizeCheckResult.attachableFiles)
this.setMailChanged(true)
if (sizeCheckResult.tooBigFiles.length > 0) {
throw new UserError(() => lang.get("tooBigAttachment_msg") + "\n" + sizeCheckResult.tooBigFiles.join("\n"))
}
}
removeAttachment(file: Attachment): void {
if (remove(this._attachments, file)) {
this.setMailChanged(true)
}
}
getSenderName(): string {
return getSenderNameForUser(this._mailboxDetails, this.user())
}
getDraft(): Readonly<Mail> | null {
return this.draft
2021-12-28 13:53:11 +01:00
}
_updateDraft(body: string, attachments: ReadonlyArray<Attachment> | null, draft: Mail): Promise<Mail> {
return this._mailFacade
.updateDraft(
{
subject: this.getSubject(),
body: body,
senderMailAddress: this._senderAddress,
senderName: this.getSenderName(),
toRecipients: this.toRecipients().map(recipientInfoToDraftRecipient),
ccRecipients: this.ccRecipients().map(recipientInfoToDraftRecipient),
bccRecipients: this.bccRecipients().map(recipientInfoToDraftRecipient),
attachments: attachments,
confidential: this.isConfidential(),
draft: draft
},
)
.catch(
ofClass(LockedError, e => {
console.log("updateDraft: operation is still active", e)
throw new UserError("operationStillActive_msg")
}),
)
.catch(
ofClass(NotFoundError, e => {
console.log("draft has been deleted, creating new one")
return this._createDraft(body, attachments, downcast(draft.method))
}),
)
2021-12-28 13:53:11 +01:00
}
_createDraft(body: string, attachments: ReadonlyArray<Attachment> | null, mailMethod: MailMethod): Promise<Mail> {
return this._mailFacade.createDraft(
{
subject: this.getSubject(),
bodyText: body,
senderMailAddress: this._senderAddress,
senderName: this.getSenderName(),
toRecipients: this.toRecipients().map(recipientInfoToDraftRecipient),
ccRecipients: this.ccRecipients().map(recipientInfoToDraftRecipient),
bccRecipients: this.bccRecipients().map(recipientInfoToDraftRecipient),
conversationType: this._conversationType,
previousMessageId: this._previousMessageId,
attachments: attachments,
confidential: this.isConfidential(),
replyTos: this._replyTos.map(recipientInfoToEncryptedMailAddress),
method: mailMethod
},
2021-12-28 13:53:11 +01:00
)
}
isConfidential(): boolean {
return this._isConfidential || !this.containsExternalRecipients()
}
isConfidentialExternal(): boolean {
return this._isConfidential && this.containsExternalRecipients()
}
setConfidential(confidential: boolean): void {
this._isConfidential = confidential
}
containsExternalRecipients(): boolean {
return this.allRecipients().some(r => isExternal(r))
}
getExternalRecipients(): Array<RecipientInfo> {
return this.allRecipients().filter(r => isExternal(r))
}
/**
* @reject {RecipientsNotFoundError}
* @reject {TooManyRequestsError}
* @reject {AccessBlockedError}
* @reject {FileNotFoundError}
* @reject {PreconditionFailedError}
* @reject {LockedError}
* @reject {UserError}
* @param mailMethod
* @param getConfirmation: A callback to get user confirmation
* @param waitHandler: A callback to allow UI blocking while the mail is being sent. it seems like wrapping the send call in showProgressDialog causes the confirmation dialogs not to be shown. We should fix this, but this works for now
2021-12-28 13:53:11 +01:00
* @param tooManyRequestsError
* @return true if the send was completed, false if it was aborted (by getConfirmation returning false
*/
async send(
mailMethod: MailMethod,
getConfirmation: (arg0: TranslationText) => Promise<boolean> = _ => Promise.resolve(true),
waitHandler: (arg0: TranslationText, arg1: Promise<any>) => Promise<any> = (_, p) => p,
tooManyRequestsError: TranslationKey = "tooManyMails_msg",
2021-12-28 13:53:11 +01:00
): Promise<boolean> {
this.onBeforeSend()
if (this.allRecipients().length === 1 && this.allRecipients()[0].mailAddress.toLowerCase().trim() === "approval@tutao.de") {
await this._sendApprovalMail(this.getBody())
return true
}
if (this.toRecipients().length === 0 && this.ccRecipients().length === 0 && this.bccRecipients().length === 0) {
throw new UserError("noRecipients_msg")
}
const numVisibleRecipients = this.toRecipients().length + this.ccRecipients().length
// Many recipients is a warning
if (numVisibleRecipients >= TOO_MANY_VISIBLE_RECIPIENTS && !(await getConfirmation("manyRecipients_msg"))) {
return false
}
// Empty subject is a warning
if (this.getSubject().length === 0 && !(await getConfirmation("noSubject_msg"))) {
return false
}
// The next check depends on contacts being available
await this.waitForResolvedRecipients()
// No password in external confidential mail is an error
if (this.isConfidentialExternal() && this.getExternalRecipients().some(r => !this.getPassword(r.mailAddress))) {
throw new UserError("noPreSharedPassword_msg")
}
// Weak password is a warning
if (this.isConfidentialExternal() && this.hasInsecurePasswords() && !(await getConfirmation("presharedPasswordNotStrongEnough_msg"))) {
return false
}
const doSend = async () => {
await this.saveDraft(true, mailMethod)
2021-12-28 13:53:11 +01:00
await this._updateContacts(this.allRecipients())
const allRecipients = this.allRecipients().map(({
name,
mailAddress,
type,
contact
}) => makeRecipientDetails(name, mailAddress, type, contact))
await this._mailFacade.sendDraft(neverNull(this.draft), allRecipients, this._selectedNotificationLanguage)
2021-12-28 13:53:11 +01:00
await this._updatePreviousMail()
await this._updateExternalLanguage()
return true
}
return waitHandler(this.isConfidential() ? "sending_msg" : "sendingUnencrypted_msg", doSend())
.catch(
ofClass(LockedError, () => {
throw new UserError("operationStillActive_msg")
}),
) // catch all of the badness
.catch(
ofClass(RecipientNotResolvedError, () => {
throw new UserError("tooManyAttempts_msg")
}),
)
.catch(
ofClass(RecipientsNotFoundError, e => {
if (mailMethod === MailMethod.ICAL_CANCEL) {
// in case of calendar event cancellation we will remove invalid recipients and then delete the event without sending updates
throw e
} else {
let invalidRecipients = e.message
throw new UserError(
() => lang.get("tutanotaAddressDoesNotExist_msg") + " " + lang.get("invalidRecipients_msg") + "\n" + invalidRecipients,
)
}
}),
)
.catch(
ofClass(TooManyRequestsError, () => {
throw new UserError(tooManyRequestsError)
}),
)
.catch(
ofClass(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(this._logins, true, ApprovalStatus.SPAM_SENDER).then(() => {
console.log("could not send mail (blocked access)", e)
return false
})
}),
)
.catch(
ofClass(FileNotFoundError, () => {
throw new UserError("couldNotAttachFile_msg")
}),
)
.catch(
ofClass(PreconditionFailedError, () => {
throw new UserError("operationStillActive_msg")
}),
)
2021-12-28 13:53:11 +01:00
}
/**
* Whether any of the external recipients have an insecure password.
* We don't consider empty passwords, because an empty password will disallow and encrypted email from sending, whereas an insecure password
* can still be used
* @returns {boolean}
*/
hasInsecurePasswords(): boolean {
const minimalPasswordStrength = this.allRecipients()
.filter(r => this.getPassword(r.mailAddress) !== "")
.reduce((min, recipient) => Math.min(min, this.getPasswordStrength(recipient)), PASSWORD_MIN_SECURE_VALUE)
2021-12-28 13:53:11 +01:00
return !isSecurePassword(minimalPasswordStrength)
}
saveDraft(
saveAttachments: boolean,
mailMethod: MailMethod
): Promise<void> {
if (this.currentSavePromise == null) {
this.currentSavePromise = Promise.resolve().then(async () => {
try {
await this.doSaveDraft(saveAttachments, mailMethod)
} finally {
// If there is an error, we still need to reset currentSavePromise
this.currentSavePromise = null
}
if (this._mailChanged && this.doSaveAgain) {
this.doSaveAgain = false
await this.saveDraft(saveAttachments, mailMethod)
}
})
} else {
this.doSaveAgain = true
}
return this.currentSavePromise
}
2021-12-28 13:53:11 +01:00
/**
* Saves the draft.
* @param saveAttachments True if also the attachments shall be saved, false otherwise.
* @param mailMethod
2021-12-28 13:53:11 +01:00
* @returns {Promise} When finished.
* @throws FileNotFoundError when one of the attachments could not be opened
* @throws PreconditionFailedError when the draft is locked
*/
private async doSaveDraft(
saveAttachments: boolean,
mailMethod: MailMethod,
2021-12-28 13:53:11 +01:00
): Promise<void> {
// Allow any changes that might occur while the mail is being saved to be accounted for
// if saved is called before this has completed
this._mailChanged = false
try {
const attachments = saveAttachments ? this._attachments : null
// We also want to create new drafts for drafts edited from trash or spam folder
this.draft = this.draft == null || await this.isMailInTrashOrSpam(this.draft)
? await this._createDraft(this.getBody(), attachments, mailMethod)
: await this._updateDraft(this.getBody(), attachments, this.draft)
const newAttachments = await promiseMap(
this.draft.attachments,
fileId => this._entity.load<TutanotaFile>(FileTypeRef, fileId),
{
concurrency: 5,
}
)
this._attachments = [] // attachFiles will push to existing files but we want to overwrite them
this.attachFiles(newAttachments)
} catch (e) {
if (e instanceof PayloadTooLargeError) {
throw new UserError("requestTooLarge_msg")
} else if (e instanceof MailBodyTooLargeError) {
throw new UserError("mailBodyTooLarge_msg")
} else if (e instanceof FileNotFoundError) {
throw new UserError("couldNotAttachFile_msg")
} else if (e instanceof PreconditionFailedError) {
throw new UserError("operationStillActive_msg")
} else {
throw e
}
}
}
private async isMailInTrashOrSpam(draft: Mail) {
const folders = await this._mailModel.getMailboxFolders(draft)
const trashAndMailFolders = folders.filter(f => f.folderType === MailFolderType.TRASH || f.folderType === MailFolderType.SPAM)
return trashAndMailFolders.some(folder => isSameId(folder.mails, getListId(draft)))
2021-12-28 13:53:11 +01:00
}
_sendApprovalMail(body: string): Promise<unknown> {
2021-12-28 13:53:11 +01:00
const listId = "---------c--"
const m = createApprovalMail({
_id: [listId, stringToCustomId(this._senderAddress)],
_ownerGroup: this.user().user.userGroup.group,
text: `Subject: ${this.getSubject()}<br>${body}`,
})
return this._entity.setup(listId, m).catch(ofClass(NotAuthorizedError, e => console.log("not authorized for approval message")))
}
getAvailableNotificationTemplateLanguages(): Array<Language> {
return this._availableNotificationTemplateLanguages
}
getSelectedNotificationLanguageCode(): string {
return this._selectedNotificationLanguage
}
setSelectedNotificationLanguageCode(code: string) {
this._selectedNotificationLanguage = code
this.setMailChanged(true)
}
_updateExternalLanguage() {
let props = this.user().props
if (props.notificationMailLanguage !== this._selectedNotificationLanguage) {
props.notificationMailLanguage = this._selectedNotificationLanguage
this._entity.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 this._entity.update(this._previousMail).catch(
ofClass(NotFoundError, e => {
// ignore
}),
2021-12-28 13:53:11 +01:00
)
} else {
return Promise.resolve()
}
}
_updateContacts(resolvedRecipients: RecipientInfo[]): Promise<any> {
return Promise.all(
resolvedRecipients.map(r => {
const {mailAddress, contact} = r
if (!contact) return Promise.resolve()
const isExternalAndConfidential = isExternal(r) && this.isConfidential()
if (!contact._id && (!this.user().props.noAutomaticContacts || isExternalAndConfidential)) {
if (isExternalAndConfidential) {
contact.presharedPassword = this.getPassword(r.mailAddress).trim()
2021-12-28 13:53:11 +01:00
}
return this._contactModel.contactListId().then(listId => {
return this._entity.setup(listId, contact)
})
} else if (contact._id && isExternalAndConfidential && contact.presharedPassword !== this.getPassword(mailAddress).trim()) {
contact.presharedPassword = this.getPassword(mailAddress).trim()
return this._entity.update(contact)
} else {
return Promise.resolve()
}
}),
2021-12-28 13:53:11 +01:00
)
}
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._mailFacade, recipientInfo).then(recipientInfo => {
if (recipientInfo.resolveContactPromise) {
return recipientInfo.resolveContactPromise.then(() => recipientInfo)
} else {
return recipientInfo
}
})
}),
2021-12-28 13:53:11 +01:00
).catch(
ofClass(TooManyRequestsError, () => {
throw new RecipientNotResolvedError("")
}),
2021-12-28 13:53:11 +01:00
)
}
_handleEntityEvent(update: EntityUpdateData): Promise<void> {
const {operation, instanceId, instanceListId} = update
let contactId: IdTuple = [neverNull(instanceListId), instanceId]
if (isUpdateForTypeRef(ContactTypeRef, update)) {
if (operation === OperationType.UPDATE) {
this._entity.load(ContactTypeRef, contactId).then(contact => {
for (const fieldType of typedValues(RecipientField)) {
2021-12-28 13:53:11 +01:00
const matching = this.getRecipientList(fieldType).filter(recipient => recipient.contact && isSameId(recipient.contact._id, contact._id))
matching.forEach(recipient => {
// if the mail address no longer exists on the contact then delete the recipient
if (!contact.mailAddresses.find(ma => cleanMatch(ma.address, recipient.mailAddress))) {
this.removeRecipient(recipient, fieldType, true)
} else {
// else just modify the recipient
recipient.name = getContactDisplayName(contact)
recipient.contact = contact
}
})
}
})
} else if (operation === OperationType.DELETE) {
for (const fieldType of typedValues(RecipientField)) {
2021-12-28 13:53:11 +01:00
const recipients = this.getRecipientList(fieldType)
const toDelete = recipients.filter(recipient => (recipient.contact && isSameId(recipient.contact._id, contactId)) || false)
2021-12-28 13:53:11 +01:00
for (const r of toDelete) {
this.removeRecipient(r, fieldType, true)
}
}
}
this.setMailChanged(true)
} else if (isUpdateForTypeRef(CustomerPropertiesTypeRef, update)) {
this.updateAvailableNotificationTemplateLanguages()
}
return Promise.resolve()
}
setOnBeforeSendFunction(fun: () => unknown) {
this.onBeforeSend = fun
}
}
2021-12-28 13:53:11 +01:00
export function defaultSendMailModel(mailboxDetails: MailboxDetail): SendMailModel {
2021-12-28 13:53:11 +01:00
return new SendMailModel(locator.mailFacade, logins, locator.mailModel, locator.contactModel, locator.eventController, locator.entityClient, mailboxDetails)
2021-05-27 15:14:41 +02:00
}