diff --git a/application/Espo/Classes/FieldValidators/Email/SendAt/Future.php b/application/Espo/Classes/FieldValidators/Email/SendAt/Future.php new file mode 100644 index 0000000000..b03f5ebd62 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/Email/SendAt/Future.php @@ -0,0 +1,58 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\FieldValidators\Email\SendAt; + +use Espo\Core\Field\DateTime; +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Entities\Email; +use Espo\ORM\Entity; + +/** + * @implements Validator + */ +class Future implements Validator +{ + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + $value = $entity->getSendAt(); + + if (!$value) { + return null; + } + + if ($value->isLessThan(DateTime::createNow())) { + return Failure::create(); + } + + return null; + } +} diff --git a/application/Espo/Classes/Jobs/SendScheduledEmails.php b/application/Espo/Classes/Jobs/SendScheduledEmails.php new file mode 100644 index 0000000000..42716586c0 --- /dev/null +++ b/application/Espo/Classes/Jobs/SendScheduledEmails.php @@ -0,0 +1,163 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Jobs; + +use Espo\Core\Acl\Table; +use Espo\Core\AclManager; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Error; +use Espo\Core\Field\DateTime; +use Espo\Core\Job\JobDataLess; +use Espo\Core\Mail\Exceptions\NoSmtp; +use Espo\Core\Mail\Exceptions\SendingError; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Language; +use Espo\Core\Utils\Log; +use Espo\Entities\Email; +use Espo\Entities\Notification; +use Espo\Entities\User; +use Espo\ORM\EntityManager; +use Espo\Tools\Email\SendService; +use Exception; +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class SendScheduledEmails implements JobDataLess +{ + private const BATCH_COUNT = 10; + + public function __construct( + private EntityManager $entityManager, + private Config $config, + private Log $log, + private SendService $sendService, + private Language $language, + private AclManager $aclManager, + ) {} + + public function run(): void + { + $emails = $this->entityManager + ->getRDBRepositoryByClass(Email::class) + ->where([ + 'status' => Email::STATUS_DRAFT, + 'sendAt!=' => null, + 'sendAt<' => DateTime::createNow()->toString(), + ]) + ->order('sendAt') + ->order('createdAt') + ->limit(0, $this->getPortion()) + ->sth() + ->find(); + + foreach ($emails as $email) { + try { + $this->processEmail($email); + } catch (SendingError|Exception $e) { + $this->log->error("Scheduled email send, {$email->getId()}." . $e->getMessage(), ['exception' => $e]); + + $this->processFail($email); + } + } + } + + private function getPortion(): int + { + return $this->config->get('emailScheduledBatchCount') ?? self::BATCH_COUNT; + } + + + /** + * @throws BadRequest + * @throws Error + * @throws NoSmtp + * @throws SendingError + */ + private function processEmail(Email $email): void + { + $user = $this->getUser($email); + + $this->sendService->send($email, $user); + + $this->entityManager->saveEntity($email); + } + + private function processFail(Email $email): void + { + $email->setSendAt(null); + $this->entityManager->saveEntity($email); + + if (!$email->getCreatedBy()) { + return; + } + + $message = $this->language->translateLabel('couldNotSentScheduledEmail', 'messages', 'Email'); + $message = str_replace('{link}', $this->getEmailLink($email), $message); + + $notification = $this->entityManager->getRDBRepositoryByClass(Notification::class)->getNew(); + + $notification + ->setType(Notification::TYPE_MESSAGE) + ->setUserId($email->getCreatedBy()->getId()) + ->setMessage($message); + + $this->entityManager->saveEntity($notification); + } + + private function getEmailLink(Email $email): string + { + return rtrim($this->config->getSiteUrl()) . '#Email/view/' . $email->getId(); + } + + private function getUser(Email $email): User + { + if (!$email->getCreatedBy()) { + throw new RuntimeException("No createdBy in email."); + } + + $userId = $email->getCreatedBy()->getId(); + + $user = $this->entityManager + ->getRDBRepositoryByClass(User::class) + ->getById($userId); + + if (!$user) { + throw new RuntimeException("User $userId not found."); + } + + if (!$this->aclManager->checkScope($user, Email::ENTITY_TYPE, Table::ACTION_CREATE)) { + throw new RuntimeException("User $userId don't have access to create emails."); + } + + return $user; + } +} diff --git a/application/Espo/Classes/RecordHooks/Email/BeforeSave.php b/application/Espo/Classes/RecordHooks/Email/BeforeSave.php new file mode 100644 index 0000000000..b899c309d8 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Email/BeforeSave.php @@ -0,0 +1,53 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\Email; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Entities\Email; +use Espo\ORM\Entity; + +/** + * @implements SaveHook + */ +class BeforeSave implements SaveHook +{ + + public function process(Entity $entity): void + { + if ( + $entity->getStatus() !== Email::STATUS_DRAFT && + $entity->getSendAt() && + $entity->isAttributeChanged('sendAt') + ) { + throw new BadRequest("Cannot set send-at if status is not Draft."); + } + } +} diff --git a/application/Espo/Core/Mail/Sender.php b/application/Espo/Core/Mail/Sender.php index 7dc3f7551d..792e63bf95 100644 --- a/application/Espo/Core/Mail/Sender.php +++ b/application/Espo/Core/Mail/Sender.php @@ -587,8 +587,10 @@ class Sender $this->transport->send($message); - $email->setStatus(Email::STATUS_SENT); - $email->set('dateSent', DateTime::createNow()->toString()); + $email + ->setStatus(Email::STATUS_SENT) + ->setDateSent(DateTime::createNow()) + ->setSendAt(null); } catch (Exception $e) { /** @noinspection PhpDeprecationInspection */ $this->resetParams(); diff --git a/application/Espo/Core/Upgrades/Migrations/V9_0/AfterUpgrade.php b/application/Espo/Core/Upgrades/Migrations/V9_0/AfterUpgrade.php index 203938d5b1..b70e6095c5 100644 --- a/application/Espo/Core/Upgrades/Migrations/V9_0/AfterUpgrade.php +++ b/application/Espo/Core/Upgrades/Migrations/V9_0/AfterUpgrade.php @@ -31,6 +31,7 @@ namespace Espo\Core\Upgrades\Migrations\V9_0; use Espo\Core\Upgrades\Migration\Script; use Espo\Entities\Preferences; +use Espo\Entities\ScheduledJob; use Espo\Entities\User; use Espo\ORM\EntityManager; @@ -65,5 +66,17 @@ class AfterUpgrade implements Script $preferences->set('reactionNotifications', true); $this->entityManager->saveEntity($preferences); } + + $this->createScheduledJob(); + } + + private function createScheduledJob(): void + { + $this->entityManager->createEntity(ScheduledJob::ENTITY_TYPE, [ + 'name' => 'Send Scheduled Emails', + 'job' => 'SendScheduledEmails', + 'status' => 'Active', + 'scheduling' => '*/10 * * * *', + ]); } } diff --git a/application/Espo/Entities/Email.php b/application/Espo/Entities/Email.php index e4d25ea5a5..b959ed6493 100644 --- a/application/Espo/Entities/Email.php +++ b/application/Espo/Entities/Email.php @@ -901,4 +901,17 @@ class Email extends Entity /** @var Collection */ return $this->relations->getMany('attachments'); } + + public function getSendAt(): ?DateTime + { + /** @var ?DateTime */ + return $this->getValueObject('sendAt'); + } + + public function setSendAt(?DateTime $sendAt): self + { + $this->setValueObject('sendAt', $sendAt); + + return $this; + } } diff --git a/application/Espo/Resources/defaults/config.php b/application/Espo/Resources/defaults/config.php index 56aadb97a1..08337c6223 100644 --- a/application/Espo/Resources/defaults/config.php +++ b/application/Espo/Resources/defaults/config.php @@ -310,5 +310,6 @@ return [ 'authIpAddressCheckExcludedUsersNames' => (object) [], 'availableReactions' => ['Like'], 'streamReactionsCheckMaxSize' => 50, + 'emailScheduledBatchCount' => 50, 'isInstalled' => false, ]; diff --git a/application/Espo/Resources/i18n/en_US/Email.json b/application/Espo/Resources/i18n/en_US/Email.json index 716db05b66..c99b97cd0e 100644 --- a/application/Espo/Resources/i18n/en_US/Email.json +++ b/application/Espo/Resources/i18n/en_US/Email.json @@ -60,7 +60,8 @@ "event": "Event", "icsEventDateStart": "ICS Event Date Start", "groupFolder": "Group Folder", - "groupStatusFolder": "Group Status Folder" + "groupStatusFolder": "Group Status Folder", + "sendAt": "Send At" }, "links": { "replied": "Replied", @@ -129,13 +130,15 @@ "Event": "Event", "View Attachments": "View Attachments", "Moved to Trash": "Moved to Trash", - "Retrieved from Trash": "Retrieved from Trash" + "Retrieved from Trash": "Retrieved from Trash", + "Schedule Send": "Schedule Send" }, "strings": { "sendingFailed": "Email sending failed", "group": "Group" }, "messages": { + "couldNotSentScheduledEmail": "Could not send scheduled [email]({link})", "notEditAccess": "No edit access to email.", "groupFolderNoAccess": "No access to group folder.", "groupMoveOutNoEditAccess": "Cannot move out from group folder. No edit access to email.", diff --git a/application/Espo/Resources/i18n/en_US/Global.json b/application/Espo/Resources/i18n/en_US/Global.json index e46481cb80..e4a6f1bda1 100644 --- a/application/Espo/Resources/i18n/en_US/Global.json +++ b/application/Espo/Resources/i18n/en_US/Global.json @@ -169,6 +169,7 @@ "Created": "Created", "Create": "Create", "create": "create", + "Scheduled": "Scheduled", "Overview": "Overview", "Details": "Details", "Add Field": "Add Field", @@ -254,6 +255,7 @@ "Today": "Today", "Tomorrow": "Tomorrow", "Yesterday": "Yesterday", + "Now": "Now", "Submit": "Submit", "Close": "Close", "Yes": "Yes", diff --git a/application/Espo/Resources/i18n/en_US/ScheduledJob.json b/application/Espo/Resources/i18n/en_US/ScheduledJob.json index e29d22c386..2cdb7b2ea5 100644 --- a/application/Espo/Resources/i18n/en_US/ScheduledJob.json +++ b/application/Espo/Resources/i18n/en_US/ScheduledJob.json @@ -21,7 +21,8 @@ "AuthTokenControl": "Auth Token Control", "SendEmailNotifications": "Send Email Notifications", "CheckNewVersion": "Check for New Version", - "ProcessWebhookQueue": "Process Webhook Queue" + "ProcessWebhookQueue": "Process Webhook Queue", + "SendScheduledEmails": "Send Scheduled Emails" }, "cronSetup": { "linux": "Note: Add this line to the crontab file to run Espo Scheduled Jobs:", diff --git a/application/Espo/Resources/i18n/en_US/Settings.json b/application/Espo/Resources/i18n/en_US/Settings.json index 68f4a1f345..d4eb2cda0b 100644 --- a/application/Espo/Resources/i18n/en_US/Settings.json +++ b/application/Espo/Resources/i18n/en_US/Settings.json @@ -86,6 +86,7 @@ "personalEmailMaxPortionSize": "Max email portion size for personal account fetching", "inboundEmailMaxPortionSize": "Max email portion size for group account fetching", "maxEmailAccountCount": "Max number of personal email accounts per user", + "emailScheduledBatchCount": "Max number of scheduled emails sent per batch", "authTokenLifetime": "Auth Token Lifetime (hours)", "authTokenMaxIdleTime": "Auth Token Max Idle Time (hours)", "dashboardLayout": "Dashboard Layout (default)", @@ -307,6 +308,7 @@ "Currency Settings": "Currency Settings", "Currency Rates": "Currency Rates", "Mass Email": "Mass Email", + "Scheduled Send": "Scheduled Send", "Test Connection": "Test Connection", "Connecting": "Connecting...", "Activities": "Activities", diff --git a/application/Espo/Resources/layouts/Email/defaultSidePanel.json b/application/Espo/Resources/layouts/Email/defaultSidePanel.json index a6c18ff3f9..b9f8870303 100644 --- a/application/Espo/Resources/layouts/Email/defaultSidePanel.json +++ b/application/Espo/Resources/layouts/Email/defaultSidePanel.json @@ -16,5 +16,8 @@ }, { "name": "tasks" + }, + { + "name": "sendAt" } ] diff --git a/application/Espo/Resources/layouts/Settings/outboundEmails.json b/application/Espo/Resources/layouts/Settings/outboundEmails.json index 777c61869e..4034e639b9 100644 --- a/application/Espo/Resources/layouts/Settings/outboundEmails.json +++ b/application/Espo/Resources/layouts/Settings/outboundEmails.json @@ -24,5 +24,11 @@ [{"name": "massEmailOpenTracking"}, {"name": "massEmailVerp"}], [{"name": "massEmailDisableMandatoryOptOutLink"}, false] ] + }, + { + "label": "Scheduled Send", + "rows": [ + [{"name": "emailScheduledBatchCount"}, false] + ] } ] diff --git a/application/Espo/Resources/metadata/app/config.json b/application/Espo/Resources/metadata/app/config.json index 7a636acb99..5d060e5a93 100644 --- a/application/Espo/Resources/metadata/app/config.json +++ b/application/Espo/Resources/metadata/app/config.json @@ -156,6 +156,9 @@ }, "availableReactions": { "level": "global" + }, + "emailScheduledBatchCount": { + "level": "admin" } } } diff --git a/application/Espo/Resources/metadata/app/scheduledJobs.json b/application/Espo/Resources/metadata/app/scheduledJobs.json index 3846589e5f..ad6fa5a8ba 100644 --- a/application/Espo/Resources/metadata/app/scheduledJobs.json +++ b/application/Espo/Resources/metadata/app/scheduledJobs.json @@ -60,5 +60,8 @@ "CheckInboundEmails": { "preparatorClassName": "Espo\\Classes\\JobPreparators\\CheckInboundEmails", "jobClassName": "Espo\\Classes\\Jobs\\CheckInboundEmails" + }, + "SendScheduledEmails": { + "jobClassName": "Espo\\Classes\\Jobs\\SendScheduledEmails" } } diff --git a/application/Espo/Resources/metadata/clientDefs/Email.json b/application/Espo/Resources/metadata/clientDefs/Email.json index 11d043dae3..5ad05f7289 100644 --- a/application/Espo/Resources/metadata/clientDefs/Email.json +++ b/application/Espo/Resources/metadata/clientDefs/Email.json @@ -175,6 +175,17 @@ ] } + }, + "sendAt": { + "visible": { + "conditionGroup": [ + { + "type": "in", + "attribute": "status", + "value": ["Draft"] + } + ] + } } }, "panels": { diff --git a/application/Espo/Resources/metadata/entityDefs/Email.json b/application/Espo/Resources/metadata/entityDefs/Email.json index 333a248d81..b617189d97 100644 --- a/application/Espo/Resources/metadata/entityDefs/Email.json +++ b/application/Espo/Resources/metadata/entityDefs/Email.json @@ -417,6 +417,17 @@ "readOnly": true, "customizationDisabled": true }, + "sendAt": { + "type": "datetime", + "customizationDisabled": true, + "layoutAvailabilityList": [ + "filters", + "list" + ], + "validatorClassNameList": [ + "Espo\\Classes\\FieldValidators\\Email\\SendAt\\Future" + ] + }, "createdAt": { "type": "datetime", "readOnly": true, diff --git a/application/Espo/Resources/metadata/entityDefs/ScheduledJob.json b/application/Espo/Resources/metadata/entityDefs/ScheduledJob.json index a3f5a35712..733556b08f 100644 --- a/application/Espo/Resources/metadata/entityDefs/ScheduledJob.json +++ b/application/Espo/Resources/metadata/entityDefs/ScheduledJob.json @@ -78,6 +78,7 @@ "Cleanup": "1 1 * * 0", "AuthTokenControl": "*/6 * * * *", "SendEmailNotifications": "*/2 * * * *", - "ProcessWebhookQueue": "*/2 * * * *" + "ProcessWebhookQueue": "*/2 * * * *", + "SendScheduledEmails": "*/10 * * * *" } } diff --git a/application/Espo/Resources/metadata/entityDefs/Settings.json b/application/Espo/Resources/metadata/entityDefs/Settings.json index 4b3713f805..34671d4bbc 100644 --- a/application/Espo/Resources/metadata/entityDefs/Settings.json +++ b/application/Espo/Resources/metadata/entityDefs/Settings.json @@ -582,6 +582,11 @@ "type": "bool", "tooltip": true }, + "emailScheduledBatchCount": { + "type": "int", + "min": 1, + "required": true + }, "authTokenLifetime": { "type": "float", "min": 0, diff --git a/application/Espo/Resources/metadata/recordDefs/Email.json b/application/Espo/Resources/metadata/recordDefs/Email.json index 899bf5682c..c10aee76a0 100644 --- a/application/Espo/Resources/metadata/recordDefs/Email.json +++ b/application/Espo/Resources/metadata/recordDefs/Email.json @@ -43,12 +43,14 @@ ], "beforeCreateHookClassNameList": [ "Espo\\Classes\\RecordHooks\\Email\\CheckFromAddress", - "Espo\\Classes\\RecordHooks\\Email\\BeforeCreate" + "Espo\\Classes\\RecordHooks\\Email\\BeforeCreate", + "Espo\\Classes\\RecordHooks\\Email\\BeforeSave" ], "beforeUpdateHookClassNameList": [ "Espo\\Classes\\RecordHooks\\Email\\CheckFromAddress", "Espo\\Classes\\RecordHooks\\Email\\MarkAsReadBeforeUpdate", - "Espo\\Classes\\RecordHooks\\Email\\BeforeUpdate" + "Espo\\Classes\\RecordHooks\\Email\\BeforeUpdate", + "Espo\\Classes\\RecordHooks\\Email\\BeforeSave" ], "afterUpdateHookClassNameList": [ "Espo\\Classes\\RecordHooks\\Email\\AfterUpdate" diff --git a/client/src/views/email/modals/schedule-send.js b/client/src/views/email/modals/schedule-send.js new file mode 100644 index 0000000000..c9ba630228 --- /dev/null +++ b/client/src/views/email/modals/schedule-send.js @@ -0,0 +1,157 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM – Open Source CRM application. + * Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko + * Website: https://www.espocrm.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +import ModalView from 'views/modal'; +import Model from 'model'; +import EditForModalRecordView from 'views/record/edit-for-modal'; +import DatetimeFieldView from 'views/fields/datetime'; +import moment from 'moment'; + +// noinspection JSUnusedGlobalSymbols +export default class EmailScheduleSendModalView extends ModalView { + + // language=Handlebars + templateContent = `
{{{record}}}
` + + /** + * @type {Model} + */ + formModel + + /** + * @type {EditForModalRecordView} + */ + recordView + + /** + * @param {{ + * model: import('model').default, + * onSave: function(): void, + * }} options + */ + constructor(options) { + super(options); + + this.onSave = options.onSave; + } + + setup() { + this.headerText = this.translate('Schedule Send', 'labels', 'Email'); + + this.buttonList.push({ + name: 'schedule', + label: 'Schedule', + style: 'danger', + onClick: () => this.actionSchedule(), + }); + + this.buttonList.push({ + name: 'cancel', + label: 'Cancel', + onClick: () => this.close(), + }); + + this.formModel = new Model( + { + now: this.getDateTime().getNow(), + sendAt: this.getSendAt(), + } + ); + + this.recordView = new EditForModalRecordView({ + model: this.formModel, + detailLayout: [ + { + rows: [ + [ + { + view: new DatetimeFieldView({ + name: 'sendAt', + labelText: this.translate('sendAt', 'fields', 'Email'), + params: { + required: true, + after: 'now', + }, + otherFieldLabelText: this.translate('Now'), + }) + }, + false + ] + ] + } + ], + }); + + this.assignView('record', this.recordView, '.record'); + } + + /** + * @private + * @return {string} + */ + getSendAt() { + const sendAtMoment = moment.utc(this.getDateTime().getNow(10)); + + if (sendAtMoment.isBefore(moment().add(1, 'minutes'))) { + sendAtMoment.add(10, 'minutes'); + } + + return sendAtMoment.format(this.getDateTime().internalDateTimeFormat); + } + + async actionSchedule() { + if (this.recordView.validate()) { + return; + } + + this.disableButton('schedule'); + Espo.Ui.notify(' ... '); + + this.model.set({ + status: 'Draft', + sendAt: this.formModel.attributes.sendAt, + }); + + try { + await this.model.save(); + } catch (e) { + this.enableButton('schedule'); + + return; + } + + const name = this.model.attributes.subject; + const url = `#Email/view/${this.model.id}`; + + const message = this.translate('Scheduled') + '\n' + `[${name}](${url})`; + + Espo.Ui.notify(message, 'success', 4000); + + this.onSave(); + } +} diff --git a/client/src/views/fields/date.js b/client/src/views/fields/date.js index 676d50d6ce..cb64e6fce1 100644 --- a/client/src/views/fields/date.js +++ b/client/src/views/fields/date.js @@ -45,6 +45,7 @@ class DateFieldView extends BaseFieldView { * module:views/fields/base~params & * Record * } [params] Parameters. + * @property {string} [otherFieldLabelText] A label text of other field. Used in before/after validations. */ /** @@ -568,9 +569,12 @@ class DateFieldView extends BaseFieldView { } if (unix <= otherUnix) { + const otherFieldLabelText = this.options.otherFieldLabelText || + this.translate(field, 'fields', this.entityType); + const msg = this.translate('fieldShouldAfter', 'messages') .replace('{field}', this.getLabelText()) - .replace('{otherField}', this.translate(field, 'fields', this.entityType)); + .replace('{otherField}', otherFieldLabelText); this.showValidationMessage(msg); diff --git a/client/src/views/fields/datetime.js b/client/src/views/fields/datetime.js index c5396a1a49..205862f5ca 100644 --- a/client/src/views/fields/datetime.js +++ b/client/src/views/fields/datetime.js @@ -45,6 +45,7 @@ class DatetimeFieldView extends DateFieldView { * module:views/fields/base~params & * Record * } [params] Parameters. + * @property {string} [otherFieldLabelText] A label text of other field. Used in before/after validations. */ /** diff --git a/client/src/views/modals/compose-email.js b/client/src/views/modals/compose-email.js index 69dbf53794..357cba2c88 100644 --- a/client/src/views/modals/compose-email.js +++ b/client/src/views/modals/compose-email.js @@ -28,6 +28,7 @@ import EditModalView from 'views/modals/edit'; import MailtoHelper from 'helpers/misc/mailto'; +import EmailScheduleSendModalView from 'views/email/modals/schedule-send'; class ComposeEmailModalView extends EditModalView { @@ -99,6 +100,12 @@ class ComposeEmailModalView extends EditModalView { title: 'Ctrl+Enter', }); + this.dropdownItemList.push({ + name: 'scheduleSend', + text: this.translate('Schedule Send', 'labels', 'Email'), + onClick: () => this.actionScheduleSend(), + }); + this.$header = $('') .attr('role', 'button') .attr('tabindex', '0') @@ -290,6 +297,36 @@ class ComposeEmailModalView extends EditModalView { return super.beforeCollapse(); } + + /** + * @private + */ + async actionScheduleSend() { + // Prevents skipping required validation of the 'To' field. + this.model.set('status', 'Sending'); + + if (this.getRecordView().validate()) { + Espo.Ui.error(this.translate('Not valid')); + + this.model.set('status', 'Draft'); + + return; + } + + this.model.set('status', 'Draft'); + + const view = new EmailScheduleSendModalView({ + model: this.model, + onSave: () => { + this.trigger('after:save', this.model); + + this.close(); + }, + }); + + await this.assignView('dialog', view); + await view.render(); + } } export default ComposeEmailModalView; diff --git a/install/core/afterInstall/records.php b/install/core/afterInstall/records.php index 30bb101f52..1be22c787f 100644 --- a/install/core/afterInstall/records.php +++ b/install/core/afterInstall/records.php @@ -92,5 +92,11 @@ return [ 'status' => 'Active', 'scheduling' => '*/2 * * * *', ], + [ + 'name' => 'Send Scheduled Emails', + 'job' => 'SendScheduledEmails', + 'status' => 'Active', + 'scheduling' => '*/10 * * * *', + ], ], ];