cancellation email

This commit is contained in:
Yuri Kuznetsov
2022-11-23 17:53:08 +02:00
parent 72f51ec77c
commit 3282de2c8f
13 changed files with 547 additions and 102 deletions
@@ -33,25 +33,27 @@ use RuntimeException;
class Ics
{
public const STATUS_CONFIRMED = 'CONFIRMED';
public const STATUS_TENTATIVE = 'TENTATIVE';
public const STATUS_CANCELLED = 'CANCELLED';
public const METHOD_REQUEST = 'REQUEST';
public const METHOD_CANCEL = 'CANCEL';
/** @var self::METHOD_* string */
private string $method;
private ?string $output = null;
private string $prodid;
private ?int $startDate = null;
private ?int $endDate = null;
private ?string $summary = null;
private ?string $address = null;
private ?string $email = null;
private ?string $who = null;
private ?string $description = null;
private ?string $uid = null;
/** @var self::STATUS_* string */
private string $status;
/**
* @param array<string,string|int|null> $attributes
@@ -63,6 +65,8 @@ class Ics
throw new RuntimeException('PRODID is required');
}
$this->status = self::STATUS_CONFIRMED;
$this->method = self::METHOD_REQUEST;
$this->prodid = $prodid;
foreach ($attributes as $key => $value) {
@@ -90,7 +94,7 @@ class Ics
"BEGIN:VCALENDAR\r\n".
"VERSION:2.0\r\n".
"PRODID:-" . $this->prodid . "\r\n".
"METHOD:REQUEST\r\n".
"METHOD:" . $this->method . "\r\n".
"BEGIN:VEVENT\r\n".
"DTSTART:" . $this->formatTimestamp($this->startDate) . "\r\n".
"DTEND:" . $this->formatTimestamp($this->endDate) . "\r\n".
@@ -100,7 +104,8 @@ class Ics
"DESCRIPTION:" . $this->escapeString($this->formatMultiline($this->description)) . "\r\n".
"UID:" . $this->uid . "\r\n".
"SEQUENCE:0\r\n".
"DTSTAMP:" . $this->formatTimestamp(time())."\r\n".
"DTSTAMP:" . $this->formatTimestamp(time()) . "\r\n".
"STATUS:" . $this->status . "\r\n" .
"END:VEVENT\r\n".
"END:VCALENDAR";
}
@@ -54,6 +54,9 @@ use DateTime;
class Invitations
{
private const TYPE_INVITATION = 'invitation';
private const TYPE_CANCELLATION = 'cancellation';
private $smtpParams;
private $entityManager;
private $emailSender;
@@ -97,6 +100,125 @@ class Invitations
* @throws Error
*/
public function sendInvitation(Entity $entity, Entity $invitee, string $link): void
{
$this->sendInternal($entity, $invitee, $link);
}
/**
* @throws SendingError
* @throws Error
*/
public function sendCancellation(Entity $entity, Entity $invitee, string $link): void
{
$this->sendInternal($entity, $invitee, $link, self::TYPE_CANCELLATION);
}
/**
* @throws SendingError
* @throws Error
*/
private function sendInternal(
Entity $entity,
Entity $invitee,
string $link,
string $type = self::TYPE_INVITATION
): void {
$uid = $type === self::TYPE_INVITATION ?
$this->createUniqueId($entity, $invitee, $link) : null;
$emailAddress = $invitee->get('emailAddress');
if (empty($emailAddress)) {
return;
}
/** @var Email $email */
$email = $this->entityManager->getNewEntity(Email::ENTITY_TYPE);
$email->set('to', $emailAddress);
$subjectTpl = $this->templateFileManager->getTemplate($type, 'subject', $entity->getEntityType(), 'Crm');
$bodyTpl = $this->templateFileManager->getTemplate($type, 'body', $entity->getEntityType(), 'Crm');
$subjectTpl = str_replace(["\n", "\r"], '', $subjectTpl);
$data = [];
$siteUrl = rtrim($this->config->get('siteUrl'), '/');
$recordUrl = $siteUrl . '/#' . $entity->getEntityType() . '/view/' . $entity->getId();
$data['recordUrl'] = $recordUrl;
if ($uid) {
$part = $siteUrl . '?entryPoint=eventConfirmation&action=';
$data['acceptLink'] = $part . 'accept&uid=' . $uid->getIdValue();
$data['declineLink'] = $part . 'decline&uid=' . $uid->getIdValue();
$data['tentativeLink'] = $part . 'tentative&uid=' . $uid->getIdValue();
}
if ($invitee instanceof User) {
$data['isUser'] = true;
$htmlizer = $this->htmlizerFactory->createForUser($invitee);
}
else {
$htmlizer = $this->htmlizerFactory->createNoAcl();
}
$data['inviteeName'] = $invitee->get('name');
$data['entityType'] = $this->language->translateLabel($entity->getEntityType(), 'scopeNames');
$data['entityTypeLowerFirst'] = Util::mbLowerCaseFirst($data['entityType']);
$subject = $htmlizer->render(
$entity,
$subjectTpl,
$type . '-email-subject-' . $entity->getEntityType(),
$data,
true,
true
);
$body = $htmlizer->render(
$entity,
$bodyTpl,
$type . '-email-body-' . $entity->getEntityType(),
$data,
false,
true
);
$email->set('subject', $subject);
$email->set('body', $body);
$email->set('isHtml', true);
$attachmentName = ucwords($this->language->translateLabel($entity->getEntityType(), 'scopeNames')) . '.ics';
/** @var Attachment $attachment */
$attachment = $this->entityManager->getNewEntity(Attachment::ENTITY_TYPE);
$attachment->set([
'name' => $attachmentName,
'type' => 'text/calendar',
'contents' => $this->getIcsContents($entity, $type),
]);
$message = new Message();
$sender = $this->emailSender->create();
if ($this->smtpParams) {
$sender->withSmtpParams($this->smtpParams);
}
$sender
->withMessage($message)
->withAttachments([$attachment])
->send($email);
}
private function createUniqueId(Entity $entity, Entity $invitee, string $link): UniqueId
{
/** @var UniqueId $uid */
$uid = $this->entityManager->getNewEntity(UniqueId::ENTITY_TYPE);
@@ -128,93 +250,10 @@ class Invitations
$this->entityManager->saveEntity($uid);
$emailAddress = $invitee->get('emailAddress');
if (empty($emailAddress)) {
return;
}
/** @var Email $email */
$email = $this->entityManager->getNewEntity(Email::ENTITY_TYPE);
$email->set('to', $emailAddress);
$subjectTpl = $this->templateFileManager->getTemplate('invitation', 'subject', $entity->getEntityType(), 'Crm');
$bodyTpl = $this->templateFileManager->getTemplate('invitation', 'body', $entity->getEntityType(), 'Crm');
$subjectTpl = str_replace(["\n", "\r"], '', $subjectTpl);
$data = [];
$siteUrl = rtrim($this->config->get('siteUrl'), '/');
$recordUrl = $siteUrl . '/#' . $entity->getEntityType() . '/view/' . $entity->getId();
$data['recordUrl'] = $recordUrl;
$data['acceptLink'] = $siteUrl . '?entryPoint=eventConfirmation&action=accept&uid=' . $uid->get('name');
$data['declineLink'] = $siteUrl . '?entryPoint=eventConfirmation&action=decline&uid=' . $uid->get('name');
$data['tentativeLink'] = $siteUrl . '?entryPoint=eventConfirmation&action=tentative&uid=' . $uid->get('name');
if ($invitee instanceof User) {
$data['isUser'] = true;
$htmlizer = $this->htmlizerFactory->createForUser($invitee);
}
else {
$htmlizer = $this->htmlizerFactory->createNoAcl();
}
$data['inviteeName'] = $invitee->get('name');
$data['entityType'] = $this->language->translateLabel($entity->getEntityType(), 'scopeNames');
$data['entityTypeLowerFirst'] = Util::mbLowerCaseFirst($data['entityType']);
$subject = $htmlizer->render(
$entity,
$subjectTpl,
'invitation-email-subject-' . $entity->getEntityType(),
$data,
true,
true
);
$body = $htmlizer->render(
$entity,
$bodyTpl,
'invitation-email-body-' . $entity->getEntityType(),
$data,
false,
true
);
$email->set('subject', $subject);
$email->set('body', $body);
$email->set('isHtml', true);
$attachmentName = ucwords($this->language->translateLabel($entity->getEntityType(), 'scopeNames')) . '.ics';
/** @var Attachment $attachment */
$attachment = $this->entityManager->getNewEntity(Attachment::ENTITY_TYPE);
$attachment->set([
'name' => $attachmentName,
'type' => 'text/calendar',
'contents' => $this->getIcsContents($entity),
]);
$message = new Message();
$sender = $this->emailSender->create();
if ($this->smtpParams) {
$sender->withSmtpParams($this->smtpParams);
}
$sender
->withMessage($message)
->withAttachments([$attachment])
->send($email);
return $uid;
}
protected function getIcsContents(Entity $entity): string
protected function getIcsContents(Entity $entity, string $type): string
{
/** @var ?User $user */
$user = $this->entityManager
@@ -230,7 +269,16 @@ class Invitations
$email = $user->getEmailAddress();
}
$status = $type === self::TYPE_CANCELLATION ?
Ics::STATUS_CANCELLED :
Ics::STATUS_CONFIRMED;
$method = $type === self::TYPE_CANCELLATION ?
Ics::METHOD_CANCEL :
Ics::METHOD_REQUEST;
$ics = new Ics('//EspoCRM//EspoCRM Calendar//EN', [
'method' => $method,
'startDate' => strtotime($entity->get('dateStart')),
'endDate' => strtotime($entity->get('dateEnd')),
'uid' => $entity->getId(),
@@ -238,6 +286,7 @@ class Invitations
'who' => $who,
'email' => $email,
'description' => $entity->get('description'),
'status' => $status,
]);
return $ics->get();
@@ -34,7 +34,6 @@ use Espo\Core\Controllers\Record;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\ForbiddenSilent;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Api\Request;
use Espo\Core\Mail\Exceptions\SendingError;
@@ -71,6 +70,30 @@ class Call extends Record
return $resultList !== 0;
}
/**
* @throws BadRequest
* @throws Forbidden
* @throws Error
* @throws SendingError
* @throws NotFound
*/
public function postActionSendCancellation(Request $request): bool
{
$id = $request->getParsedBody()->id ?? null;
if (!$id) {
throw new BadRequest();
}
$invitees = $this->fetchInvitees($request);
$resultList = $this->injectableFactory
->create(InvitationService::class)
->sendCancellation(CallEntity::ENTITY_TYPE, $id, $invitees);
return $resultList !== 0;
}
/**
* @param Request $request
* @return ?Invitee[]
@@ -73,6 +73,30 @@ class Meeting extends Record
return $resultList !== 0;
}
/**
* @throws BadRequest
* @throws Forbidden
* @throws Error
* @throws SendingError
* @throws NotFound
*/
public function postActionSendCancellation(Request $request): bool
{
$id = $request->getParsedBody()->id ?? null;
if (!$id) {
throw new BadRequest();
}
$invitees = $this->fetchInvitees($request);
$resultList = $this->injectableFactory
->create(InvitationService::class)
->sendCancellation(MeetingEntity::ENTITY_TYPE, $id, $invitees);
return $resultList !== 0;
}
/**
* @param Request $request
* @return ?Invitee[]
@@ -6,6 +6,7 @@
},
"templates": {
"invitation": "Invitation",
"cancellation": "Cancellation",
"reminder": "Reminder"
}
}
@@ -42,6 +42,7 @@
"Set Held": "Set Held",
"Set Not Held": "Set Not Held",
"Send Invitations": "Send Invitations",
"Send Cancellation": "Send Cancellation",
"on time": "on time",
"before": "before",
"All-Day": "All-Day",
@@ -54,6 +55,7 @@
},
"messages": {
"sendInvitationsToSelectedAttendees": "Invitation emails will be sent to the selected attendees.",
"sendCancellationsToSelectedAttendees": "Cancellation emails will be sent to the selected attendees.",
"selectAcceptanceStatus": "Set your acceptance status.",
"nothingHasBeenSent": "Nothing were sent"
}
@@ -3,8 +3,12 @@
"scopeList": ["Meeting", "Call"],
"module": "Crm"
},
"cancellation": {
"scopeList": ["Meeting", "Call"],
"module": "Crm"
},
"reminder": {
"scopeList": ["Meeting", "Call", "Task"],
"module": "Crm"
}
}
}
@@ -0,0 +1,5 @@
<p>Subject: {{name}}</p>
<p>Start: {{#if isAllDay}}{{dateStartDate}}{{else}}{{dateStart}}{{/if}}</p>
{{#if isUser}}
<p><a href="{{recordUrl}}">View record</a></p>
{{/if}}
@@ -0,0 +1 @@
Cancellation of {{entityTypeLowerFirst}} '{{name}}'
@@ -46,9 +46,11 @@ use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\Tools\Email\SendService;
class InvitationService
{
private const TYPE_INVITATION = 'invitation';
private const TYPE_CANCELLATION = 'cancellation';
private RecordServiceContainer $recordServiceContainer;
private SendService $sendService;
private User $user;
@@ -76,7 +78,7 @@ class InvitationService
}
/**
* Send invitations for a meeting (or call). Checks access. Uses user's SMTP if available.
* Send invitation emails for a meeting (or call). Checks access. Uses user's SMTP if available.
*
* @param ?Invitee[] $targets
* @return Entity[] Entities an invitation was sent to.
@@ -87,6 +89,39 @@ class InvitationService
*/
public function send(string $entityType, string $id, ?array $targets = null): array
{
return $this->sendInternal($entityType, $id, $targets);
}
/**
* Send cancellation emails for a meeting (or call). Checks access. Uses user's SMTP if available.
*
* @param ?Invitee[] $targets
* @return Entity[] Entities a cancellation was sent to.
* @throws NotFound
* @throws Forbidden
* @throws Error
* @throws SendingError
*/
public function sendCancellation(string $entityType, string $id, ?array $targets = null): array
{
return $this->sendInternal($entityType, $id, $targets, self::TYPE_CANCELLATION);
}
/**
* @param ?Invitee[] $targets
* @return Entity[]
* @throws NotFound
* @throws Forbidden
* @throws Error
* @throws SendingError
*/
private function sendInternal(
string $entityType,
string $id,
?array $targets = null,
string $type = self::TYPE_INVITATION
): array {
$entity = $this->recordServiceContainer
->get($entityType)
->getEntity($id);
@@ -115,7 +150,7 @@ class InvitationService
->getRDBRepository($entityType)
->getRelation($entity, $link);
if ($targets === null) {
if ($targets === null && $type === self::TYPE_INVITATION) {
$builder->where([
'@relation.status=' => Meeting::ATTENDEE_STATUS_NONE,
]);
@@ -134,7 +169,13 @@ class InvitationService
continue;
}
$sender->sendInvitation($entity, $attendee, $link);
if ($type === self::TYPE_INVITATION) {
$sender->sendInvitation($entity, $attendee, $link);
}
if ($type === self::TYPE_CANCELLATION) {
$sender->sendCancellation($entity, $attendee, $link);
}
$sentAddressList[] = $emailAddress;
$resultEntityList[] = $attendee;
@@ -30,25 +30,35 @@
return Dep.extend({
cancellationPeriod: '8 hours',
setup: function () {
Dep.prototype.setup.call(this);
this.controlSendInvitationsButton();
this.controlAcceptanceStatusButton();
this.controlSendCancellationButton();
this.listenTo(this.model, 'sync', () => {
this.controlSendInvitationsButton();
this.controlSendCancellationButton();
});
this.listenTo(this.model, 'sync', () => {
this.controlAcceptanceStatusButton();
});
MeetingDetail.prototype.setupCancellationPeriod.call(this);
},
actionSendInvitations: function () {
MeetingDetail.prototype.actionSendInvitations.call(this);
},
actionSendCancellation: function () {
MeetingDetail.prototype.actionSendCancellation.call(this);
},
actionSetAcceptanceStatus: function () {
MeetingDetail.prototype.actionSetAcceptanceStatus.call(this);
},
@@ -57,6 +67,10 @@
MeetingDetail.prototype.controlSendInvitationsButton.call(this);
},
controlSendCancellationButton: function () {
MeetingDetail.prototype.controlSendCancellationButton.call(this);
},
controlAcceptanceStatusButton: function () {
MeetingDetail.prototype.controlAcceptanceStatusButton.call(this);
},
@@ -30,19 +30,41 @@ define('crm:views/meeting/detail', ['views/detail', 'lib!moment'], function (Dep
return Dep.extend({
cancellationPeriod: '8 hours',
setup: function () {
Dep.prototype.setup.call(this);
this.controlSendInvitationsButton();
this.controlAcceptanceStatusButton();
this.controlSendCancellationButton();
this.listenTo(this.model, 'sync', () => {
this.controlSendInvitationsButton();
this.controlSendCancellationButton();
});
this.listenTo(this.model, 'sync', () => {
this.controlAcceptanceStatusButton();
});
this.setupCancellationPeriod();
},
setupCancellationPeriod: function () {
this.cancellationPeriodAmount = 0;
this.cancellationPeriodUnits = 'hours';
let cancellationPeriod = this.getConfig().get('eventCancellationPeriod') || this.cancellationPeriod;
if (!cancellationPeriod) {
return;
}
let arr = cancellationPeriod.split(' ');
this.cancellationPeriodAmount = parseInt(arr[0]);
this.cancellationPeriodUnits = arr[1] ?? 'hours';
},
controlAcceptanceStatusButton: function () {
@@ -174,6 +196,46 @@ define('crm:views/meeting/detail', ['views/detail', 'lib!moment'], function (Dep
this.removeMenuItem('sendInvitations');
},
controlSendCancellationButton: function () {
let show = this.model.get('status') === 'Not Held';
if (show) {
let dateEnd = this.model.get('dateEnd');
if (
dateEnd &&
this.getDateTime()
.toMoment(dateEnd)
.subtract(this.cancellationPeriodAmount, this.cancellationPeriodUnits)
.isBefore(moment.now())
) {
show = false;
}
}
if (show) {
let userIdList = this.model.getLinkMultipleIdList('users');
let contactIdList = this.model.getLinkMultipleIdList('contacts');
let leadIdList = this.model.getLinkMultipleIdList('leads');
if (!contactIdList.length && !leadIdList.length && !userIdList.length) {
show = false;
}
}
if (show) {
this.addMenuItem('dropdown', {
text: this.translate('Send Cancellation', 'labels', 'Meeting'),
action: 'sendCancellation',
acl: 'edit',
});
return;
}
this.removeMenuItem('sendCancellation');
},
actionSendInvitations: function () {
Espo.Ui.notify(' ... ');
@@ -188,6 +250,20 @@ define('crm:views/meeting/detail', ['views/detail', 'lib!moment'], function (Dep
});
},
actionSendCancellation: function () {
Espo.Ui.notify(' ... ');
this.createView('dialog', 'crm:views/meeting/modals/send-cancellation', {
model: this.model,
}).then(view => {
Espo.Ui.notify(false);
view.render();
this.listenToOnce(view, 'sent', () => this.model.fetch());
});
},
actionSetAcceptanceStatus: function () {
this.createView('dialog', 'crm:views/meeting/modals/acceptance-status', {
model: this.model
@@ -0,0 +1,200 @@
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM - Open Source CRM application.
* Copyright (C) 2014-2022 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* EspoCRM is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* EspoCRM 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with EspoCRM. If not, see http://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 General Public License version 3.
*
* In accordance with Section 7(b) of the GNU General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
define('crm:views/meeting/modals/send-cancellation', ['views/modal', 'collection'], function (Dep, Collection) {
/**
* @module crm_views/meeting/modals/send-cancellation
*/
/**
* @class
* @name Class
* @extends module:views/modal.Class
* @memberOf module:crm_views/meeting/modals/send-cancellation
*/
return Dep.extend(/** @lends module:crm_views/meeting/modals/send-cancellation.Class# */{
backdrop: true,
templateContent: `
<div class="margin-bottom">
<p>{{message}}</p>
</div>
<div class="list-container">{{{list}}}</div>
`,
data: function () {
return {
message: this.translate('sendCancellationsToSelectedAttendees', 'messages', 'Meeting'),
};
},
setup: function () {
Dep.prototype.setup.call(this);
this.shortcutKeys = {};
this.shortcutKeys['Control+Enter'] = e => {
if (!this.hasAvailableActionItem('send')) {
return;
}
e.preventDefault();
this.actionSend();
};
this.$header = $('<span>').append(
$('<span>')
.text(this.translate(this.model.entityType, 'scopeNames')),
' <span class="chevron-right"></span> ',
$('<span>')
.text(this.model.get('name')),
' <span class="chevron-right"></span> ',
$('<span>')
.text(this.translate('Send Cancellation', 'labels', 'Meeting'))
);
this.addButton({
label: 'Send',
name: 'send',
style: 'danger',
disabled: true,
});
this.addButton({
label: 'Cancel',
name: 'cancel',
});
this.collection = new Collection();
this.collection.url = this.model.entityType + `/${this.model.id}/attendees`;
this.wait(
this.collection.fetch()
.then(() => {
Espo.Utils.clone(this.collection.models).forEach(model => {
model.entityType = model.get('_scope');
if (!model.get('emailAddress')) {
this.collection.remove(model.id);
}
});
return this.createView('list', 'views/record/list', {
selector: '.list-container',
collection: this.collection,
rowActionsDisabled: true,
massActionsDisabled: true,
checkAllResultDisabled: true,
selectable: true,
buttonsDisabled: true,
listLayout: [
{
name: 'name',
customLabel: this.translate('name', 'fields'),
notSortable: true,
},
{
name: 'acceptanceStatus',
width: 40,
customLabel: this.translate('acceptanceStatus', 'fields', 'Meeting'),
notSortable: true,
view: 'views/fields/enum',
params: {
options: this.model.getFieldParam('acceptanceStatus', 'options'),
style: this.model.getFieldParam('acceptanceStatus', 'style'),
},
},
],
})
})
.then(view => {
this.collection.models
.filter(model => {
if (model.id === this.getUser().id && model.entityType === 'User') {
return false;
}
return true;
})
.forEach(model => {
this.getListView().checkRecord(model.id);
});
this.listenTo(view, 'check', () => this.controlSendButton());
this.controlSendButton();
})
);
},
controlSendButton: function () {
this.getListView().checkedList.length ?
this.enableButton('send') :
this.disableButton('send');
},
/**
* @return {module:views/record/list.Class}
*/
getListView: function () {
return this.getView('list');
},
actionSend: function () {
this.disableButton('send');
Espo.Ui.notify(' ... ');
let targets = this.getListView().checkedList.map(id => {
return {
entityType: this.collection.get(id).entityType,
id: id,
};
});
Espo.Ajax
.postRequest(this.model.entityType + '/action/sendCancellation', {
id: this.model.id,
targets: targets,
})
.then(result => {
result ?
Espo.Ui.success(this.translate('Sent')) :
Espo.Ui.warning(this.translate('nothingHasBeenSent', 'messages', 'Meeting'));
this.trigger('sent');
this.close();
})
.catch(() => {
this.enableButton('send');
});
},
});
});