From ba6c326baa9f6375a3d079748b76c90b52eb399b Mon Sep 17 00:00:00 2001 From: vaf Date: Tue, 22 Dec 2020 16:19:19 +0100 Subject: [PATCH] out of office notification, closes #241 --- src/api/common/TutanotaConstants.js | 11 + .../tutanota/OutOfOfficeNotification.js | 112 +++++++ .../OutOfOfficeNotificationMessage.js | 69 ++++ src/gui/base/Banner.js | 6 +- src/gui/base/BubbleTextField.js | 14 +- src/gui/base/Dialog.js | 2 +- src/gui/base/HtmlEditor.js | 7 +- src/gui/base/TextFieldN.js | 6 +- src/login/LoginViewController.js | 36 +- src/misc/TranslationKey.js | 21 +- .../EditOutOfOfficeNotificationDialog.js | 308 ++++++++++++++++++ src/settings/MailSettingsViewer.js | 59 +++- src/settings/OutOfOfficeNotificationUtils.js | 93 ++++++ src/translations/en.js | 21 +- test/client/Suite.js | 4 +- .../common/OutOfOfficeNotificationTest.js | 145 +++++++++ test/client/mail/MailUtilsSignatureTest.js | 2 +- 17 files changed, 892 insertions(+), 24 deletions(-) create mode 100644 src/api/entities/tutanota/OutOfOfficeNotification.js create mode 100644 src/api/entities/tutanota/OutOfOfficeNotificationMessage.js create mode 100644 src/settings/EditOutOfOfficeNotificationDialog.js create mode 100644 src/settings/OutOfOfficeNotificationUtils.js create mode 100644 test/client/common/OutOfOfficeNotificationTest.js diff --git a/src/api/common/TutanotaConstants.js b/src/api/common/TutanotaConstants.js index d6f4a7dd602..a49ffa626c7 100644 --- a/src/api/common/TutanotaConstants.js +++ b/src/api/common/TutanotaConstants.js @@ -27,6 +27,17 @@ export const REQUEST_SIZE_LIMIT_MAP: Map = new Map([ ["/rest/tutanota/draftservice", 1024 * 1024], // should be large enough ]) +export const OutOfOfficeNotificationMessageType = Object.freeze({ + Default: "0", + InsideOrganization: "1" +}) +export type OutOfOfficeNotificationMessageTypeEnum = $Values + +export const OUT_OF_OFFICE_SUBJECT_PREFIX = "Auto-reply: " + +export const OUT_OF_OFFICE_SUBJECT_MAX_LENGTH = 128 +export const OUT_OF_OFFICE_MESSAGE_MAX_LENGTH = 20 * 1024 + export const GroupType = Object.freeze({ User: "0", Admin: "1", diff --git a/src/api/entities/tutanota/OutOfOfficeNotification.js b/src/api/entities/tutanota/OutOfOfficeNotification.js new file mode 100644 index 00000000000..2c0f3f1b120 --- /dev/null +++ b/src/api/entities/tutanota/OutOfOfficeNotification.js @@ -0,0 +1,112 @@ +// @flow + +import {create, TypeRef} from "../../common/EntityFunctions" + +import type {OutOfOfficeNotificationMessage} from "./OutOfOfficeNotificationMessage" + +export const OutOfOfficeNotificationTypeRef: TypeRef = new TypeRef("tutanota", "OutOfOfficeNotification") +export const _TypeModel: TypeModel = { + "name": "OutOfOfficeNotification", + "since": 44, + "type": "ELEMENT_TYPE", + "id": 1131, + "rootId": "CHR1dGFub3RhAARr", + "versioned": false, + "encrypted": false, + "values": { + "_format": { + "name": "_format", + "id": 1135, + "since": 44, + "type": "Number", + "cardinality": "One", + "final": false, + "encrypted": false + }, + "_id": { + "name": "_id", + "id": 1133, + "since": 44, + "type": "GeneratedId", + "cardinality": "One", + "final": true, + "encrypted": false + }, + "_ownerGroup": { + "name": "_ownerGroup", + "id": 1136, + "since": 44, + "type": "GeneratedId", + "cardinality": "ZeroOrOne", + "final": true, + "encrypted": false + }, + "_permissions": { + "name": "_permissions", + "id": 1134, + "since": 44, + "type": "GeneratedId", + "cardinality": "One", + "final": true, + "encrypted": false + }, + "enabled": { + "name": "enabled", + "id": 1137, + "since": 44, + "type": "Boolean", + "cardinality": "One", + "final": false, + "encrypted": false + }, + "endDate": { + "name": "endDate", + "id": 1139, + "since": 44, + "type": "Date", + "cardinality": "ZeroOrOne", + "final": false, + "encrypted": false + }, + "startDate": { + "name": "startDate", + "id": 1138, + "since": 44, + "type": "Date", + "cardinality": "ZeroOrOne", + "final": false, + "encrypted": false + } + }, + "associations": { + "notifications": { + "name": "notifications", + "id": 1140, + "since": 44, + "type": "AGGREGATION", + "cardinality": "Any", + "refType": "OutOfOfficeNotificationMessage", + "final": false + } + }, + "app": "tutanota", + "version": "44" +} + +export function createOutOfOfficeNotification(values?: $Shape<$Exact>): OutOfOfficeNotification { + return Object.assign(create(_TypeModel, OutOfOfficeNotificationTypeRef), values) +} + +export type OutOfOfficeNotification = { + _type: TypeRef; + + _format: NumberString; + _id: Id; + _ownerGroup: ?Id; + _permissions: Id; + enabled: boolean; + endDate: ?Date; + startDate: ?Date; + + notifications: OutOfOfficeNotificationMessage[]; +} \ No newline at end of file diff --git a/src/api/entities/tutanota/OutOfOfficeNotificationMessage.js b/src/api/entities/tutanota/OutOfOfficeNotificationMessage.js new file mode 100644 index 00000000000..617dbf680f8 --- /dev/null +++ b/src/api/entities/tutanota/OutOfOfficeNotificationMessage.js @@ -0,0 +1,69 @@ +// @flow + +import {create, TypeRef} from "../../common/EntityFunctions" + + +export const OutOfOfficeNotificationMessageTypeRef: TypeRef = new TypeRef("tutanota", "OutOfOfficeNotificationMessage") +export const _TypeModel: TypeModel = { + "name": "OutOfOfficeNotificationMessage", + "since": 44, + "type": "AGGREGATED_TYPE", + "id": 1126, + "rootId": "CHR1dGFub3RhAARm", + "versioned": false, + "encrypted": false, + "values": { + "_id": { + "name": "_id", + "id": 1127, + "since": 44, + "type": "CustomId", + "cardinality": "One", + "final": true, + "encrypted": false + }, + "message": { + "name": "message", + "id": 1129, + "since": 44, + "type": "String", + "cardinality": "One", + "final": false, + "encrypted": false + }, + "subject": { + "name": "subject", + "id": 1128, + "since": 44, + "type": "String", + "cardinality": "One", + "final": false, + "encrypted": false + }, + "type": { + "name": "type", + "id": 1130, + "since": 44, + "type": "Number", + "cardinality": "One", + "final": false, + "encrypted": false + } + }, + "associations": {}, + "app": "tutanota", + "version": "44" +} + +export function createOutOfOfficeNotificationMessage(values?: $Shape<$Exact>): OutOfOfficeNotificationMessage { + return Object.assign(create(_TypeModel, OutOfOfficeNotificationMessageTypeRef), values) +} + +export type OutOfOfficeNotificationMessage = { + _type: TypeRef; + + _id: Id; + message: string; + subject: string; + type: NumberString; +} \ No newline at end of file diff --git a/src/gui/base/Banner.js b/src/gui/base/Banner.js index 93afaba38e8..dff38f38117 100644 --- a/src/gui/base/Banner.js +++ b/src/gui/base/Banner.js @@ -20,7 +20,7 @@ export const BannerType = Object.freeze({ }) export type BannerTypeEnum = $Values -export type Attrs = { +export type BannerAttrs = { icon: AllIconsEnum, title: string, message: string, @@ -29,8 +29,8 @@ export type Attrs = { type: BannerTypeEnum } -export class Banner implements MComponent { - view({attrs}: Vnode): Children { +export class Banner implements MComponent { + view({attrs}: Vnode): Children { const colors = getColors(attrs.type) const isVertical = attrs.type === BannerType.Warning return m(MessageBoxN, { diff --git a/src/gui/base/BubbleTextField.js b/src/gui/base/BubbleTextField.js index 8926f71b4de..2b3b223a8ad 100644 --- a/src/gui/base/BubbleTextField.js +++ b/src/gui/base/BubbleTextField.js @@ -85,6 +85,8 @@ export class BubbleTextField { this.bubbles = [] + //TODO the class .flex-wrap was removed in TextFieldN and needs to be reinserted for the BubbleTextField exclusively + // see the related TODO in TextFieldN.js this.textField._injectionsLeft = () => this.bubbles.map((b, i) => { // We need overflow: hidden on both so that ellipsis on button works. // flex is for reserving space for the comma. align-items: end so that comma is pushed to the bottom. @@ -188,7 +190,7 @@ export class BubbleTextField { switch (key.keyCode) { case 13: // return case 32: // whitespace - return this.createBubbles() || false + return this.createBubbles() || false case 8: return this.handleBackspace() case 46: @@ -206,12 +208,12 @@ export class BubbleTextField { case 17: // do not react on ctrl key return true } - + // Handle commas - if (key.key === ",") { - return this.createBubbles() || false - } - + if (key.key === ",") { + return this.createBubbles() || false + } + this.removeBubbleSelection() return true } diff --git a/src/gui/base/Dialog.js b/src/gui/base/Dialog.js index 2f8adce1553..2f59455c5b3 100644 --- a/src/gui/base/Dialog.js +++ b/src/gui/base/Dialog.js @@ -150,7 +150,7 @@ export class Dialog { } /** - * By default the focus is set on the first text field after this dialog is fully visible. This behavor can be overwritten by calling this function. + * By default the focus is set on the first text field after this dialog is fully visible. This behavior can be overwritten by calling this function. */ setFocusOnLoadFunction(callback: Function): void { this._focusOnLoadFunction = callback diff --git a/src/gui/base/HtmlEditor.js b/src/gui/base/HtmlEditor.js index dff22cbaa32..d770c968a68 100644 --- a/src/gui/base/HtmlEditor.js +++ b/src/gui/base/HtmlEditor.js @@ -108,7 +108,12 @@ export class HtmlEditor { this._editor.isEnabled() && injections ? injections() : null, ]), this._editor.isEnabled() ? m("hr.hr.mb-s") : null, - m(this._editor) + m(this._editor, + { + oncreate: vnode => this._editor.initialized.promise.then(() => this._editor.setHTML(this._value())), + onremove: vnode => this._value(this.getValue()) + } + ) ]) : null, this._mode() === Mode.HTML ? m(".html", m("textarea.input-area.selectable", { oncreate: vnode => { diff --git a/src/gui/base/TextFieldN.js b/src/gui/base/TextFieldN.js index fce1be49eff..d8f94590dc8 100644 --- a/src/gui/base/TextFieldN.js +++ b/src/gui/base/TextFieldN.js @@ -16,7 +16,7 @@ export type TextFieldAttrs = { type?: TextFieldTypeEnum, helpLabel?: ?lazy, alignRight?: boolean, - injectionsLeft?: lazy, // only used by the BubbleTextField to display bubbles + injectionsLeft?: lazy, // only used by the BubbleTextField to display bubbles and out of office notification injectionsRight?: lazy, keyHandler?: keyHandler, // interceptor used by the BubbleTextField to react on certain keys onfocus?: (dom: HTMLElement, input: HTMLInputElement) => mixed, @@ -78,7 +78,9 @@ export class TextFieldN implements MComponent { } }, lang.getMaybeLazy(a.label)), m(".flex.flex-column", [ // another wrapper to fix IE 11 min-height bug https://github.com/philipwalton/flexbugs#3-min-height-on-a-flex-container-wont-apply-to-its-flex-items - m(".flex.items-end.flex-wrap", { + //TODO we need to add the class "flex-wrap" to the component below for BubbleTextFieldN + // once we refactor to have a BubbleTextFieldN component that uses this TextFieldN instead of TextField + m(".flex.items-end", { // .flex-wrap style: { 'min-height': px(size.button_height + 2), // 2 px border 'padding-bottom': this.active ? px(0) : px(1), diff --git a/src/login/LoginViewController.js b/src/login/LoginViewController.js index eb701d69783..4ac436166c5 100644 --- a/src/login/LoginViewController.js +++ b/src/login/LoginViewController.js @@ -13,7 +13,7 @@ import { TooManyRequestsError } from "../api/common/error/RestError" import {load, serviceRequestVoid, update} from "../api/main/Entity" -import {assertMainOrNode, isAdminClient, isApp, LOGIN_TITLE, Mode} from "../api/Env" +import {assertMainOrNode, isAdminClient, isApp, isTutanotaDomain, LOGIN_TITLE, Mode} from "../api/Env" import {CloseEventBusOption, Const} from "../api/common/TutanotaConstants" import {CustomerPropertiesTypeRef} from "../api/entities/sys/CustomerProperties" import {neverNull, noOp} from "../api/common/utils/Utils" @@ -42,6 +42,10 @@ import {locator} from "../api/main/MainLocator" import {checkApprovalStatus} from "../misc/LoginUtils" import {getHourCycle} from "../misc/Formatter" import {formatPrice} from "../subscription/PriceUtils" +import {showEditOutOfOfficeNotificationDialog} from "../settings/EditOutOfOfficeNotificationDialog" +import * as notificationOverlay from "../gui/base/NotificationOverlay" +import {ButtonType} from "../gui/base/ButtonN" +import {isNotificationCurrentlyActive, loadOutOfOfficeNotification} from "../settings/OutOfOfficeNotificationUtils" import {showMoreStorageNeededOrderDialog} from "../subscription/SubscriptionUtils" assertMainOrNode() @@ -266,10 +270,34 @@ export class LoginViewController implements ILoginViewController { if (!isAdminClient()) { return locator.calendarModel.init() } - }).then(() => { - lang.updateFormats({ - hourCycle: getHourCycle(logins.getUserController().userSettingsGroupRoot) }) + .then(() => { + lang.updateFormats({ + hourCycle: getHourCycle(logins.getUserController().userSettingsGroupRoot) + }) + }) + .then(() => { + return this._remindActiveOutOfOfficeNotification() + }) + } + + _remindActiveOutOfOfficeNotification(): Promise { + return loadOutOfOfficeNotification().then((notification) => { + if (notification && isNotificationCurrentlyActive(notification, new Date())) { + const notificationMessage: Component = { + view: () => { + return m("", lang.get("outOfOfficeReminder_label")) + } + } + notificationOverlay.show(notificationMessage, {label: "close_alt"}, [ + { + label: "deactivate_action", + click: () => showEditOutOfOfficeNotificationDialog(notification), + type: ButtonType.Primary + } + ]) + + } }) } diff --git a/src/misc/TranslationKey.js b/src/misc/TranslationKey.js index 1dc46c6db5b..382d5cb5aae 100644 --- a/src/misc/TranslationKey.js +++ b/src/misc/TranslationKey.js @@ -1267,4 +1267,23 @@ export type TranslationKeyType = "about_label" | "yourMessage_label" | "you_label" | "emptyString_msg" - | "dragAndDropExport_action" \ No newline at end of file + | "dragAndDropExport_action" + // TODO + | "outOfOfficeNotification_title" + | "outOfOfficeDefaultSubject_msg" + | "outOfOfficeDefault_msg" + | "invalidTimePeriod_msg" + | "message_label" + | "outOfOfficeInternal_msg" + | "outOfOfficeExternal_msg" + | "outOfOfficeEveryone_msg" + | "outOfOfficeMessageInvalid_msg" + | "outOfOfficeTimeRange_msg" + | "outOfOfficeTimeRangeHelp_msg" + | "outOfOfficeReminder_label" + | "outOfOfficeRecipients_label" + | "insideOnly_label" + | "insideOutside_label" + | "everyone_label" + | "outOfOfficeRecipientsHelp_label" + | "outOfOfficeUnencrypted_msg" \ No newline at end of file diff --git a/src/settings/EditOutOfOfficeNotificationDialog.js b/src/settings/EditOutOfOfficeNotificationDialog.js new file mode 100644 index 00000000000..27f689aa547 --- /dev/null +++ b/src/settings/EditOutOfOfficeNotificationDialog.js @@ -0,0 +1,308 @@ +// @flow +import m from "mithril" +import {Dialog} from "../gui/base/Dialog" +import {DatePicker} from "../gui/base/DatePicker" +import {getStartOfTheWeekOffsetForUser} from "../calendar/CalendarUtils" +import {HtmlEditor} from "../gui/base/HtmlEditor" +import type {OutOfOfficeNotification} from "../api/entities/tutanota/OutOfOfficeNotification" +import {createOutOfOfficeNotification} from "../api/entities/tutanota/OutOfOfficeNotification" +import type {GroupMembership} from "../api/entities/sys/GroupMembership" +import {TextFieldN} from "../gui/base/TextFieldN" +import stream from "mithril/stream/stream.js" +import {lang} from "../misc/LanguageViewModel" +import {locator} from "../api/main/MainLocator" +import {EmailSignatureType, Keys, OUT_OF_OFFICE_SUBJECT_PREFIX, OutOfOfficeNotificationMessageType} from "../api/common/TutanotaConstants" +import {DropDownSelector} from "../gui/base/DropDownSelector" +import type {CheckboxAttrs} from "../gui/base/CheckboxN" +import {CheckboxN} from "../gui/base/CheckboxN" +import type {OutOfOfficeNotificationMessage} from "../api/entities/tutanota/OutOfOfficeNotificationMessage" +import {createOutOfOfficeNotificationMessage} from "../api/entities/tutanota/OutOfOfficeNotificationMessage" +import {px} from "../gui/size" +import {ButtonType} from "../gui/base/ButtonN" +import {getDayShifted, getStartOfDay, getStartOfNextDay} from "../api/common/utils/DateUtils" +import {getDefaultNotificationLabel, getMailMembership, notificationMessagesAreValid} from "./OutOfOfficeNotificationUtils" +import {logins} from "../api/main/LoginController" +import {getDefaultSignature} from "../mail/MailUtils" + +const RecipientMessageType = Object.freeze({ + EXTERNAL_TO_EVERYONE: 0, + INTERNAL_AND_EXTERNAL: 1, + INTERNAL_ONLY: 2 +}) +type RecipientMessageTypeEnum = $Values; + +class NotificationData { + outOfOfficeNotification: OutOfOfficeNotification + mailMembership: GroupMembership + enabled: Stream + startDatePicker: DatePicker + endDatePicker: DatePicker + organizationSubject: Stream + defaultSubject: Stream + organizationOutOfOfficeEditor: HtmlEditor + defaultOutOfOfficeEditor: HtmlEditor + timeRangeEnabled: Stream = stream(false) + recipientMessageTypes: Stream = stream(RecipientMessageType.EXTERNAL_TO_EVERYONE) + + constructor(outOfOfficeNotification: ?OutOfOfficeNotification) { + this.mailMembership = getMailMembership() + this.enabled = stream(false) + this.startDatePicker = new DatePicker(getStartOfTheWeekOffsetForUser(), "dateFrom_label") + this.endDatePicker = new DatePicker(getStartOfTheWeekOffsetForUser(), "dateTo_label") + this.organizationSubject = stream("") + this.defaultSubject = stream("") + this.organizationOutOfOfficeEditor = new HtmlEditor("message_label", {enabled: true}) + .setMinHeight(100) + .showBorders() + this.defaultOutOfOfficeEditor = new HtmlEditor("message_label", {enabled: true}) + .setMinHeight(100) + .showBorders() + this._setDefaultMessages() + if (!outOfOfficeNotification) { + this.startDatePicker.setDate(getStartOfDay(new Date())) + this.outOfOfficeNotification = createOutOfOfficeNotification() + } else { + this.outOfOfficeNotification = outOfOfficeNotification + this.enabled(outOfOfficeNotification.enabled) + let defaultEnabled = false + let organizationEnabled = false + outOfOfficeNotification.notifications.forEach((notification) => { + if (notification.type === OutOfOfficeNotificationMessageType.Default) { + defaultEnabled = true + this.defaultSubject(notification.subject) + this.defaultOutOfOfficeEditor.setValue(notification.message) + } else if (notification.type === OutOfOfficeNotificationMessageType.InsideOrganization) { + organizationEnabled = true + this.organizationSubject(notification.subject) + this.organizationOutOfOfficeEditor.setValue(notification.message) + } + }) + if (defaultEnabled && organizationEnabled) { + this.recipientMessageTypes(RecipientMessageType.INTERNAL_AND_EXTERNAL) + } else if (organizationEnabled) { + this.recipientMessageTypes(RecipientMessageType.INTERNAL_ONLY) + } else { + this.recipientMessageTypes(RecipientMessageType.EXTERNAL_TO_EVERYONE) + } + if (outOfOfficeNotification.startDate) { + this.timeRangeEnabled(true) + this.startDatePicker.setDate(outOfOfficeNotification.startDate) + + // end dates are stored as the beginning of the following date. We substract one day to show the correct date to the user. + const shiftedEndDate = outOfOfficeNotification.endDate ? getDayShifted(outOfOfficeNotification.endDate, -1) : null + this.endDatePicker.setDate(shiftedEndDate) + } + } + } + + _setDefaultMessages() { + const templateSubject = lang.get("outOfOfficeDefaultSubject_msg") + const props = logins.getUserController().props + let signature = "" + if (props.emailSignatureType === EmailSignatureType.EMAIL_SIGNATURE_TYPE_CUSTOM) { + signature = props.customEmailSignature + } else if (props.emailSignatureType === EmailSignatureType.EMAIL_SIGNATURE_TYPE_DEFAULT) { + signature = getDefaultSignature() + } + let templateMessage = lang.get("outOfOfficeDefault_msg") + if (signature.length) { + templateMessage = `${templateMessage}\n
${signature}` + } + this.organizationSubject(templateSubject) + this.defaultSubject(templateSubject) + this.defaultOutOfOfficeEditor.setValue(templateMessage) + this.organizationOutOfOfficeEditor.setValue(templateMessage) + } + + /** + * Return OutOfOfficeNotification created from input data or null if invalid. + * Shows error dialogs if invalid. + * */ + getNotificationFromData(): ?OutOfOfficeNotification { + let startDate: ?Date = null + let endDate: ?Date = null + // We use the last second of the day as end time to make sure notifications are still send during this day. + // We use the local time for date picking and convert it to UTC because the server expects utc dates + if (this.timeRangeEnabled()) { + startDate = this.startDatePicker.date() + endDate = this.endDatePicker.date() + if (endDate) { + endDate = getStartOfNextDay(endDate) + } + if (!startDate || (endDate && (startDate.getTime() > endDate.getTime() || endDate.getTime() < Date.now()))) { + Dialog.error("invalidTimePeriod_msg") + return null + } + } + + const notificationMessages: OutOfOfficeNotificationMessage[] = [] + if (this.isDefaultMessageEnabled()) { + const defaultNotification: OutOfOfficeNotificationMessage = createOutOfOfficeNotificationMessage({ + subject: this.defaultSubject(), + message: this.defaultOutOfOfficeEditor.getValue(), + type: OutOfOfficeNotificationMessageType.Default + }) + notificationMessages.push(defaultNotification) + } + if (this.isOrganizationMessageEnabled()) { + const organizationNotification: OutOfOfficeNotificationMessage = createOutOfOfficeNotificationMessage({ + subject: this.organizationSubject(), + message: this.organizationOutOfOfficeEditor.getValue(), + type: OutOfOfficeNotificationMessageType.InsideOrganization + }) + notificationMessages.push(organizationNotification) + } + if (!notificationMessagesAreValid(notificationMessages)) { + Dialog.error("outOfOfficeMessageInvalid_msg") + return null + } + this.outOfOfficeNotification._ownerGroup = this.mailMembership.group + this.outOfOfficeNotification.enabled = this.enabled() + this.outOfOfficeNotification.startDate = startDate + this.outOfOfficeNotification.endDate = endDate + this.outOfOfficeNotification.notifications = notificationMessages + return this.outOfOfficeNotification + } + + isOrganizationMessageEnabled(): boolean { + return this.recipientMessageTypes() === RecipientMessageType.INTERNAL_ONLY + || this.recipientMessageTypes() === RecipientMessageType.INTERNAL_AND_EXTERNAL + } + + isDefaultMessageEnabled(): boolean { + return this.recipientMessageTypes() === RecipientMessageType.EXTERNAL_TO_EVERYONE + || this.recipientMessageTypes() === RecipientMessageType.INTERNAL_AND_EXTERNAL + } + +} + +export function showEditOutOfOfficeNotificationDialog(outOfOfficeNotification: ?OutOfOfficeNotification) { + const notificationData = new NotificationData(outOfOfficeNotification) + const statusItems = [ + { + name: lang.get("deactivated_label"), + value: false + }, + { + name: lang.get("activated_label"), + value: true + } + ] + const recipientItems = [ + { + name: lang.get("everyone_label"), + value: RecipientMessageType.EXTERNAL_TO_EVERYONE + }, + { + name: lang.get("insideOutside_label"), + value: RecipientMessageType.INTERNAL_AND_EXTERNAL + }, + { + name: lang.get("insideOnly_label"), + value: RecipientMessageType.INTERNAL_ONLY + } + ] + const recipientHelpLabel: lazy = () => lang.get("outOfOfficeRecipientsHelp_label") + const statusSelector: DropDownSelector = new DropDownSelector("state_label", null, statusItems, notificationData.enabled) + const recipientSelector: DropDownSelector = new DropDownSelector("outOfOfficeRecipients_label", recipientHelpLabel, recipientItems, notificationData.recipientMessageTypes) + const timeRangeCheckboxAttrs: CheckboxAttrs = { + label: () => lang.get("outOfOfficeTimeRange_msg"), + checked: notificationData.timeRangeEnabled, + helpLabel: () => lang.get("outOfOfficeTimeRangeHelp_msg"), + } + + const childForm = { + view: () => { + const defaultEnabled = notificationData.isDefaultMessageEnabled() + const organizationEnabled = notificationData.isOrganizationMessageEnabled() + return [ + m(statusSelector), + m(recipientSelector), + m(".mt.flex-start", m(CheckboxN, timeRangeCheckboxAttrs)), + notificationData.timeRangeEnabled() + ? m(".flex-start", [ + m(notificationData.startDatePicker), m(notificationData.endDatePicker) + ]) + : null, + m(".mt-l", lang.get("outOfOfficeUnencrypted_msg",)), + defaultEnabled + ? [ + m(".h4.text-center.mt-l", getDefaultNotificationLabel(organizationEnabled)), + m(TextFieldN, { + label: "subject_label", + value: notificationData.defaultSubject, + injectionsLeft: () => m(".flex-no-grow-no-shrink-auto.pr-s", { + style: { + 'line-height': px(24), + opacity: '1' + } + }, OUT_OF_OFFICE_SUBJECT_PREFIX) + } + ), + m(notificationData.defaultOutOfOfficeEditor) + ] + : null, + organizationEnabled + ? [ + m(".h4.text-center.mt-l", lang.get("outOfOfficeInternal_msg")), + m(TextFieldN, { + label: "subject_label", + value: notificationData.organizationSubject, + injectionsLeft: () => m(".flex-no-grow-no-shrink-auto.pr-s", { + style: { + 'line-height': px(24), + opacity: '1' + } + }, OUT_OF_OFFICE_SUBJECT_PREFIX) + } + ), + m(notificationData.organizationOutOfOfficeEditor) + ] + : null, + m(".pb", "") + ] + } + } + + const saveOutOfOfficeNotification = () => { + const sendableNotification = notificationData.getNotificationFromData() + // Error messages are already shown if sendableNotification is null. We do not close the dialog. + if (sendableNotification) { + const requestPromise = outOfOfficeNotification + ? locator.entityClient.update(sendableNotification) + : locator.entityClient.setup(null, sendableNotification) + // If the request fails the user should have to close manually. Otherwise the input data would be lost. + requestPromise.then(() => cancel()).catch(e => Dialog.error(() => e.toString())) + } + } + + function cancel() { + dialog.close() + } + + const dialogHeaderAttrs = { + left: [{label: "cancel_action", click: cancel, type: ButtonType.Secondary}], + right: [{label: "ok_action", click: saveOutOfOfficeNotification, type: ButtonType.Primary}], + middle: () => lang.get("outOfOfficeNotification_title"), + } + const dialog = Dialog.largeDialog(dialogHeaderAttrs, childForm).addShortcut({ + key: Keys.ESC, + exec: cancel, + help: "close_alt" + }).addShortcut({ + key: Keys.S, + ctrl: true, + exec: saveOutOfOfficeNotification, + help: "ok_action" + }) + dialog.show() +} + + + + + + + + + diff --git a/src/settings/MailSettingsViewer.js b/src/settings/MailSettingsViewer.js index 9458449e212..0aa5ab3c846 100644 --- a/src/settings/MailSettingsViewer.js +++ b/src/settings/MailSettingsViewer.js @@ -41,6 +41,14 @@ import type {EditAliasesFormAttrs} from "./EditAliasesFormN" import {createEditAliasFormAttrs, updateNbrOfAliases} from "./EditAliasesFormN" import {getEnabledMailAddressesForGroupInfo} from "../api/common/utils/GroupUtils"; import {isSameId} from "../api/common/utils/EntityUtils"; +import {showEditOutOfOfficeNotificationDialog} from "./EditOutOfOfficeNotificationDialog" +import {MailboxGroupRootTypeRef} from "../api/entities/tutanota/MailboxGroupRoot" +import type {OutOfOfficeNotification} from "../api/entities/tutanota/OutOfOfficeNotification" +import {LazyLoaded} from "../api/common/utils/LazyLoaded" +import {showNotAvailableForFreeDialog} from "../misc/ErrorHandlerImpl" +import {OutOfOfficeNotificationTypeRef} from "../api/entities/tutanota/OutOfOfficeNotification" +import {formatDate} from "../misc/Formatter" +import {loadOutOfOfficeNotification} from "./OutOfOfficeNotificationUtils" assertMainOrNode() @@ -57,6 +65,8 @@ export class MailSettingsViewer implements UpdatableSettingsViewer { _indexStateWatch: ?Stream; _identifierListViewer: IdentifierListViewer; _editAliasFormAttrs: EditAliasesFormAttrs; + _outOfOfficeNotification: LazyLoaded; + _outOfOfficeIsEnabled: Stream; // stores the status label, based on whether the notification is/ or will really be activated (checking start time/ end time) constructor() { this._defaultSender = stream(getDefaultSenderFromUser(logins.getUserController())) @@ -68,9 +78,9 @@ export class MailSettingsViewer implements UpdatableSettingsViewer { this._enableMailIndexing = stream(locator.search.indexState().mailIndexEnabled) this._inboxRulesExpanded = stream(false) this._inboxRulesTableLines = stream([]) + this._outOfOfficeIsEnabled = stream(lang.get("deactivated_label")) this._indexStateWatch = null this._identifierListViewer = new IdentifierListViewer(logins.getUserController().user) - this._updateInboxRules(logins.getUserController().props) this._editAliasFormAttrs = createEditAliasFormAttrs(logins.getUserController().userGroupInfo) @@ -78,6 +88,11 @@ export class MailSettingsViewer implements UpdatableSettingsViewer { if (logins.getUserController().isGlobalAdmin()) { updateNbrOfAliases(this._editAliasFormAttrs) } + + this._outOfOfficeNotification = new LazyLoaded(() => { + return loadOutOfOfficeNotification() + }, null) + this._outOfOfficeNotification.getAsync().then(() => this._updateOutOfOfficeNotification()) } view(): Children { @@ -130,6 +145,21 @@ export class MailSettingsViewer implements UpdatableSettingsViewer { injectionsRight: () => [m(ButtonN, changeSignatureButtonAttrs)] } + const outOfOfficeAttrs: TextFieldAttrs = { + label: "outOfOfficeNotification_title", + value: this._outOfOfficeIsEnabled, + disabled: true, + injectionsRight: () => [m(ButtonN, editOutOfOfficeNotificationButtonAttrs)] + } + + const editOutOfOfficeNotificationButtonAttrs: ButtonAttrs = { + label: "outOfOfficeNotification_title", + click: () => logins.getUserController().isPremiumAccount() + ? this._outOfOfficeNotification.getAsync().then(notification => showEditOutOfOfficeNotificationDialog(notification)) + : showNotAvailableForFreeDialog(true), + icon: () => Icons.Edit + } + const defaultUnconfidentialAttrs: DropDownSelectorAttrs = { label: "defaultExternalDelivery_label", items: [ @@ -203,7 +233,6 @@ export class MailSettingsViewer implements UpdatableSettingsViewer { icon: () => Icons.Add } - const inboxRulesTableAttrs: TableAttrs = { columnHeading: ["inboxRuleField_label", "inboxRuleValue_label", "inboxRuleTargetFolder_label"], columnWidths: [ColumnWidth.Small, ColumnWidth.Largest, ColumnWidth.Small], @@ -235,6 +264,7 @@ export class MailSettingsViewer implements UpdatableSettingsViewer { logins.isEnabled(FeatureType.InternalCommunication) ? null : m(DropDownSelectorN, sendPlaintextAttrs), logins.isEnabled(FeatureType.DisableContacts) ? null : m(DropDownSelectorN, noAutomaticContactsAttrs), m(DropDownSelectorN, enableMailIndexingAttrs), + m(TextFieldN, outOfOfficeAttrs), (logins.getUserController().isGlobalAdmin()) ? m(EditAliasesFormN, this._editAliasFormAttrs) : null, logins.isEnabled(FeatureType.InternalCommunication) ? null : [ m(".flex-space-between.items-center.mt-l.mb-s", [ @@ -244,7 +274,8 @@ export class MailSettingsViewer implements UpdatableSettingsViewer { m(ExpanderPanelN, {expanded: this._inboxRulesExpanded}, m(TableN, inboxRulesTableAttrs)), m(".small", lang.get("nbrOfInboxRules_msg", {"{1}": logins.getUserController().props.inboxRules.length})), ], - m(this._identifierListViewer) + m(this._identifierListViewer), + ]) ] } @@ -281,6 +312,24 @@ export class MailSettingsViewer implements UpdatableSettingsViewer { }) } + _updateOutOfOfficeNotification(): void { + const notification = this._outOfOfficeNotification.getLoaded() + if (notification && notification.enabled) { + var timeRange = "" + if (notification.startDate) { + timeRange += " (" + formatDate(notification.startDate) + if (notification.endDate) { + timeRange += " - " + formatDate(notification.endDate) + } + timeRange += ")" + } + this._outOfOfficeIsEnabled(lang.get("activated_label") + timeRange) + } else { + this._outOfOfficeIsEnabled(lang.get("deactivated_label")) + } + m.redraw() + } + _getTextForTarget(mailboxDetails: MailboxDetail, targetFolderId: IdTuple): string { let folder = mailboxDetails.folders.find(folder => isSameId(folder._id, targetFolderId)) if (folder) { @@ -308,6 +357,10 @@ export class MailSettingsViewer implements UpdatableSettingsViewer { this._editAliasFormAttrs.userGroupInfo = groupInfo m.redraw() }) + } else if (isUpdateForTypeRef(MailboxGroupRootTypeRef, update) + || isUpdateForTypeRef(OutOfOfficeNotificationTypeRef, update)) { + //TODO would it be enough to use either of those, to avoid redrawing twice? + this._outOfOfficeNotification.reload().then(() => this._updateOutOfOfficeNotification()) } return p.then(() => { this._identifierListViewer.entityEventReceived(update) diff --git a/src/settings/OutOfOfficeNotificationUtils.js b/src/settings/OutOfOfficeNotificationUtils.js new file mode 100644 index 00000000000..122192ce781 --- /dev/null +++ b/src/settings/OutOfOfficeNotificationUtils.js @@ -0,0 +1,93 @@ +//@flow +import type {OutOfOfficeNotification} from "../api/entities/tutanota/OutOfOfficeNotification" +import {OutOfOfficeNotificationTypeRef} from "../api/entities/tutanota/OutOfOfficeNotification" +import {formatDate} from "../misc/Formatter" +import {lang} from "../misc/LanguageViewModel" +import type {OutOfOfficeNotificationMessage} from "../api/entities/tutanota/OutOfOfficeNotificationMessage" +import {locator} from "../api/main/MainLocator" +import {MailboxGroupRootTypeRef} from "../api/entities/tutanota/MailboxGroupRoot" +import type {GroupMembership} from "../api/entities/sys/GroupMembership" +import {logins} from "../api/main/LoginController" +import {OUT_OF_OFFICE_MESSAGE_MAX_LENGTH, OUT_OF_OFFICE_SUBJECT_MAX_LENGTH} from "../api/common/TutanotaConstants" + +/** + * Returns true if notifications are currently sent. + */ +export function isNotificationCurrentlyActive(notification: OutOfOfficeNotification, currentDate: Date): boolean { + if (notification.enabled) { + if (notification.startDate && !notification.endDate) { + return currentDate >= notification.startDate + } else if (notification.startDate && notification.endDate) { + return currentDate >= notification.startDate && currentDate < notification.endDate + } else { + // no dates specified but enabled + return true + } + } else { + return false + } +} + +export function formatActivateState(notification: OutOfOfficeNotification): string { + if (notification.enabled) { + var timeRange = "" + if (notification.startDate) { + timeRange += " (" + formatDate(notification.startDate) + if (notification.endDate) { + timeRange += " - " + formatDate(notification.endDate) + } + timeRange += ")" + } + return lang.get("activated_label") + timeRange + } else { + return lang.get("deactivated_label") + } +} + +/** + * + * @param messages + * @returns {boolean} true if messages is a valid array of notification messages (at least one message) + */ +export function notificationMessagesAreValid(messages: OutOfOfficeNotificationMessage[]): boolean { + if (messages.length < 1 || messages.length > 2) { + return false + } + let result = true + messages.forEach((message) => { + if (message.subject.length === 0 + || message.message.length === 0 + || message.message === "

" // TODO proper check + || message.subject.length > OUT_OF_OFFICE_SUBJECT_MAX_LENGTH + || message.message.length > OUT_OF_OFFICE_MESSAGE_MAX_LENGTH) { + result = false + } + }) + return result +} + +/** + * + * @param organizationMessageEnabled true if a special messagesfor senders from the same organization is setup + * @returns {string} the label for default notifications (depends on whether only default notifications or both default and same organization notifications are enabled) + */ +export function getDefaultNotificationLabel(organizationMessageEnabled: boolean): string { + if (organizationMessageEnabled) { + return lang.get("outOfOfficeExternal_msg") + } else { + return lang.get("outOfOfficeEveryone_msg") + } +} + +export function getMailMembership(): GroupMembership { + return logins.getUserController().getUserMailGroupMembership() +} + +export function loadOutOfOfficeNotification(): Promise { + const mailMembership = getMailMembership() + return locator.entityClient.load(MailboxGroupRootTypeRef, mailMembership.group).then((grouproot) => { + if (grouproot.outOfOfficeNotification) { + return locator.entityClient.load(OutOfOfficeNotificationTypeRef, grouproot.outOfOfficeNotification) + } + }) +} \ No newline at end of file diff --git a/src/translations/en.js b/src/translations/en.js index b53945b9a04..bd0b49f6b4b 100644 --- a/src/translations/en.js +++ b/src/translations/en.js @@ -1279,6 +1279,25 @@ export default { "yourCalendars_label": "Your calendars", "yourFolders_action": "YOUR FOLDERS", "yourMessage_label": "Your message", - "you_label": "You" + "you_label": "You", + "dragAndDropExport_action": "Drag & drop export", + "outOfOfficeNotification_title": "Out of office notification", + "outOfOfficeDefault_msg": "Hello,\n
\n
thanks for your email. I am out of the office and will be back soon. Until then I will have limited access to my email.\n
\n
Kind Regards", + "outOfOfficeDefaultSubject_msg": "I am out of the office", + "invalidTimePeriod_msg": "The entered time period is invalid.", + "message_label": "Message", + "outOfOfficeInternal_msg": "Inside your organization", + "outOfOfficeExternal_msg": "Outside your organization", + "outOfOfficeEveryone_msg": "To everyone", + "outOfOfficeMessageInvalid_msg": "The subject and/or message is invalid.\nEmpty subjects or messages are not allowed.\nMaximum subject size: 128 characters. Maximum message size: 20kb.", + "outOfOfficeTimeRange_msg": "Only send during this time range:", + "outOfOfficeTimeRangeHelp_msg": "Check to pick dates.", + "outOfOfficeReminder_label": "Out of office notifications are activated.", + "outOfOfficeRecipients_label": "Notification recipients", + "outOfOfficeRecipientsHelp_label": "Select who receives the out of office notifications and if distinct notifications are sent to members of your organization and other recipients.", + "insideOnly_label": "Inside only", + "insideOutside_label": "Inside/outside", + "everyone_label": "Everyone", + "outOfOfficeUnencrypted_msg": "Please note that out of office notifications are sent in plaintext to the server and to future recipients." } } diff --git a/test/client/Suite.js b/test/client/Suite.js index 25ec3ff1ab8..6df73750798 100644 --- a/test/client/Suite.js +++ b/test/client/Suite.js @@ -27,11 +27,13 @@ import "./gui/base/WizardDialogNTest" import "./calendar/CalendarEventViewModelTest" import "./gui/ColorTest" import "./mail/SendMailModelTest" +import "./common/OutOfOfficeNotificationTest" +import "./subscription/SubscriptionUtilsTest" +import "./subscription/SwitchSubscriptionDialogModelTest" import o from "ospec" import {random} from "../../src/api/worker/crypto/Randomizer" import {EntropySrc} from "../../src/api/common/TutanotaConstants" import {preTest, reportTest} from "../api/TestUtils" -import {noOp} from "../../src/api/common/utils/Utils" (async () => { if (typeof process != "undefined") { diff --git a/test/client/common/OutOfOfficeNotificationTest.js b/test/client/common/OutOfOfficeNotificationTest.js new file mode 100644 index 00000000000..796d3223e4b --- /dev/null +++ b/test/client/common/OutOfOfficeNotificationTest.js @@ -0,0 +1,145 @@ +//@flow +import o from "ospec/ospec.js" +import {createOutOfOfficeNotification} from "../../../src/api/entities/tutanota/OutOfOfficeNotification" +import {mockAttribute, unmockAttribute} from "../../api/TestUtils" +import {lang} from "../../../src/misc/LanguageViewModel" +import { + formatActivateState, + isNotificationCurrentlyActive, + notificationMessagesAreValid +} from "../../../src/settings/OutOfOfficeNotificationUtils" +import {getDayShifted, getStartOfDay, getStartOfNextDay} from "../../../src/api/common/utils/DateUtils" +import {createOutOfOfficeNotificationMessage} from "../../../src/api/entities/tutanota/OutOfOfficeNotificationMessage" + +o.spec("OutOfOfficeNotificationTest", function () { + + const mockedAttributes = [] + + o.before(function () { + mockedAttributes.push(mockAttribute(lang, lang.get, function (key, obj) { + if (key === "activated_label") { + return "Activated" + } else if (key === "deactivated_label") { + return "Deactivated" + } + throw new Error("unexpected translation key: " + key) + })) + }) + + o.after(function () { + mockedAttributes.forEach(function (mockedAttribute) { + unmockAttribute(mockedAttribute) + }) + }) + + o("Active state formatting", function () { + let notification = createOutOfOfficeNotification({enabled: true, startDate: null, endDate: null}) + o(formatActivateState(notification)).equals("Activated") + notification = createOutOfOfficeNotification({enabled: true, startDate: new Date(2020, 11, 15), endDate: null}) + o(formatActivateState(notification)).equals("Activated (12/15/2020)") + notification = createOutOfOfficeNotification({enabled: true, startDate: new Date(2020, 11, 15), endDate: new Date(2021, 0, 10)}) + o(formatActivateState(notification)).equals("Activated (12/15/2020 - 1/10/2021)") + notification = createOutOfOfficeNotification({enabled: false, startDate: new Date(2020, 11, 15), endDate: new Date(2021, 0, 10)}) + o(formatActivateState(notification)).equals("Deactivated") + }); + + o("is active with enabled notification", function () { + const now = new Date() + const oneDayBefore = getDayShifted(now, -1); + const oneDayAfter = getDayShifted(now, +1); + let notification = createOutOfOfficeNotification({enabled: true, startDate: null, endDate: null}) + o(isNotificationCurrentlyActive(notification, now)).equals(true) + o(isNotificationCurrentlyActive(notification, oneDayBefore)).equals(true) + o(isNotificationCurrentlyActive(notification, oneDayAfter)).equals(true) + }); + + o("is active with disabled notification", function () { + const now = new Date() + const oneDayBefore = getDayShifted(now, -1); + const oneDayAfter = getDayShifted(now, +1); + let notification = createOutOfOfficeNotification({enabled: false, startDate: null, endDate: null}) + o(isNotificationCurrentlyActive(notification, now)).equals(false) + o(isNotificationCurrentlyActive(notification, oneDayBefore)).equals(false) + o(isNotificationCurrentlyActive(notification, oneDayAfter)).equals(false) + }); + + + o("is active with startDate", function () { + const now = new Date() + const oneDayBefore = getDayShifted(now, -1); + const oneDayAfter = getDayShifted(now, +1); + let notification = createOutOfOfficeNotification({enabled: true, startDate: getStartOfDay(now), endDate: null}) + o(isNotificationCurrentlyActive(notification, now)).equals(true) + o(isNotificationCurrentlyActive(notification, oneDayBefore)).equals(false) + o(isNotificationCurrentlyActive(notification, oneDayAfter)).equals(true) + }); + + o("is active with start and end date", function () { + const now = new Date() + const oneDayBefore = getDayShifted(now, -1); + const oneDayAfter = getDayShifted(now, +1); + let notification = createOutOfOfficeNotification({ + enabled: true, + startDate: getStartOfDay(now), + endDate: getStartOfNextDay(now) + }) + o(isNotificationCurrentlyActive(notification, now)).equals(true) + o(isNotificationCurrentlyActive(notification, oneDayBefore)).equals(false) + o(isNotificationCurrentlyActive(notification, oneDayAfter)).equals(false) + }); + o("is active with start and end date", function () { + const now = new Date() + const activeUntil = getDayShifted(now, +5); + const oneDayAfter = getStartOfNextDay(activeUntil) + + let notification = createOutOfOfficeNotification({ + enabled: true, + startDate: getStartOfDay(now), + endDate: getStartOfNextDay(activeUntil) + }) + o(isNotificationCurrentlyActive(notification, now)).equals(true) + o(isNotificationCurrentlyActive(notification, activeUntil)).equals(true) + o(isNotificationCurrentlyActive(notification, oneDayAfter)).equals(false) + }); + + + o("are messages valid", function () { + const messages = [] + const outsideMessage = createOutOfOfficeNotificationMessage({ + message: "out", + subject: "out subject", + type: "0" //OutOfOfficeNotificationMessageType.Default + }) + const insideMessage = createOutOfOfficeNotificationMessage({ + message: "in", + subject: "in subject", + type: "1" //OutOfOfficeNotificationMessageType.SameOrganization + }) + const noSubjectMessage = createOutOfOfficeNotificationMessage({ + message: "invalid message", + subject: "", + type: "0" + }) + const noTextMessage = createOutOfOfficeNotificationMessage({ + message: "", + subject: "subject", + type: "0" + }) + o(notificationMessagesAreValid(messages)).equals(false) // empty not allowed + messages.push(outsideMessage) + o(notificationMessagesAreValid(messages)).equals(true) + messages.push(insideMessage) + o(notificationMessagesAreValid(messages)).equals(true) + messages.push(outsideMessage) + o(notificationMessagesAreValid(messages)).equals(false) //too many + messages.shift() + messages.shift() + messages.push(noSubjectMessage) + o(notificationMessagesAreValid(messages)).equals(false) // invalid not allowed + messages.shift() + messages.shift() + messages.push(noTextMessage) + o(notificationMessagesAreValid(messages)).equals(false) // invalid not allowed + }) + +}) \ No newline at end of file diff --git a/test/client/mail/MailUtilsSignatureTest.js b/test/client/mail/MailUtilsSignatureTest.js index 075d0edb5eb..28e92421f06 100644 --- a/test/client/mail/MailUtilsSignatureTest.js +++ b/test/client/mail/MailUtilsSignatureTest.js @@ -11,7 +11,7 @@ import type {LoginController} from "../../../src/api/main/LoginController" const TEST_DEFAULT_SIGNATURE = "--\nDefault signature" -o.spec("MailUtilsSignature", function () { +o.spec("MailUtilsSignatureTest", function () { const mockedAttributes = []