email scheduled send

This commit is contained in:
Yuri Kuznetsov
2024-11-09 09:38:11 +02:00
parent bd90b55c4d
commit 3ef9fb0f26
25 changed files with 570 additions and 9 deletions
@@ -0,0 +1,58 @@
<?php
/************************************************************************
* 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 <https://www.gnu.org/licenses/>.
*
* 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<Email>
*/
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;
}
}
@@ -0,0 +1,163 @@
<?php
/************************************************************************
* 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 <https://www.gnu.org/licenses/>.
*
* 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;
}
}
@@ -0,0 +1,53 @@
<?php
/************************************************************************
* 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 <https://www.gnu.org/licenses/>.
*
* 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<Email>
*/
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.");
}
}
}
+4 -2
View File
@@ -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();
@@ -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 * * * *',
]);
}
}
+13
View File
@@ -901,4 +901,17 @@ class Email extends Entity
/** @var Collection<Attachment> */
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;
}
}
@@ -310,5 +310,6 @@ return [
'authIpAddressCheckExcludedUsersNames' => (object) [],
'availableReactions' => ['Like'],
'streamReactionsCheckMaxSize' => 50,
'emailScheduledBatchCount' => 50,
'isInstalled' => false,
];
@@ -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.",
@@ -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",
@@ -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:",
@@ -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",
@@ -16,5 +16,8 @@
},
{
"name": "tasks"
},
{
"name": "sendAt"
}
]
@@ -24,5 +24,11 @@
[{"name": "massEmailOpenTracking"}, {"name": "massEmailVerp"}],
[{"name": "massEmailDisableMandatoryOptOutLink"}, false]
]
},
{
"label": "Scheduled Send",
"rows": [
[{"name": "emailScheduledBatchCount"}, false]
]
}
]
@@ -156,6 +156,9 @@
},
"availableReactions": {
"level": "global"
},
"emailScheduledBatchCount": {
"level": "admin"
}
}
}
@@ -60,5 +60,8 @@
"CheckInboundEmails": {
"preparatorClassName": "Espo\\Classes\\JobPreparators\\CheckInboundEmails",
"jobClassName": "Espo\\Classes\\Jobs\\CheckInboundEmails"
},
"SendScheduledEmails": {
"jobClassName": "Espo\\Classes\\Jobs\\SendScheduledEmails"
}
}
@@ -175,6 +175,17 @@
]
}
},
"sendAt": {
"visible": {
"conditionGroup": [
{
"type": "in",
"attribute": "status",
"value": ["Draft"]
}
]
}
}
},
"panels": {
@@ -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,
@@ -78,6 +78,7 @@
"Cleanup": "1 1 * * 0",
"AuthTokenControl": "*/6 * * * *",
"SendEmailNotifications": "*/2 * * * *",
"ProcessWebhookQueue": "*/2 * * * *"
"ProcessWebhookQueue": "*/2 * * * *",
"SendScheduledEmails": "*/10 * * * *"
}
}
@@ -582,6 +582,11 @@
"type": "bool",
"tooltip": true
},
"emailScheduledBatchCount": {
"type": "int",
"min": 1,
"required": true
},
"authTokenLifetime": {
"type": "float",
"min": 0,
@@ -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"
@@ -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 <https://www.gnu.org/licenses/>.
*
* 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 = `<div class="record no-side-margin">{{{record}}}</div>`
/**
* @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();
}
}
+5 -1
View File
@@ -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);
+1
View File
@@ -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.
*/
/**
+37
View File
@@ -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 = $('<a>')
.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;
+6
View File
@@ -92,5 +92,11 @@ return [
'status' => 'Active',
'scheduling' => '*/2 * * * *',
],
[
'name' => 'Send Scheduled Emails',
'job' => 'SendScheduledEmails',
'status' => 'Active',
'scheduling' => '*/10 * * * *',
],
],
];