diff --git a/application/Espo/Classes/FieldProcessing/Email/IcsDataLoader.php b/application/Espo/Classes/FieldProcessing/Email/IcsDataLoader.php index 0d2ca389b0..b79cdcbb3b 100644 --- a/application/Espo/Classes/FieldProcessing/Email/IcsDataLoader.php +++ b/application/Espo/Classes/FieldProcessing/Email/IcsDataLoader.php @@ -91,6 +91,12 @@ class IcsDataLoader implements Loader return; } + $method = $ical->cal['VCALENDAR']['METHOD'] ?? null; + + if ($method === 'REPLY') { + return; + } + $espoEvent = EventFactory::createFromU01jmg3Ical($ical); $valueMap = (object) [ diff --git a/application/Espo/Core/Mail/Sender.php b/application/Espo/Core/Mail/Sender.php index 356993b40e..26a6d9e5d3 100644 --- a/application/Espo/Core/Mail/Sender.php +++ b/application/Espo/Core/Mail/Sender.php @@ -31,6 +31,7 @@ namespace Espo\Core\Mail; use Espo\Core\FileStorage\Manager as FileStorageManager; use Espo\Core\Mail\Exceptions\NoSmtp; +use Espo\Core\Mail\Sender\AttachmentContainer; use Espo\Core\Mail\Sender\MessageContainer; use Espo\Core\Mail\Sender\TransportPreparatorFactory; use Espo\Core\ORM\Repository\Option\SaveOption; @@ -67,7 +68,7 @@ class Sender /** @var array */ private array $overrideParams = []; private ?string $envelopeFromAddress = null; - /** @var ?iterable */ + /** @var ?iterable */ private $attachmentList = null; /** @var array{string, string}[] */ private array $headers = []; @@ -146,7 +147,7 @@ class Sender /** * With specific attachments. * - * @param iterable $attachmentList + * @param iterable $attachmentList */ public function withAttachments(iterable $attachmentList): self { @@ -309,18 +310,23 @@ class Sender } } + /** @var AttachmentContainer[] $containers */ + $containers = []; + if ($this->attachmentList !== null) { foreach ($this->attachmentList as $attachment) { - $collection[] = $attachment; + if ($attachment instanceof Attachment) { + $collection[] = $attachment; + } else { + $containers[] = $attachment; + } } } $list = []; foreach ($collection as $attachment) { - $contents = $attachment->has(self::ATTACHMENT_ATTR_CONTENTS) ? - $attachment->get(self::ATTACHMENT_ATTR_CONTENTS) : - $this->fileStorageManager->getContents($attachment); + $contents = $this->getAttachmentContents($attachment); $part = new DataPart( body: $contents, @@ -331,6 +337,8 @@ class Sender $list[] = $part; } + $this->prepareContainerParts($containers, $list); + return $list; } @@ -342,9 +350,7 @@ class Sender $list = []; foreach ($email->getInlineAttachmentList() as $attachment) { - $contents = $attachment->has(self::ATTACHMENT_ATTR_CONTENTS) ? - $attachment->get(self::ATTACHMENT_ATTR_CONTENTS) : - $this->fileStorageManager->getContents($attachment); + $contents = $this->getAttachmentContents($attachment); $part = (new DataPart($contents, null, $attachment->getType())) ->asInline() @@ -541,4 +547,48 @@ class Sender return new Envelope(new Address($this->envelopeFromAddress), $recipients); } + + /** + * @todo Use stream. + * + */ + private function getAttachmentContents(Attachment $attachment): string + { + return $attachment->has(self::ATTACHMENT_ATTR_CONTENTS) ? + $attachment->get(self::ATTACHMENT_ATTR_CONTENTS) : + $this->fileStorageManager->getContents($attachment); + } + + /** + * @param AttachmentContainer[] $containers + * @param DataPart[] $list + */ + private function prepareContainerParts(array $containers, array &$list): void + { + foreach ($containers as $container) { + $attachment = $container->attachment; + + $contents = $this->getAttachmentContents($attachment); + + $part = new DataPart( + body: $contents, + filename: $attachment->getName() ?? '', + contentType: $attachment->getType(), + ); + + if ($container->inline) { + $part->asInline(); + } + + if ($container->contentTypeParams && $attachment->getType()) { + $part->getHeaders()->addParameterizedHeader( + name: 'Content-Type', + value: $attachment->getType(), + params: $container->contentTypeParams, + ); + } + + $list[] = $part; + } + } } diff --git a/application/Espo/Core/Mail/Sender/AttachmentContainer.php b/application/Espo/Core/Mail/Sender/AttachmentContainer.php new file mode 100644 index 0000000000..ea251bd27b --- /dev/null +++ b/application/Espo/Core/Mail/Sender/AttachmentContainer.php @@ -0,0 +1,51 @@ +. + * + * 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\Core\Mail\Sender; + +use Espo\Entities\Attachment; + +/** + * @since 10.0.0 + * @internal + */ +readonly class AttachmentContainer +{ + /** + * Important: Use named parameters. Parameter order backward compatibility is not guaranteed. + * + * @param Attachment $attachment + * @param array $contentTypeParams + */ + public function __construct( + public Attachment $attachment, + public bool $inline = false, + public array $contentTypeParams = [], + ) {} +} diff --git a/application/Espo/Modules/Crm/Business/Event/Ics.php b/application/Espo/Modules/Crm/Business/Event/Ics.php index 300621a098..284fa5b52d 100644 --- a/application/Espo/Modules/Crm/Business/Event/Ics.php +++ b/application/Espo/Modules/Crm/Business/Event/Ics.php @@ -60,8 +60,8 @@ class Ics /** * @param array{ - * organizer?: array{string, ?string}|null, - * attendees?: array{string, ?string}[], + * organizer?: array{0: string, 1: ?string}|null, + * attendees?: array{0: string, 1: ?string, 2: string|null}[], * startDate?: ?int, * endDate?: ?int, * summary?: ?string, @@ -115,7 +115,7 @@ class Ics $organizerPart = ''; if ($this->organizer) { - $organizerPart = "ORGANIZER;{$this->preparePerson($this->organizer[0], $this->organizer[1])}"; + $organizerPart = "ORGANIZER;{$this->preparePerson($this->organizer[0], $this->organizer[1], null)}"; } $locationValuePart = $this->escapeString($this->formatMultiline($this->address)); @@ -136,7 +136,7 @@ class Ics "STATUS:$this->status\r\n"; foreach ($this->attendees as $attendee) { - $body .= "ATTENDEE;{$this->preparePerson($attendee[0], $attendee[1])}"; + $body .= "ATTENDEE;{$this->preparePerson($attendee[0], $attendee[1], $attendee[2] ?? null)}"; } $end = @@ -146,9 +146,17 @@ class Ics $this->output = $start . $body . $end; } - private function preparePerson(string $address, ?string $name): string + private function preparePerson(string $address, ?string $name, ?string $status): string { - return "CN={$this->escapeString($name)}:MAILTO:{$this->escapeString($address)}\r\n"; + $output = ''; + + if ($status) { + $output .= "PARTSTAT=$status;"; + } + + $output .= "CN={$this->escapeString($name)}:MAILTO:{$this->escapeString($address)}\r\n"; + + return $output; } private function formatTimestamp(?int $timestamp): string diff --git a/application/Espo/Modules/Crm/Business/Event/Invitations.php b/application/Espo/Modules/Crm/Business/Event/Invitations.php index e5eb7c7d9e..af6004de70 100644 --- a/application/Espo/Modules/Crm/Business/Event/Invitations.php +++ b/application/Espo/Modules/Crm/Business/Event/Invitations.php @@ -32,6 +32,7 @@ namespace Espo\Modules\Crm\Business\Event; use Espo\Core\Field\DateTime as DateTimeField; use Espo\Core\Field\LinkParent; use Espo\Core\Mail\Exceptions\SendingError; +use Espo\Core\Mail\Sender\AttachmentContainer; use Espo\Core\Name\Field; use Espo\Core\Utils\Config\ApplicationConfig; use Espo\Entities\Attachment; @@ -166,8 +167,23 @@ class Invitations $sender->withSmtpParams($this->smtpParams); } + $method = 'REQUEST'; + + if ($type === self::TYPE_CANCELLATION) { + $method = 'CANCEL'; + } + + $container = new AttachmentContainer( + attachment: $attachment, + inline: true, + contentTypeParams: [ + 'charset' => 'utf-8', + 'method' => $method, + ], + ); + $sender - ->withAttachments([$attachment]) + ->withAttachments([$container]) ->send($email); } @@ -217,9 +233,9 @@ class Invitations $organizerName = $user->getName(); $organizerAddress = $user->getEmailAddress(); - if ($organizerAddress) { + /*if ($organizerAddress) { $addressList[] = $organizerAddress; - } + }*/ } $status = $type === self::TYPE_CANCELLATION ? @@ -292,7 +308,7 @@ class Invitations /** * @param string[] $addressList - * @return array{string, ?string}[] + * @return array{string, ?string, ?string}[] */ private function getAttendees(Meeting|Call $entity, array $addressList): array { @@ -308,7 +324,7 @@ class Invitations if ($address && !in_array($address, $addressList)) { $addressList[] = $address; - $attendees[] = [$address, $it->getName()]; + $attendees[] = [$address, $it->getName(), $this->getStatus($it)]; } } @@ -322,7 +338,7 @@ class Invitations if ($address && !in_array($address, $addressList)) { $addressList[] = $address; - $attendees[] = [$address, $it->getName()]; + $attendees[] = [$address, $it->getName(), $this->getStatus($it)]; } } @@ -336,7 +352,7 @@ class Invitations if ($address && !in_array($address, $addressList)) { $addressList[] = $address; - $attendees[] = [$address, $it->getName()]; + $attendees[] = [$address, $it->getName(), $this->getStatus($it)]; } } @@ -391,4 +407,16 @@ class Invitations return $this->dateTime->convertSystemDateTime($value, $timeZone, $format, $language); } + + private function getStatus(User|Contact|Lead $invitee): ?string + { + $status = $invitee->get('acceptanceStatus'); + + return match ($status) { + Meeting::ATTENDEE_STATUS_ACCEPTED => 'ACCEPTED', + Meeting::ATTENDEE_STATUS_DECLINED => 'DECLINED', + Meeting::ATTENDEE_STATUS_TENTATIVE => 'TENTATIVE', + default => null, + }; + } } diff --git a/application/Espo/Modules/Crm/Controllers/Call.php b/application/Espo/Modules/Crm/Controllers/Call.php index 4e4c0a61b0..2e0b212673 100644 --- a/application/Espo/Modules/Crm/Controllers/Call.php +++ b/application/Espo/Modules/Crm/Controllers/Call.php @@ -53,7 +53,7 @@ class Call extends Record * @throws SendingError * @throws NotFound */ - public function postActionSendInvitations(Request $request): bool + public function postActionSendInvitations(Request $request): stdClass { $id = $request->getParsedBody()->id ?? null; @@ -63,11 +63,13 @@ class Call extends Record $invitees = $this->fetchInvitees($request); - $resultList = $this->injectableFactory + $sentToList = $this->injectableFactory ->create(InvitationService::class) ->send(CallEntity::ENTITY_TYPE, $id, $invitees); - return $resultList !== 0; + return (object) [ + 'idList' => array_map(fn ($it) => $it->getId() , $sentToList), + ]; } /** @@ -77,7 +79,7 @@ class Call extends Record * @throws SendingError * @throws NotFound */ - public function postActionSendCancellation(Request $request): bool + public function postActionSendCancellation(Request $request): stdClass { $id = $request->getParsedBody()->id ?? null; @@ -87,11 +89,13 @@ class Call extends Record $invitees = $this->fetchInvitees($request); - $resultList = $this->injectableFactory + $sentToList = $this->injectableFactory ->create(InvitationService::class) ->sendCancellation(CallEntity::ENTITY_TYPE, $id, $invitees); - return $resultList !== 0; + return (object) [ + 'idList' => array_map(fn ($it) => $it->getId() , $sentToList), + ]; } /** diff --git a/application/Espo/Modules/Crm/Controllers/Meeting.php b/application/Espo/Modules/Crm/Controllers/Meeting.php index 68c54708ee..ad522570ce 100644 --- a/application/Espo/Modules/Crm/Controllers/Meeting.php +++ b/application/Espo/Modules/Crm/Controllers/Meeting.php @@ -53,7 +53,7 @@ class Meeting extends Record * @throws SendingError * @throws NotFound */ - public function postActionSendInvitations(Request $request): bool + public function postActionSendInvitations(Request $request): stdClass { $id = $request->getParsedBody()->id ?? null; @@ -63,11 +63,13 @@ class Meeting extends Record $invitees = $this->fetchInvitees($request); - $resultList = $this->injectableFactory + $sentToList = $this->injectableFactory ->create(InvitationService::class) ->send(MeetingEntity::ENTITY_TYPE, $id, $invitees); - return $resultList !== 0; + return (object) [ + 'idList' => array_map(fn ($it) => $it->getId() , $sentToList), + ]; } /** @@ -77,7 +79,7 @@ class Meeting extends Record * @throws SendingError * @throws NotFound */ - public function postActionSendCancellation(Request $request): bool + public function postActionSendCancellation(Request $request): stdClass { $id = $request->getParsedBody()->id ?? null; @@ -87,11 +89,13 @@ class Meeting extends Record $invitees = $this->fetchInvitees($request); - $resultList = $this->injectableFactory + $sentToList = $this->injectableFactory ->create(InvitationService::class) ->sendCancellation(MeetingEntity::ENTITY_TYPE, $id, $invitees); - return $resultList !== 0; + return (object) [ + 'idList' => array_map(fn ($it) => $it->getId() , $sentToList), + ]; } /** diff --git a/application/Espo/Modules/Crm/Resources/i18n/en_US/Meeting.json b/application/Espo/Modules/Crm/Resources/i18n/en_US/Meeting.json index 7ecdfbd037..08aeee0723 100644 --- a/application/Espo/Modules/Crm/Resources/i18n/en_US/Meeting.json +++ b/application/Espo/Modules/Crm/Resources/i18n/en_US/Meeting.json @@ -59,6 +59,7 @@ "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" + "nothingHasBeenSent": "Nothing were sent", + "invitationsSentTo": "Invitation emails has been sent to: {recipients}.\n\nCould not sent to: {failedRecipients}." } } diff --git a/application/Espo/Modules/Crm/Tools/Meeting/Invitation/Sender.php b/application/Espo/Modules/Crm/Tools/Meeting/Invitation/Sender.php index 15adba032b..ab903ec228 100644 --- a/application/Espo/Modules/Crm/Tools/Meeting/Invitation/Sender.php +++ b/application/Espo/Modules/Crm/Tools/Meeting/Invitation/Sender.php @@ -34,24 +34,28 @@ use Espo\Core\Exceptions\Forbidden; use Espo\Core\InjectableFactory; use Espo\Core\Mail\Exceptions\SendingError; use Espo\Core\Mail\SmtpParams; -use Espo\Core\Name\Field; use Espo\Core\Utils\Config; +use Espo\Core\Utils\Log; use Espo\Core\Utils\Metadata; use Espo\Entities\User; use Espo\Modules\Crm\Business\Event\Invitations; use Espo\Modules\Crm\Entities\Call; +use Espo\Modules\Crm\Entities\Contact; +use Espo\Modules\Crm\Entities\Lead; use Espo\Modules\Crm\Entities\Meeting; use Espo\ORM\Entity; use Espo\ORM\EntityManager; +use Espo\ORM\Repository\RDBRelation; use Espo\Tools\Email\SendService; +use LogicException; /** * @since 9.0.0 */ class Sender { - private const TYPE_INVITATION = 'invitation'; - private const TYPE_CANCELLATION = 'cancellation'; + private const string TYPE_INVITATION = 'invitation'; + private const string TYPE_CANCELLATION = 'cancellation'; public function __construct( private SendService $sendService, @@ -60,6 +64,7 @@ class Sender private EntityManager $entityManager, private Config $config, private Metadata $metadata, + private Log $log, ) {} /** @@ -86,29 +91,22 @@ class Sender return $this->sendInternal($entity, self::TYPE_CANCELLATION, $targets); } - /** * @param ?Invitee[] $targets - * @return Entity[] - * @throws SendingError - * @throws Forbidden + * @return (User|Contact|Lead)[] */ - private function sendInternal(Meeting|Call $entity, string $type, ?array $targets): array + private function getRecipients(Meeting|Call $entity, string $type, ?array $targets): array { - $this->checkStatus($entity, $type); - $linkList = [ Meeting::LINK_USERS, Meeting::LINK_CONTACTS, Meeting::LINK_LEADS, ]; - $sender = $this->getSender(); - - $sentAddressList = []; - $resultEntityList = []; + $output = []; foreach ($linkList as $link) { + /** @var RDBRelation|RDBRelation|RDBRelation $builder */ $builder = $this->entityManager->getRelation($entity, $link); if ($targets === null && $type === self::TYPE_INVITATION) { @@ -118,7 +116,7 @@ class Sender $collection = $builder->find(); foreach ($collection as $attendee) { - $emailAddress = $attendee->get(Field::EMAIL_ADDRESS); + $emailAddress = $attendee->getEmailAddress(); if ($targets) { $target = self::findTarget($attendee, $targets); @@ -132,10 +130,60 @@ class Sender } } - if (!$emailAddress || in_array($emailAddress, $sentAddressList)) { + if (!$emailAddress) { continue; } + $output[] = $attendee; + } + } + + return $output; + } + + /** + * @param ?Invitee[] $targets + * @return Entity[] + * @throws SendingError + * @throws Forbidden + */ + private function sendInternal(Meeting|Call $entity, string $type, ?array $targets): array + { + $this->checkStatus($entity, $type); + + $sender = $this->getSender(); + + $sentAddressList = []; + $resultEntityList = []; + + $recipients = $this->getRecipients($entity, $type, $targets); + + foreach ($recipients as $recipient) { + $link = $this->getLinkName($recipient); + + $this->entityManager + ->getRelation($entity, $link) + ->updateColumns($recipient, ['status' => Meeting::ATTENDEE_STATUS_NONE]); + } + + foreach ($recipients as $attendee) { + $emailAddress = $attendee->getEmailAddress(); + + $link = $this->getLinkName($attendee); + + if ($targets) { + $target = self::findTarget($attendee, $targets); + + if ($target?->getEmailAddress()) { + $emailAddress = $target->getEmailAddress(); + } + } + + if (!$emailAddress || in_array($emailAddress, $sentAddressList)) { + continue; + } + + try { if ($type === self::TYPE_INVITATION) { $sender->sendInvitation($entity, $attendee, $link, $emailAddress); } @@ -143,14 +191,18 @@ class Sender if ($type === self::TYPE_CANCELLATION) { $sender->sendCancellation($entity, $attendee, $link, $emailAddress); } + } catch (SendingError $e) { + $this->log->error("Could not send event invitation to {entityType} {id}.", [ + 'exception' => $e, + 'entityType' => $attendee->getEntityType(), + 'id' => $attendee->getId(), + ]); - $sentAddressList[] = $emailAddress; - $resultEntityList[] = $attendee; - - $this->entityManager - ->getRelation($entity, $link) - ->updateColumns($attendee, ['status' => Meeting::ATTENDEE_STATUS_NONE]); + continue; } + + $sentAddressList[] = $emailAddress; + $resultEntityList[] = $attendee; } return $resultEntityList; @@ -220,4 +272,15 @@ class Sender { return $this->metadata->get("scopes.$entityType.canceledStatusList") ?? []; } + + private function getLinkName(User|Contact|Lead $recipient): string + { + $linkMap = [ + User::ENTITY_TYPE => Meeting::LINK_USERS, + Contact::ENTITY_TYPE => Meeting::LINK_CONTACTS, + Lead::ENTITY_TYPE => Meeting::LINK_LEADS, + ]; + + return $linkMap[$recipient::ENTITY_TYPE] ?? throw new LogicException(); + } } diff --git a/client/modules/crm/src/views/meeting/modals/send-invitations.js b/client/modules/crm/src/views/meeting/modals/send-invitations.js index 93eefc76ca..562abd0a4a 100644 --- a/client/modules/crm/src/views/meeting/modals/send-invitations.js +++ b/client/modules/crm/src/views/meeting/modals/send-invitations.js @@ -30,6 +30,7 @@ import Utils from 'utils'; import ModalView from 'views/modal'; import Collection from 'collection'; import Ajax from 'ajax'; +import Ui from 'ui'; export default class SendInvitationsModalView extends ModalView { @@ -167,25 +168,45 @@ export default class SendInvitationsModalView extends ModalView { Espo.Ui.notifyWait(); - const targets = this.getListView().checkedList.map(id => { - return { - entityType: this.collection.get(id).entityType, - id: id, - }; - }); + const targets = this.getListView().getCheckedIds() + .map(id => this.collection.get(id)); Ajax .postRequest(`${this.model.entityType}/action/sendInvitations`, { id: this.model.id, - targets: targets, + targets: targets.map(m => { + return { + entityType: m.entityType, + id: m.id, + } + }), }) - .then(result => { - result ? - Espo.Ui.success(this.translate('Sent')) : - Espo.Ui.warning(this.translate('nothingHasBeenSent', 'messages', 'Meeting')); + .then(/** {idList: string[]} */result => { + if (result.idList.length === 0) { + Ui.warning(this.translate('nothingHasBeenSent', 'messages', 'Meeting')); + } else if (result.idList.length === targets.length) { + Ui.success(this.translate('Sent')); + } else { + const recipientsString = targets + .filter(m => result.idList.includes(m.id)) + .map(m => m.attributes.name ?? m.attributes.id) + .join(', '); + + const failedRecipientsString = targets + .filter(m => !result.idList.includes(m.id)) + .map(m => m.attributes.name ?? m.attributes.id) + .join(', '); + + const message = this.translate('invitationsSentTo', 'messages', 'Meeting') + .replace('{recipients}', recipientsString) + .replace('{failedRecipients}', failedRecipientsString); + + Ui.notify(message, 'warning', null, { + closeButton: true, + }); + } this.trigger('sent'); - this.close(); }) .catch(() => { diff --git a/tests/unit/Espo/Tools/Meeting/IcsTest.php b/tests/unit/Espo/Tools/Meeting/IcsTest.php index 9454f59e1f..9430fe551b 100644 --- a/tests/unit/Espo/Tools/Meeting/IcsTest.php +++ b/tests/unit/Espo/Tools/Meeting/IcsTest.php @@ -47,7 +47,7 @@ class IcsTest extends TestCase 'description' => 'Test.', 'stamp' => strtotime('2025-01-01 09:00:00'), 'attendees' => [ - ['att1@test.com', 'Att 1'], + ['att1@test.com', 'Att 1', 'TENTATIVE'], ['att2@test.com', 'Att 2'], ], ]); @@ -67,7 +67,7 @@ class IcsTest extends TestCase "SEQUENCE:0\r\n". "DTSTAMP:20250101T090000Z\r\n". "STATUS:CONFIRMED\r\n". - "ATTENDEE;CN=Att 1:MAILTO:att1@test.com\r\n". + "ATTENDEE;PARTSTAT=TENTATIVE;CN=Att 1:MAILTO:att1@test.com\r\n". "ATTENDEE;CN=Att 2:MAILTO:att2@test.com\r\n". "END:VEVENT\r\n". "END:VCALENDAR";