tutanota/src/common/mailFunctionality/SendMailModel.ts

1335 lines
45 KiB
TypeScript
Raw Normal View History

import { assertMainOrNode } from "../api/common/Env.js"
import { DataFile } from "../api/common/DataFile.js"
import { FileReference } from "../api/common/utils/FileUtils.js"
import {
ContactTypeRef,
ConversationEntry,
ConversationEntryTypeRef,
FileTypeRef,
Mail,
MailboxProperties,
MailboxPropertiesTypeRef,
MailDetails,
MailDetailsDraftTypeRef,
MailTypeRef,
} from "../api/entities/tutanota/TypeRefs.js"
import {
ApprovalStatus,
CalendarAttendeeStatus,
ConversationType,
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,
cleanMatch,
contains,
deduplicate,
defer,
DeferredObject,
downcast,
findAndRemove,
getFromMap,
LazyLoaded,
neverNull,
noOp,
ofClass,
promiseMap,
remove,
typedValues,
} from "@tutao/tutanota-utils"
import Stream from "mithril/stream"
import stream from "mithril/stream"
import type { File as TutanotaFile } from "../../common/api/entities/tutanota/TypeRefs.js"
import { checkAttachmentSize, getDefaultSender, getTemplateLanguages, isAliasEnabledWithUser, isUserEmail, RecipientField } from "./SharedMailUtils.js"
import { cloneInlineImages, InlineImages, revokeInlineImages } from "./inlineImagesUtils.js"
import { RecipientsModel, ResolvableRecipient } from "../api/main/RecipientsModel.js"
2025-02-10 13:15:28 +01:00
import { getAvailableLanguageCode, getSubstitutedLanguageCode, lang, Language, languages, MaybeTranslation, TranslationKey } from "../misc/LanguageViewModel.js"
import { MailFacade } from "../api/worker/facades/lazy/MailFacade.js"
import { EntityClient } from "../api/common/EntityClient.js"
import { LoginController } from "../api/main/LoginController.js"
import { EventController } from "../api/main/EventController.js"
import { DateProvider } from "../api/common/DateProvider.js"
import { EntityUpdateData, isUpdateForTypeRef } from "../api/common/utils/EntityUpdateUtils.js"
import { UserController } from "../api/main/UserController.js"
import { cleanMailAddress, findRecipientWithAddress } from "../api/common/utils/CommonCalendarUtils.js"
import { getPasswordStrengthForUser, isSecurePassword, PASSWORD_MIN_SECURE_VALUE } from "../misc/passwords/PasswordUtils.js"
import {
AccessBlockedError,
LockedError,
NotAuthorizedError,
NotFoundError,
PayloadTooLargeError,
PreconditionFailedError,
TooManyRequestsError,
} from "../api/common/error/RestError.js"
import { ProgrammingError } from "../api/common/error/ProgrammingError.js"
import { UserError } from "../api/main/UserError.js"
import { getSenderName } from "../misc/MailboxPropertiesUtils.js"
import { RecipientNotResolvedError } from "../api/common/error/RecipientNotResolvedError.js"
import { RecipientsNotFoundError } from "../api/common/error/RecipientsNotFoundError.js"
import { checkApprovalStatus } from "../misc/LoginUtils.js"
import { FileNotFoundError } from "../api/common/error/FileNotFoundError.js"
import { elementIdPart, isSameId, stringToCustomId } from "../api/common/utils/EntityUtils.js"
import { MailBodyTooLargeError } from "../api/common/error/MailBodyTooLargeError.js"
import { createApprovalMail } from "../api/entities/monitor/TypeRefs.js"
import { CustomerPropertiesTypeRef } from "../api/entities/sys/TypeRefs.js"
import { isMailAddress } from "../misc/FormatValidator.js"
import { MailboxDetail, MailboxModel } from "./MailboxModel.js"
import { ContactModel } from "../contactsFunctionality/ContactModel.js"
import { getContactDisplayName } from "../contactsFunctionality/ContactUtils.js"
import { getMailBodyText } from "../api/common/CommonMailUtils.js"
import { KeyVerificationMismatchError } from "../api/common/error/KeyVerificationMismatchError"
import { EventInviteEmailType } from "../../calendar-app/calendar/view/CalendarNotificationSender"
import { SyncTracker } from "../api/main/SyncTracker"
import { AutosaveFacade } from "../api/worker/facades/lazy/AutosaveFacade"
assertMainOrNode()
2021-12-23 14:03:23 +01:00
export const TOO_MANY_VISIBLE_RECIPIENTS = 10
2021-12-28 13:53:11 +01:00
export type Attachment = TutanotaFile | DataFile | FileReference
export type InitAsResponseArgs = {
2021-12-28 13:53:11 +01:00
previousMail: Mail
conversationType: ConversationType
senderMailAddress: string
recipients: Recipients
2021-12-28 13:53:11 +01:00
attachments: TutanotaFile[]
subject: string
bodyText: string
replyTos: RecipientList
}
2022-03-17 16:18:17 +01:00
type InitArgs = {
conversationType: ConversationType
subject: string
bodyText: string
recipients: Recipients
confidential: boolean | null
draft?: Mail | null
2022-03-17 16:18:17 +01:00
senderMailAddress?: string
attachments?: ReadonlyArray<Attachment>
replyTos?: RecipientList
previousMail?: Mail | null
previousMessageId?: string | null
initialChangedState: boolean | null
2022-03-17 16:18:17 +01:00
}
/**
* Model which allows sending mails interactively - including resolving of recipients and handling of drafts.
*/
export class SendMailModel {
private initialized: DeferredObject<void> | null = null
onMailChanged: Stream<null> = stream(null)
onRecipientDeleted: Stream<{ field: RecipientField; recipient: Recipient } | null> = stream(null)
2022-03-17 16:18:17 +01:00
onBeforeSend: () => void = noOp
loadedInlineImages: InlineImages = new Map()
// Isn't private because used by MinimizedEditorOverlay, refactor?
2022-03-17 16:18:17 +01:00
draft: Mail | null = null
private conversationType: ConversationType = ConversationType.NEW
private subject: string = ""
private body: string = ""
private recipients: Map<RecipientField, Array<ResolvableRecipient>> = new Map()
2022-03-17 16:18:17 +01:00
private senderAddress: string
private confidential: boolean
// contains either Files from Tutanota or DataFiles of locally loaded files. these map 1:1 to the _attachmentButtons
private attachments: Array<Attachment> = []
private replyTos: Array<ResolvableRecipient> = []
2022-03-17 16:18:17 +01:00
// only needs to be the correct value if this is a new email. if we are editing a draft, conversationType is not used
private previousMessageId: Id | null = null
private previousMail: Mail | null = null
private selectedNotificationLanguage: string
private availableNotificationTemplateLanguages: Array<Language> = []
private mailChangedAt: number = 0
private mailSavedAt: number = 1
private mailRemotelyUpdatedAt: number = 1
2022-03-17 16:18:17 +01:00
private passwords: Map<string, string> = new Map()
private newMail: boolean = false
2021-12-28 13:53:11 +01:00
// 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
private recipientsResolved = new LazyLoaded<void>(async () => {})
// Ignores one update event from the server
// visible for testing
_draftSavedRecently: boolean = false
private waitUntilSync: boolean = false
private _emailType: EventInviteEmailType | null = null
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(
2022-03-17 16:18:17 +01:00
public readonly mailFacade: MailFacade,
public readonly entity: EntityClient,
public readonly logins: LoginController,
public readonly mailboxModel: MailboxModel,
2022-03-17 16:18:17 +01:00
public readonly contactModel: ContactModel,
private readonly eventController: EventController,
public readonly mailboxDetails: MailboxDetail,
private readonly recipientsModel: RecipientsModel,
private readonly dateProvider: DateProvider,
private mailboxProperties: MailboxProperties,
private readonly autosaveFacade: AutosaveFacade,
private readonly needNewDraft: (mail: Mail) => Promise<boolean>,
private readonly syncTracker: SyncTracker,
2021-12-28 13:53:11 +01:00
) {
const userProps = logins.getUserController().props
2022-03-17 16:18:17 +01:00
this.senderAddress = this.getDefaultSender()
this.confidential = !userProps.defaultUnconfidential
2021-12-28 13:53:11 +01:00
2022-03-17 16:18:17 +01:00
this.selectedNotificationLanguage = getAvailableLanguageCode(userProps.notificationMailLanguage || lang.code)
this.updateAvailableNotificationTemplateLanguages()
2021-12-28 13:53:11 +01:00
2023-02-22 17:31:36 +01:00
this.eventController.addEntityListener(this.entityEventReceived)
2022-03-17 16:18:17 +01:00
}
2023-02-22 17:31:36 +01:00
private readonly entityEventReceived = async (updates: ReadonlyArray<EntityUpdateData>) => {
for (const update of updates) {
2022-03-17 16:18:17 +01:00
await this.handleEntityEvent(update)
}
2021-12-28 13:53:11 +01:00
}
/**
* 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
*/
2022-03-17 16:18:17 +01:00
private async updateAvailableNotificationTemplateLanguages(): Promise<void> {
this.availableNotificationTemplateLanguages = languages.slice().sort((a, b) => lang.get(a.textId).localeCompare(lang.get(b.textId)))
const filteredLanguages = await getTemplateLanguages(this.availableNotificationTemplateLanguages, this.entity, this.logins)
if (filteredLanguages.length > 0) {
const languageCodes = filteredLanguages.map((l: Language) => l.code)
2022-03-17 16:18:17 +01:00
this.selectedNotificationLanguage =
getSubstitutedLanguageCode(this.logins.getUserController().props.notificationMailLanguage || lang.code, languageCodes) || languageCodes[0]
this.availableNotificationTemplateLanguages = filteredLanguages
}
2021-12-28 13:53:11 +01:00
}
user(): UserController {
2022-03-17 16:18:17 +01:00
return this.logins.getUserController()
2021-12-28 13:53:11 +01:00
}
2023-08-31 16:31:00 +02:00
isSharedMailbox(): boolean {
return !this.mailboxDetails.mailGroup.user
}
2021-12-28 13:53:11 +01:00
getPreviousMail(): Mail | null {
2022-03-17 16:18:17 +01:00
return this.previousMail
2021-12-28 13:53:11 +01:00
}
getConversationType(): ConversationType {
2022-03-17 16:18:17 +01:00
return this.conversationType
2021-12-28 13:53:11 +01:00
}
setPassword(mailAddress: string, password: string) {
this.onMailChanged(null)
2022-03-17 16:18:17 +01:00
this.passwords.set(mailAddress, password)
2021-12-28 13:53:11 +01:00
}
getPassword(mailAddress: string): string {
2022-03-17 16:18:17 +01:00
return this.passwords.get(mailAddress) || ""
2021-12-28 13:53:11 +01:00
}
getSubject(): string {
2022-03-17 16:18:17 +01:00
return this.subject
2021-12-28 13:53:11 +01:00
}
setSubject(subject: string) {
this.markAsChangedIfNecessary(subject !== this.subject)
2022-03-17 16:18:17 +01:00
this.subject = subject
2021-12-28 13:53:11 +01:00
}
getBody(): string {
2022-03-17 16:18:17 +01:00
return this.body
2021-12-28 13:53:11 +01:00
}
setBody(body: string) {
this.markAsChangedIfNecessary(this.body !== body)
2022-03-17 16:18:17 +01:00
this.body = body
2021-12-28 13:53:11 +01:00
}
setMailRemotelyUpdatedAt(time: number) {
this.mailRemotelyUpdatedAt = time
}
getMailRemotelyUpdatedAt(): number {
return this.mailRemotelyUpdatedAt
}
setMailChangedAt(time: number) {
this.mailChangedAt = time
}
setMailSavedAt(time: number) {
this.mailSavedAt = time
}
getMailSavedAt(): number {
return this.mailSavedAt
}
/**
* set the mail address used to send the mail.
* @param senderAddress the mail address that will show up lowercased in the sender field of the sent mail.
*/
2021-12-28 13:53:11 +01:00
setSender(senderAddress: string) {
// we can (and should) do this because we lowercase all addresses on signup and when creating aliases.
Rewrite calendar editor and calendar popup make updating calendar events delete the old uidIndex correctly when updating calendar events, we sometimes had events in the delete call that do not have the hashedUid set, causing us to be unable to delete the uid index entry. when re-creating the event, that leads to a db.exists error. make sure the event popup always has the current version of the event. now that edit operations are possible from the popup, we either need to close the popup after it calls the model factory to make sure it's only called once, or make sure the model factory always uses the last version of the event. we opted for the first option here to make sure repeated changes to the attendance are actually sent as a response. make contact resolution failure more controlled for external users previously, responding to an event from an external mailbox would try to resolve a contact for the response mail, fail, try to create a contact and fail again on an opaque assertNotNull. show partial editability banner when creating new event in shared calendar make it possible to add alarms to invites in private calendars don't make saving/sending invites and cancellations depend on user choice for own events updateExistingEvent() should call sendNotifications/saveEvent even when there are no update worthy changes or the user did not tick the sendUpdates checkbox because invites/cancellations must be sent in any case and sending updates has a separate check for sendUpdates also make the sendUpdates button on the popup use the same logic as the shortcut when clicked (ask confirmation) Co-authored-by: nig <nig@tutao.de>
2023-04-25 16:54:46 +02:00
senderAddress = cleanMailAddress(senderAddress)
this.markAsChangedIfNecessary(this.senderAddress !== senderAddress)
2022-03-17 16:18:17 +01:00
this.senderAddress = senderAddress
2021-12-28 13:53:11 +01:00
}
getSender(): string {
2022-03-17 16:18:17 +01:00
return this.senderAddress
2021-12-28 13:53:11 +01:00
}
/**
* Returns the strength indicator for the recipients password
* @returns value between 0 and 100
*/
getPasswordStrength(recipient: PartialRecipient): number {
return getPasswordStrengthForUser(this.getPassword(recipient.address), recipient, this.mailboxDetails, this.logins)
2021-12-28 13:53:11 +01:00
}
hasDraftDataChangedOnServer(): boolean {
return this.mailRemotelyUpdatedAt > this.mailSavedAt
}
2021-12-28 13:53:11 +01:00
hasMailChanged(): boolean {
return this.mailChangedAt > this.mailSavedAt
2021-12-28 13:53:11 +01:00
}
async clearLocalAutosave(): Promise<void> {
// user probably opened a different draft in a different window; we don't want to delete it
if (await this.autosavedDraftIsDifferentMail()) {
console.warn("cannot clear autosave - autosaved draft is a different mail from the one we're editing")
} else {
await this.autosaveFacade.clearAutosavedDraftData()
this.updateNewMailStatus()
}
}
async makeLocalAutosave(): Promise<void> {
const body = await this.getSanitizedBody()
const subject = this.getSubject()
const isConfidential = this.isConfidential()
const getNameAndAddress = ({ name, address }: Recipient) => ({ name, address })
const to = this.toRecipients().map(getNameAndAddress)
const cc = this.ccRecipients().map(getNameAndAddress)
const bcc = this.bccRecipients().map(getNameAndAddress)
this.updateNewMailStatus()
if (this.hasMailChanged()) {
// user probably opened a different draft in a different window; we don't want to write over it
if (await this.autosavedDraftIsDifferentMail()) {
console.warn("cannot make autosave - autosaved draft is a different mail from the one we're editing")
return
}
// otherwise, we can save the draft
await this.autosaveFacade.setAutosavedDraftData({
body,
subject,
to,
cc,
bcc,
confidential: isConfidential,
mailGroupId: this.mailboxDetails.mailGroup._id,
senderAddress: this.senderAddress,
locallySavedTime: Date.now(),
editedTime: this.mailSavedAt,
lastUpdatedTime: this.mailRemotelyUpdatedAt,
// will be null if it is a new (unsaved) draft
mailId: this.getDraft()?._id ?? null,
})
}
}
private updateNewMailStatus() {
this.newMail = this.getDraft() == null
}
private isNewMail(): boolean {
return this.newMail
}
private async autosavedDraftIsDifferentMail(): Promise<boolean> {
const draftData = await this.autosaveFacade.getAutosavedDraftData()
if (draftData == null) {
return false
}
if (this.draft == null) {
// no mail id -> false (same mail)
// mail id -> true (different mail)
return draftData.mailId != null
} else if (draftData.mailId == null) {
// is a new mail -> false (same mail)
// is not a new mail -> true (different mail)
return !this.isNewMail()
} else {
return !isSameId(this.draft._id, draftData.mailId)
}
}
/**
* update the changed state of the mail.
* will only be reset when saving.
*/
markAsChangedIfNecessary(hasChanged: boolean) {
if (!hasChanged) return
this.setMailChangedAt(this.dateProvider.now())
// If it was changed really quickly, force the timestamps to be different.
if (this.mailChangedAt <= this.mailSavedAt) {
this.setMailChangedAt(this.mailSavedAt + 1)
}
// if this method is called wherever state gets changed, onMailChanged should function properly
this.onMailChanged(null)
2021-12-28 13:53:11 +01:00
}
/**
*
* @param recipients
* @param subject
* @param bodyText
* @param attachments
* @param confidential
* @param senderMailAddress
* @param initialChangedState
2021-12-28 13:53:11 +01:00
* @returns {Promise<SendMailModel>}
*/
initWithTemplate(
recipients: Recipients,
subject: string,
bodyText: string,
attachments?: ReadonlyArray<Attachment>,
confidential?: boolean,
senderMailAddress?: string,
2022-12-27 15:37:40 +01:00
initialChangedState?: boolean,
2021-12-28 13:53:11 +01:00
): Promise<SendMailModel> {
this.startInit()
2022-03-17 16:18:17 +01:00
return this.init({
2021-12-28 13:53:11 +01:00
conversationType: ConversationType.NEW,
subject,
bodyText,
recipients,
attachments,
confidential: confidential ?? null,
2021-12-28 13:53:11 +01:00
senderMailAddress,
2022-12-27 15:37:40 +01:00
initialChangedState: initialChangedState ?? null,
2021-12-28 13:53:11 +01:00
})
}
async initAsResponse(args: InitAsResponseArgs, inlineImages: InlineImages): Promise<SendMailModel> {
this.startInit()
const { previousMail, conversationType, senderMailAddress, recipients, attachments, subject, bodyText, replyTos } = args
2021-12-28 13:53:11 +01:00
let previousMessageId: string | null = null
2022-03-17 16:18:17 +01:00
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)
2022-03-17 16:18:17 +01:00
return this.init({
2021-12-28 13:53:11 +01:00
conversationType,
subject,
bodyText,
recipients,
senderMailAddress,
confidential: previousMail.confidential,
attachments,
replyTos,
previousMail,
previousMessageId,
2022-12-27 15:37:40 +01:00
initialChangedState: false,
2021-12-28 13:53:11 +01:00
})
}
async initWithDraft(
draft: Mail,
draftDetails: MailDetails,
conversationEntry: ConversationEntry,
attachments: TutanotaFile[],
inlineImages: InlineImages,
): Promise<SendMailModel> {
this.startInit()
2021-12-28 13:53:11 +01:00
let previousMessageId: string | null = null
let previousMail: Mail | null = null
const conversationType = downcast<ConversationType>(conversationEntry.conversationType)
if (conversationEntry.previous) {
try {
2022-03-17 16:18:17 +01:00
const previousEntry = await this.entity.load(ConversationEntryTypeRef, conversationEntry.previous)
previousMessageId = previousEntry.messageId
if (previousEntry.mail) {
2022-03-17 16:18:17 +01:00
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)
const { confidential, sender, subject } = draft
const { toRecipients, ccRecipients, bccRecipients } = draftDetails.recipients
2024-07-26 15:36:34 +02:00
const recipients: Recipients = {
to: toRecipients,
cc: ccRecipients,
bcc: bccRecipients,
2021-12-28 13:53:11 +01:00
}
const bodyText = getMailBodyText(draftDetails.body)
2022-03-17 16:18:17 +01:00
return this.init({
2021-12-28 13:53:11 +01:00
conversationType: conversationType,
subject,
bodyText,
recipients,
2024-07-26 15:36:34 +02:00
draft,
2021-12-28 13:53:11 +01:00
senderMailAddress: sender.address,
confidential,
attachments,
replyTos: draftDetails.replyTos,
2021-12-28 13:53:11 +01:00
previousMail,
previousMessageId,
2022-12-27 15:37:40 +01:00
initialChangedState: false,
2021-12-28 13:53:11 +01:00
})
}
private startInit() {
if (this.initialized) {
throw new ProgrammingError("trying to initialize SendMailModel twice")
}
this.initialized = defer()
}
2022-12-27 15:37:40 +01:00
private async init({
conversationType,
subject,
bodyText,
draft,
recipients,
senderMailAddress,
confidential,
attachments,
replyTos,
previousMail,
previousMessageId,
initialChangedState,
}: InitArgs): Promise<SendMailModel> {
2022-03-17 16:18:17 +01:00
this.conversationType = conversationType
this.subject = subject
this.body = bodyText
this.draft = draft || null
this.waitUntilSync = true
this.updateNewMailStatus()
2021-12-28 13:53:11 +01:00
let to: RecipientList
let cc: RecipientList
let bcc: RecipientList
2021-12-28 13:53:11 +01:00
if (recipients instanceof Array) {
to = recipients
cc = []
bcc = []
} else {
to = recipients.to ?? []
cc = recipients.cc ?? []
bcc = recipients.bcc ?? []
2021-12-28 13:53:11 +01:00
}
2023-02-16 12:25:40 +01:00
// We deliberately use .map() and not promiseMap() here because we want to insert all
// the recipients right away, we count on it in some checks in send() and we also want all of them
// to show up immediately.
// If we want to limit recipient resolution at some point we need to build a queue in some other place.
// Making it LazyLoaded() will allow us to retry it in case it fails.
// It is very important that we insert the recipients here synchronously. Even though it is inside the async function it will call insertRecipient()
// right away when we call getAsync() below
this.recipientsResolved = new LazyLoaded(async () => {
await Promise.all([
2023-02-16 12:25:40 +01:00
recipientsFilter(to).map((r) => this.insertRecipient(RecipientField.TO, r)),
recipientsFilter(cc).map((r) => this.insertRecipient(RecipientField.CC, r)),
recipientsFilter(bcc).map((r) => this.insertRecipient(RecipientField.BCC, r)),
])
})
// noinspection ES6MissingAwait
this.recipientsResolved.getAsync()
2021-12-28 13:53:11 +01:00
// .toLowerCase because all our aliases and accounts are lowercased on creation
this.senderAddress =
senderMailAddress != null && isAliasEnabledWithUser(this.mailboxDetails, this.user().userGroupInfo, senderMailAddress)
? senderMailAddress.toLowerCase()
: this.getDefaultSender()
this.confidential = confidential ?? !this.user().props.defaultUnconfidential
2022-03-17 16:18:17 +01:00
this.attachments = []
2021-12-28 13:53:11 +01:00
if (attachments) {
this.attachFiles(attachments)
}
this.replyTos = recipientsFilter(replyTos ?? []).map((recipient) => this.recipientsModel.initialize(recipient))
2022-03-17 16:18:17 +01:00
this.previousMail = previousMail || null
this.previousMessageId = previousMessageId || null
this.setMailChangedAt(this.dateProvider.now())
// Determine if we should have this mail already be detected as modified so it saves.
if (initialChangedState) {
this.onMailChanged(null)
this.setMailSavedAt(this.mailChangedAt - 1)
} else {
this.setMailSavedAt(this.mailChangedAt + 1)
}
this.mailRemotelyUpdatedAt = this.mailChangedAt
this.setMailSavedAt(this.mailChangedAt)
this._draftSavedRecently = false
assertNotNull(this.initialized, "somehow got to the end of init without startInit called").resolve()
2022-03-17 16:18:17 +01:00
return this
2021-12-28 13:53:11 +01:00
}
2022-03-17 16:18:17 +01:00
private getDefaultSender(): string {
return getDefaultSender(this.logins, this.mailboxDetails)
2021-12-28 13:53:11 +01:00
}
getRecipientList(type: RecipientField): Array<ResolvableRecipient> {
2022-03-17 16:18:17 +01:00
return getFromMap(this.recipients, type, () => [])
2021-12-28 13:53:11 +01:00
}
toRecipients(): Array<ResolvableRecipient> {
return this.getRecipientList(RecipientField.TO)
2021-12-28 13:53:11 +01:00
}
toRecipientsResolved(): Promise<Array<Recipient>> {
return Promise.all(this.toRecipients().map((recipient) => recipient.resolve()))
}
ccRecipients(): Array<ResolvableRecipient> {
return this.getRecipientList(RecipientField.CC)
2021-12-28 13:53:11 +01:00
}
ccRecipientsResolved(): Promise<Array<Recipient>> {
return Promise.all(this.ccRecipients().map((recipient) => recipient.resolve()))
}
bccRecipients(): Array<ResolvableRecipient> {
return this.getRecipientList(RecipientField.BCC)
2021-12-28 13:53:11 +01:00
}
bccRecipientsResolved(): Promise<Array<Recipient>> {
return Promise.all(this.bccRecipients().map((recipient) => recipient.resolve()))
}
2021-12-28 13:53:11 +01:00
replyTosResolved(): Promise<Array<Recipient>> {
return Promise.all(this.replyTos.map((r) => r.resolve()))
2021-12-28 13:53:11 +01:00
}
setWaitUntilSync(waitUntilSync: boolean) {
this.waitUntilSync = waitUntilSync
}
autosaveReady(): boolean {
return !this.waitUntilSync || this.syncTracker.isSyncDone()
}
async waitForSaveReady(): Promise<void> {
if (this.waitUntilSync) {
await this.syncTracker.waitSync()
}
}
/**
* add a recipient to the recipient list without updating the saved state of the draft.
* if the recipient is already inserted, it will wait for it to resolve before returning.
*
* @returns whether the list was actually changed.
*/
private async insertRecipient(fieldType: RecipientField, { address, name, type, contact }: PartialRecipient): Promise<boolean> {
let recipient = findRecipientWithAddress(this.getRecipientList(fieldType), address)
// Only add a recipient if it doesn't exist
if (!recipient) {
recipient = this.recipientsModel.initialize({
address,
name,
type,
contact,
})
this.getRecipientList(fieldType).push(recipient)
2021-12-28 13:53:11 +01:00
recipient.resolve().then(({ address, contact }) => {
if (!this.passwords.has(address) && contact != null) {
this.setPassword(address, contact.presharedPassword ?? "")
} else {
// always notify listeners after we finished resolving the recipient, even if email itself didn't change
this.onMailChanged(null)
}
2021-12-28 13:53:11 +01:00
})
await recipient.resolve()
return true
2021-12-28 13:53:11 +01:00
}
await recipient.resolve()
return false
}
/**
* Add a new recipient, this method resolves when the recipient resolves.
* will notify of a changed draft state after the recipient was inserted
*/
async addRecipient(fieldType: RecipientField, partialRecipient: PartialRecipient): Promise<void> {
const wasAdded = await this.insertRecipient(fieldType, partialRecipient)
this.markAsChangedIfNecessary(wasAdded)
}
2021-12-28 13:53:11 +01:00
/**
* Add multiple recipients.
*/
async addRecipients(recipients: { to: readonly PartialRecipient[]; cc: readonly PartialRecipient[]; bcc: readonly PartialRecipient[] }) {
await Promise.all([
promiseMap(recipients.to, (r) => this.addRecipient(RecipientField.TO, r)),
promiseMap(recipients.cc, (r) => this.addRecipient(RecipientField.CC, r)),
promiseMap(recipients.bcc, (r) => this.addRecipient(RecipientField.BCC, r)),
])
}
getRecipient(type: RecipientField, address: string): ResolvableRecipient | null {
return findRecipientWithAddress(this.getRecipientList(type), address)
2021-12-28 13:53:11 +01:00
}
removeRecipientByAddress(address: string, type: RecipientField, notify: boolean = true) {
const recipient = findRecipientWithAddress(this.getRecipientList(type), address)
if (recipient) {
this.removeRecipient(recipient, type, notify)
}
}
/**
* remove recipient from the recipient list
* @return true if the recipient was removed
*/
removeRecipient(recipient: Recipient, type: RecipientField, notify: boolean = true): boolean {
const recipients = this.recipients.get(type) ?? []
2023-02-20 14:42:51 +01:00
const cleanRecipientAddress = cleanMailAddress(recipient.address)
const didRemove = findAndRemove(recipients, (r) => cleanMailAddress(r.address) === cleanRecipientAddress)
this.markAsChangedIfNecessary(didRemove)
2021-12-28 13:53:11 +01:00
if (didRemove && notify) {
this.onRecipientDeleted({
field: type,
recipient,
})
}
return didRemove
}
dispose() {
2022-03-17 16:18:17 +01:00
this.eventController.removeEntityListener(this.entityEventReceived)
2021-12-28 13:53:11 +01:00
revokeInlineImages(this.loadedInlineImages)
}
/**
* @throws UserError in the case that any files were too big to attach. Small enough files will still have been attached
*/
getAttachments(): Array<Attachment> {
2022-03-17 16:18:17 +01:00
return this.attachments
2021-12-28 13:53:11 +01:00
}
/** @throws UserError in case files are too big to add */
attachFiles(files: ReadonlyArray<Attachment>): void {
2022-03-17 16:18:17 +01:00
let sizeLeft = MAX_ATTACHMENT_SIZE - this.attachments.reduce((total, file) => total + Number(file.size), 0)
2021-12-28 13:53:11 +01:00
const sizeCheckResult = checkAttachmentSize(files, sizeLeft)
2022-03-17 16:18:17 +01:00
this.attachments.push(...sizeCheckResult.attachableFiles)
this.markAsChangedIfNecessary(sizeCheckResult.attachableFiles.length > 0)
2021-12-28 13:53:11 +01:00
if (sizeCheckResult.tooBigFiles.length > 0) {
throw new UserError(lang.makeTranslation("tooBigAttachment_msg", lang.get("tooBigAttachment_msg") + "\n" + sizeCheckResult.tooBigFiles.join("\n")))
2021-12-28 13:53:11 +01:00
}
}
removeAttachment(file: Attachment): void {
this.markAsChangedIfNecessary(remove(this.attachments, file))
2021-12-28 13:53:11 +01:00
}
getSenderName(): string {
return getSenderName(this.mailboxProperties, this.senderAddress) ?? ""
2021-12-28 13:53:11 +01:00
}
getDraft(): Readonly<Mail> | null {
return this.draft
2021-12-28 13:53:11 +01:00
}
private async updateDraft(body: string, attachments: ReadonlyArray<Attachment> | null, draft: Mail): Promise<Mail> {
2022-03-17 16:18:17 +01:00
return this.mailFacade
.updateDraft({
subject: this.getSubject(),
body: body,
senderMailAddress: this.senderAddress,
senderName: this.getSenderName(),
toRecipients: await this.toRecipientsResolved(),
ccRecipients: await this.ccRecipientsResolved(),
bccRecipients: await this.bccRecipientsResolved(),
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))
}),
)
2022-12-27 15:37:40 +01:00
}
private async 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: await this.toRecipientsResolved(),
ccRecipients: await this.ccRecipientsResolved(),
bccRecipients: await this.bccRecipientsResolved(),
conversationType: this.conversationType,
previousMessageId: this.previousMessageId,
attachments: attachments,
confidential: this.isConfidential(),
replyTos: await this.replyTosResolved(),
method: mailMethod,
})
2021-12-28 13:53:11 +01:00
}
isConfidential(): boolean {
2022-03-17 16:18:17 +01:00
return this.confidential || !this.containsExternalRecipients()
2021-12-28 13:53:11 +01:00
}
isConfidentialExternal(): boolean {
2022-03-17 16:18:17 +01:00
return this.confidential && this.containsExternalRecipients()
2021-12-28 13:53:11 +01:00
}
setConfidential(confidential: boolean): void {
this.markAsChangedIfNecessary(this.confidential !== confidential)
2022-03-17 16:18:17 +01:00
this.confidential = confidential
2021-12-28 13:53:11 +01:00
}
containsExternalRecipients(): boolean {
2022-12-27 15:37:40 +01:00
return this.allRecipients().some((r) => r.type === RecipientType.EXTERNAL)
2021-12-28 13:53:11 +01:00
}
getExternalRecipients(): Array<Recipient> {
2022-12-27 15:37:40 +01:00
return this.allRecipients().filter((r) => r.type === RecipientType.EXTERNAL)
2021-12-28 13:53:11 +01:00
}
/**
* @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: MaybeTranslation) => Promise<boolean> = (_) => Promise.resolve(true),
waitHandler: (arg0: MaybeTranslation, arg1: Promise<any>) => Promise<any> = (_, p) => p,
tooManyRequestsError: TranslationKey = "tooManyMails_msg",
2021-12-28 13:53:11 +01:00
): Promise<boolean> {
2023-02-16 12:25:40 +01:00
// To avoid parallel invocations do not do anything async here that would later execute the sending.
// It is fine to wait for getConfirmation() because it is modal and will prevent the user from triggering multiple sends.
// If you need to do something async here put it into `asyncSend`
//
// You can't rely on resolved recipients here, only after waitForResolvedRecipients() inside asyncSend()!
2021-12-28 13:53:11 +01:00
this.onBeforeSend()
if (this.allRecipients().length === 1 && this.allRecipients()[0].address.toLowerCase().trim() === "approval@tutao.de") {
2022-03-17 16:18:17 +01:00
await this.sendApprovalMail(this.getBody())
await this.clearLocalAutosave() // because this approval mail is "sent" in an odd way, it will not clear the local autosave
2021-12-28 13:53:11 +01:00
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
}
2023-02-16 12:25:40 +01:00
const asyncSend = async () => {
// The next check depends on contacts being available
// So we need to wait for our recipients here
const recipients = await this.waitForResolvedRecipients()
2021-12-28 13:53:11 +01:00
2023-02-16 12:25:40 +01:00
// No password in external confidential mail is an error
if (this.isConfidentialExternal() && this.getExternalRecipients().some((r) => !this.getPassword(r.address))) {
throw new UserError("noPreSharedPassword_msg")
}
2021-12-28 13:53:11 +01:00
2023-02-16 12:25:40 +01:00
// Weak password is a warning
if (this.isConfidentialExternal() && this.hasInsecurePasswords() && !(await getConfirmation("presharedPasswordNotStrongEnough_msg"))) {
return false
}
2021-12-28 13:53:11 +01:00
// Don't safe unnecessarily.
if (this.hasMailChanged() || this.draft == null) {
await this.saveDraft(true, mailMethod)
}
await this.updateContacts(recipients)
2023-11-24 17:47:47 +01:00
await this.mailFacade.sendDraft(assertNotNull(this.draft, "draft was null?"), recipients, this.selectedNotificationLanguage)
await this.clearLocalAutosave() // no need to keep a local copy of a draft of an email that was sent
2022-03-17 16:18:17 +01:00
await this.updatePreviousMail()
await this.updateExternalLanguage()
2021-12-28 13:53:11 +01:00
return true
}
2023-02-16 12:25:40 +01:00
return waitHandler(this.isConfidential() ? "sending_msg" : "sendingUnencrypted_msg", asyncSend())
.catch(
ofClass(LockedError, () => {
2022-03-17 16:18:17 +01:00
throw new UserError("operationStillActive_msg")
}),
) // catch all of the badness
.catch(
ofClass(RecipientNotResolvedError, () => {
2022-03-17 16:18:17 +01:00
throw new UserError("tooManyAttempts_msg")
}),
)
.catch(
2022-12-27 15:37:40 +01:00
ofClass(RecipientsNotFoundError, (e) => {
2022-03-17 16:18:17 +01:00
if (mailMethod === MailMethod.ICAL_CANCEL) {
// in case of calendar event termination we will remove invalid recipients and then delete the event without sending updates
2022-03-17 16:18:17 +01:00
throw e
} else {
let invalidRecipients = e.message
throw new UserError(
lang.makeTranslation(
"error_msg",
lang.get("tutanotaAddressDoesNotExist_msg") + " " + lang.get("invalidRecipients_msg") + "\n" + invalidRecipients,
),
2022-03-17 16:18:17 +01:00
)
}
}),
)
.catch(
ofClass(TooManyRequestsError, () => {
2022-03-17 16:18:17 +01:00
throw new UserError(tooManyRequestsError)
}),
)
.catch(
2022-12-27 15:37:40 +01:00
ofClass(AccessBlockedError, (e) => {
2022-03-17 16:18:17 +01:00
// 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, () => {
2022-03-17 16:18:17 +01:00
throw new UserError("couldNotAttachFile_msg")
}),
)
.catch(
ofClass(PreconditionFailedError, () => {
2022-03-17 16:18:17 +01:00
throw new UserError("operationStillActive_msg")
}),
)
2025-02-10 13:15:28 +01:00
.catch(
ofClass(KeyVerificationMismatchError, async (e) => {
const failedRecipients: ResolvableRecipient[] = []
// Mark all recipients that have a KeyVerificationMismatch after hitting "Send"
for (const recipient of this.allRecipients()) {
if (contains(e.data, recipient.address)) {
await recipient.markAsKeyVerificationMismatch()
failedRecipients.push(recipient)
}
}
import("../settings/keymanagement/KeyVerificationRecoveryDialog.js").then(({ showMultiRecipientsKeyVerificationRecoveryDialog }) =>
showMultiRecipientsKeyVerificationRecoveryDialog(failedRecipients),
2025-02-10 13:15:28 +01:00
)
}),
)
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.address) !== "")
.reduce((min, recipient) => Math.min(min, this.getPasswordStrength(recipient)), PASSWORD_MIN_SECURE_VALUE)
2021-12-28 13:53:11 +01:00
return !isSecurePassword(minimalPasswordStrength)
}
2022-12-27 15:37:40 +01:00
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.hasMailChanged() && this.doSaveAgain) {
this.doSaveAgain = false
await this.saveDraft(saveAttachments, mailMethod)
}
})
} else {
this.doSaveAgain = true
}
return this.currentSavePromise
}
isSaving(): boolean {
return this.currentSavePromise != null
}
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
*/
2022-12-27 15:37:40 +01:00
private async doSaveDraft(saveAttachments: boolean, mailMethod: MailMethod): Promise<void> {
if (this.initialized == null) {
throw new ProgrammingError("init for SendMailModel was not called")
}
await this.initialized
try {
2022-03-17 16:18:17 +01:00
const attachments = saveAttachments ? this.attachments : null
// We also want to create new drafts for drafts edited from trash or spam folder
const body = await this.getSanitizedBody()
this._draftSavedRecently = true
this.waitUntilSync = false
2022-12-27 15:37:40 +01:00
this.draft =
this.draft == null || (await this.needNewDraft(this.draft))
? await this.createDraft(body, attachments, mailMethod)
: await this.updateDraft(body, attachments, this.draft)
const attachmentIds = await this.mailFacade.getAttachmentIds(this.draft)
const newAttachments = await promiseMap(attachmentIds, (fileId) => this.entity.load<TutanotaFile>(FileTypeRef, fileId), {
2022-12-27 15:37:40 +01:00
concurrency: 5,
})
2022-03-17 16:18:17 +01:00
this.attachments = [] // attachFiles will push to existing files but we want to overwrite them
this.attachFiles(newAttachments)
// 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.setMailSavedAt(this.dateProvider.now())
this.mailRemotelyUpdatedAt = this.mailSavedAt
} 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 getSanitizedBody(): Promise<string> {
const unsanitized_body = this.getBody()
const { getHtmlSanitizer } = await import("../misc/HtmlSanitizer.js")
return getHtmlSanitizer().sanitizeHTML(unsanitized_body, {
// store the draft always with external links preserved. this reverts
// the draft-src and draft-srcset attribute stow.
blockExternalContent: false,
// since we're not displaying this, this is fine.
allowRelativeLinks: true,
// do not touch inline images, we just want to store this.
usePlaceholderForInlineImages: false,
}).html
}
2022-03-17 16:18:17 +01:00
private sendApprovalMail(body: string): Promise<unknown> {
2021-12-28 13:53:11 +01:00
const listId = "---------c--"
const m = createApprovalMail({
2022-03-17 16:18:17 +01:00
_id: [listId, stringToCustomId(this.senderAddress)],
2021-12-28 13:53:11 +01:00
_ownerGroup: this.user().user.userGroup.group,
text: `Subject: ${this.getSubject()}<br>${body}`,
date: null,
range: null,
customer: null,
2021-12-28 13:53:11 +01:00
})
2022-12-27 15:37:40 +01:00
return this.entity.setup(listId, m).catch(ofClass(NotAuthorizedError, (e) => console.log("not authorized for approval message")))
2021-12-28 13:53:11 +01:00
}
getAvailableNotificationTemplateLanguages(): Array<Language> {
2022-03-17 16:18:17 +01:00
return this.availableNotificationTemplateLanguages
2021-12-28 13:53:11 +01:00
}
getSelectedNotificationLanguageCode(): string {
2022-03-17 16:18:17 +01:00
return this.selectedNotificationLanguage
2021-12-28 13:53:11 +01:00
}
setSelectedNotificationLanguageCode(code: string) {
this.markAsChangedIfNecessary(this.selectedNotificationLanguage !== code)
2022-03-17 16:18:17 +01:00
this.selectedNotificationLanguage = code
this.markAsChangedIfNecessary(true)
2021-12-28 13:53:11 +01:00
}
2022-03-17 16:18:17 +01:00
private updateExternalLanguage() {
2021-12-28 13:53:11 +01:00
let props = this.user().props
2022-03-17 16:18:17 +01:00
if (props.notificationMailLanguage !== this.selectedNotificationLanguage) {
props.notificationMailLanguage = this.selectedNotificationLanguage
2021-12-28 13:53:11 +01:00
2022-03-17 16:18:17 +01:00
this.entity.update(props)
2021-12-28 13:53:11 +01:00
}
}
2022-03-17 16:18:17 +01:00
private 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
2021-12-28 13:53:11 +01:00
} else {
return Promise.resolve()
}
2022-03-17 16:18:17 +01:00
return this.entity.update(this.previousMail).catch(ofClass(NotFoundError, noOp))
2021-12-28 13:53:11 +01:00
} else {
return Promise.resolve()
}
}
/**
* If contacts have had their passwords changed, we update them before sending
*/
private async updateContacts(resolvedRecipients: Recipient[]): Promise<any> {
for (const { address, contact, type } of resolvedRecipients) {
if (contact == null) {
continue
}
const isExternalAndConfidential = type === RecipientType.EXTERNAL && this.isConfidential()
if (!contact._id && (!this.user().props.noAutomaticContacts || isExternalAndConfidential)) {
if (isExternalAndConfidential) {
contact.presharedPassword = this.getPassword(address).trim()
}
const listId = await this.contactModel.getContactListId()
await this.entity.setup(listId, contact)
} else if (contact._id && isExternalAndConfidential && contact.presharedPassword !== this.getPassword(address).trim()) {
contact.presharedPassword = this.getPassword(address).trim()
await this.entity.update(contact)
}
}
2021-12-28 13:53:11 +01:00
}
Rewrite calendar editor and calendar popup make updating calendar events delete the old uidIndex correctly when updating calendar events, we sometimes had events in the delete call that do not have the hashedUid set, causing us to be unable to delete the uid index entry. when re-creating the event, that leads to a db.exists error. make sure the event popup always has the current version of the event. now that edit operations are possible from the popup, we either need to close the popup after it calls the model factory to make sure it's only called once, or make sure the model factory always uses the last version of the event. we opted for the first option here to make sure repeated changes to the attendance are actually sent as a response. make contact resolution failure more controlled for external users previously, responding to an event from an external mailbox would try to resolve a contact for the response mail, fail, try to create a contact and fail again on an opaque assertNotNull. show partial editability banner when creating new event in shared calendar make it possible to add alarms to invites in private calendars don't make saving/sending invites and cancellations depend on user choice for own events updateExistingEvent() should call sendNotifications/saveEvent even when there are no update worthy changes or the user did not tick the sendUpdates checkbox because invites/cancellations must be sent in any case and sending updates has a separate check for sendUpdates also make the sendUpdates button on the popup use the same logic as the shortcut when clicked (ask confirmation) Co-authored-by: nig <nig@tutao.de>
2023-04-25 16:54:46 +02:00
allRecipients(): ReadonlyArray<ResolvableRecipient> {
2021-12-28 13:53:11 +01:00
return this.toRecipients().concat(this.ccRecipients()).concat(this.bccRecipients())
}
/**
* Makes sure the recipient type and contact are resolved.
*/
async waitForResolvedRecipients(): Promise<Recipient[]> {
await this.recipientsResolved.getAsync()
return Promise.all(this.allRecipients().map((recipient) => recipient.resolve())).catch(
2022-12-27 15:37:40 +01:00
ofClass(TooManyRequestsError, () => {
throw new RecipientNotResolvedError("")
}),
)
2021-12-28 13:53:11 +01:00
}
async handleEntityEvent(update: EntityUpdateData): Promise<void> {
const { operation, instanceId, instanceListId } = update
2021-12-28 13:53:11 +01:00
let contactId: IdTuple = [neverNull(instanceListId), instanceId]
let changed = false
2021-12-28 13:53:11 +01:00
if (isUpdateForTypeRef(ContactTypeRef, update)) {
await this.recipientsResolved.getAsync()
2021-12-28 13:53:11 +01:00
if (operation === OperationType.UPDATE) {
const contact = await this.entity.load(ContactTypeRef, contactId)
for (const fieldType of typedValues(RecipientField)) {
const matching = this.getRecipientList(fieldType).filter((recipient) => recipient.contact && isSameId(recipient.contact._id, contact._id))
for (const recipient of matching) {
// if the mail address no longer exists on the contact then delete the recipient
if (!contact.mailAddresses.some((ma) => cleanMatch(ma.address, recipient.address))) {
changed = changed || this.removeRecipient(recipient, fieldType, true)
} else {
// else just modify the recipient
recipient.setName(getContactDisplayName(contact))
recipient.setContact(contact)
changed = true
2023-09-07 17:56:39 +02:00
}
2021-12-28 13:53:11 +01:00
}
}
2021-12-28 13:53:11 +01:00
} else if (operation === OperationType.DELETE) {
for (const fieldType of typedValues(RecipientField)) {
2021-12-28 13:53:11 +01:00
const recipients = this.getRecipientList(fieldType)
2022-12-27 15:37:40 +01:00
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) {
changed = changed || this.removeRecipient(r, fieldType, true)
2021-12-28 13:53:11 +01:00
}
}
}
} else if (isUpdateForTypeRef(CustomerPropertiesTypeRef, update)) {
await this.updateAvailableNotificationTemplateLanguages()
} else if (isUpdateForTypeRef(MailboxPropertiesTypeRef, update) && operation === OperationType.UPDATE) {
this.mailboxProperties = await this.entity.load(MailboxPropertiesTypeRef, update.instanceId)
} else if (isUpdateForTypeRef(MailDetailsDraftTypeRef, update) && operation === OperationType.UPDATE && this.draft != null) {
const mailDetailsDraftId = assertNotNull(this.draft.mailDetailsDraft)
if (isSameId(update.instanceId, elementIdPart(mailDetailsDraftId))) {
if (this._draftSavedRecently) {
this._draftSavedRecently = false
} else {
this.mailRemotelyUpdatedAt = this.dateProvider.now()
if (this.mailRemotelyUpdatedAt < this.mailSavedAt) {
this.mailRemotelyUpdatedAt = this.mailSavedAt + 1
}
await this.makeLocalAutosave()
}
}
2021-12-28 13:53:11 +01:00
}
this.markAsChangedIfNecessary(changed)
2021-12-28 13:53:11 +01:00
return Promise.resolve()
}
setOnBeforeSendFunction(fun: () => unknown) {
this.onBeforeSend = fun
}
isUserPreviousSender(): boolean {
if (!this.previousMail) return false
2023-11-17 16:24:54 +01:00
return isUserEmail(this.logins, this.mailboxDetails, this.previousMail.sender.address)
}
setEmailTypeFromAttendeeStatus(attendeeStatus: CalendarAttendeeStatus) {
switch (attendeeStatus) {
case CalendarAttendeeStatus.ACCEPTED:
this._emailType = EventInviteEmailType.REPLY_ACCEPT
break
case CalendarAttendeeStatus.DECLINED:
this._emailType = EventInviteEmailType.REPLY_DECLINE
break
case CalendarAttendeeStatus.TENTATIVE:
this._emailType = EventInviteEmailType.REPLY_TENTATIVE
break
}
}
get emailType() {
if (!this._emailType) {
throw new Error("Email type not set")
}
return this._emailType
}
isPlainTextMail() {
return this.logins.getUserController().props.sendPlaintextOnly
}
}
/**
* deduplicate a list of recipients for insertion in any of the recipient fields
* recipients are considered equal when their cleanMailAddress() is the same
* returns the recipients with their original mail address
*
* unhandled edge case: it's possible to lose recipients that should be kept when
* * the mail contains several recipients that have the same clean address (Bob@e.de and bob@e.de)
* * the e.de mail server considers these distinct
* * we hit "reply all"
*
*/
function recipientsFilter(recipientList: ReadonlyArray<PartialRecipient>): Array<PartialRecipient> {
// we pack each recipient along with its cleaned address, deduplicate the array by comparing cleaned and then unpack the original recipient
// this prevents us from changing the values contained in the array and still keeps the cleanAddress calls out of the n^2 loop
const cleanedList = recipientList
.filter((r) => isMailAddress(r.address, false))
.map((a) => ({
recipient: a,
cleaned: cleanMailAddress(a.address),
}))
return deduplicate(cleanedList, (a, b) => a.cleaned === b.cleaned).map((a) => a.recipient)
}