diff --git a/components/ILIAS/Badge/classes/class.ilBadgeHandler.php b/components/ILIAS/Badge/classes/class.ilBadgeHandler.php index 1381a4e2c0f1..f4886786bcd1 100755 --- a/components/ILIAS/Badge/classes/class.ilBadgeHandler.php +++ b/components/ILIAS/Badge/classes/class.ilBadgeHandler.php @@ -21,6 +21,7 @@ use ILIAS\Notifications\Model\ilNotificationLink; use ILIAS\Notifications\Model\ilNotificationParameter; use ILIAS\Badge\GlobalScreen\BadgeNotificationProvider; +use ILIAS\Mail\Attachments\MailAttachments; /** * Class ilBadgeHandler @@ -537,7 +538,7 @@ public function sendNotification( '', $lng->txt('badge_notification_subject'), $ntf->composeAndGetMessage($user_id, null, 'read', true), - [] + MailAttachments::empty() ); diff --git a/components/ILIAS/Calendar/classes/class.ilCalendarMailNotification.php b/components/ILIAS/Calendar/classes/class.ilCalendarMailNotification.php index 41935ce7cd3c..3983f4c5def0 100755 --- a/components/ILIAS/Calendar/classes/class.ilCalendarMailNotification.php +++ b/components/ILIAS/Calendar/classes/class.ilCalendarMailNotification.php @@ -18,6 +18,8 @@ declare(strict_types=1); +use ILIAS\Mail\Attachments\MailAttachments; + /** * Distributes calendar mail notifications * @author Stefan Meyer @@ -448,8 +450,6 @@ public function send(): void $this->sendMail(array($user_id), true); break; } - - $this->deleteAttachments(); } protected function addAttachment(): void @@ -461,18 +461,16 @@ protected function addAttachment(): void $ics_filename = 'appointment_' . (new DateTimeImmutable('now'))->format('Ymd_His_u') . '.ics'; - $attachment = new ilFileDataMail($this->getSender()); - $effective_ics_filename = $attachment->storeAsAttachment( + $attachment = $this->initFileDataMail(); + $rcid = $attachment->createCollectionFromContent( $ics_filename, $export->getExportString() ); - $this->setAttachments([$effective_ics_filename]); - } - - protected function deleteAttachments(): void - { - $attachment = new ilFileDataMail($this->getSender()); - $attachment->unlinkFiles($this->getAttachments()); + $this->setAttachments( + $rcid !== null + ? MailAttachments::fromIrss($rcid) + : MailAttachments::empty() + ); } } diff --git a/components/ILIAS/Course/classes/class.ilTimingsCronReminder.php b/components/ILIAS/Course/classes/class.ilTimingsCronReminder.php index 88a264feb927..d7eeea1b2602 100755 --- a/components/ILIAS/Course/classes/class.ilTimingsCronReminder.php +++ b/components/ILIAS/Course/classes/class.ilTimingsCronReminder.php @@ -21,6 +21,7 @@ use ILIAS\Cron\Job\Schedule\JobScheduleType; use ILIAS\Cron\Job\JobResult; use ILIAS\Cron\CronJob; +use ILIAS\Mail\Attachments\MailAttachments; class ilTimingsCronReminder extends CronJob { @@ -309,7 +310,7 @@ protected function sendExceededMail(int $user_id, array $ref_ids, string $mail_b '', $this->user_lang->txt('timings_cron_reminder_exceeded_subject'), $mail_body, - [], + MailAttachments::empty(), true ); $this->log->debug('...mail send for user ' . $user_id . ' to mail ' . $login . ' has exceeded timings for ' . $mail_body); @@ -334,7 +335,7 @@ protected function sendFreshlyStartedMail(int $user_id, array $ref_ids, string $ '', $this->user_lang->txt('timings_cron_reminder_started_subject'), $mail_body, - [], + MailAttachments::empty(), true ); $this->log->debug('...mail send for user ' . $user_id . ' to mail ' . $login . ' has freshly started timings for ' . $mail_body); diff --git a/components/ILIAS/EmployeeTalk/classes/Notification/Notification.php b/components/ILIAS/EmployeeTalk/classes/Notification/Notification.php index 4158b62b48a6..ab6ab46b8451 100755 --- a/components/ILIAS/EmployeeTalk/classes/Notification/Notification.php +++ b/components/ILIAS/EmployeeTalk/classes/Notification/Notification.php @@ -20,6 +20,8 @@ namespace ILIAS\EmployeeTalk\Notification; +use ILIAS\Mail\Attachments\MailAttachments; + class Notification { protected \ilObjUser $to; @@ -117,10 +119,10 @@ public function send(): void } $attachment_name = 'appointments.ics'; - $attachment->storeAsAttachment( - $attachment_name, - $this->attachment - ); + $rcid = $attachment->createCollectionFromContent($attachment_name, $this->attachment); + $attachments = $rcid !== null + ? MailAttachments::fromIrss($rcid) + : MailAttachments::empty(); $mail = new \ilMail(ANONYMOUS_USER_ID); $mail->enqueue( @@ -129,10 +131,8 @@ public function send(): void '', $subject, $notif->composeAndGetMessage($this->to->getId(), null, '', true), - [$attachment_name] + $attachments ); - - $attachment->unlinkFile($attachment_name); } protected function getTalkGoto(): string diff --git a/components/ILIAS/Exercise/Assignment/class.ilExAssignmentReminder.php b/components/ILIAS/Exercise/Assignment/class.ilExAssignmentReminder.php index 0581655d17d6..92be6900ce82 100755 --- a/components/ILIAS/Exercise/Assignment/class.ilExAssignmentReminder.php +++ b/components/ILIAS/Exercise/Assignment/class.ilExAssignmentReminder.php @@ -16,6 +16,8 @@ * *********************************************************************/ +use ILIAS\Mail\Attachments\MailAttachments; + /** * * TODO: import/export reminder data with the exercise/assignment. @@ -561,7 +563,7 @@ protected function sendReminders(array $reminders): int "", $subject, $message, - array() + MailAttachments::empty() ); } diff --git a/components/ILIAS/Exercise/classes/class.ilObjExercise.php b/components/ILIAS/Exercise/classes/class.ilObjExercise.php index 2f5dfdf9b923..a39cdaa0b731 100755 --- a/components/ILIAS/Exercise/classes/class.ilObjExercise.php +++ b/components/ILIAS/Exercise/classes/class.ilObjExercise.php @@ -23,6 +23,8 @@ use ILIAS\Filesystem\Exception\IOException; use ILIAS\Exercise\InternalService; use ILIAS\Exercise\Assignment\Mandatory\MandatoryAssignmentsManager; +use ILIAS\Mail\Attachments\MailAttachments; +use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; /** * Class ilObjExercise @@ -454,17 +456,11 @@ public function sendAssignment(ilExAssignment $a_ass, array $a_members): void // instruction files $if = $this->service->domain()->assignment()->instructionFiles($a_ass->getId()); - $files = $if->getFiles(); - $file_names = []; - if (count($files) > 0) { - $mfile_obj = new ilFileDataMail($GLOBALS['DIC']['ilUser']->getId()); - foreach ($if->getFiles() as $file) { - $file_names[] = $file["name"]; - $mfile_obj->storeAsAttachment( - $file["name"], - $if->getStream($file["rid"])->getContents() - ); - } + $attachments = MailAttachments::empty(); + if ($if->getFiles() !== []) { + $attachments = MailAttachments::fromIrss( + new ResourceCollectionIdentification($if->getCollectionIdString()) + ); } // recipients @@ -485,16 +481,10 @@ public function sendAssignment(ilExAssignment $a_ass, array $a_members): void "", $subject, $body, - $file_names + $attachments ); unset($tmp_mail_obj); - // remove tmp files - if (count($file_names) && $mfile_obj) { - $mfile_obj->unlinkFiles($file_names); - unset($mfile_obj); - } - // set recipients mail status foreach ($a_members as $member_id) { $member_status = $a_ass->getMemberStatus($member_id); diff --git a/components/ILIAS/Forum/classes/Notification/class.ilForumCronNotificationDataProvider.php b/components/ILIAS/Forum/classes/Notification/class.ilForumCronNotificationDataProvider.php index bdb50c48e0d5..7f9a38185eb8 100755 --- a/components/ILIAS/Forum/classes/Notification/class.ilForumCronNotificationDataProvider.php +++ b/components/ILIAS/Forum/classes/Notification/class.ilForumCronNotificationDataProvider.php @@ -18,6 +18,9 @@ declare(strict_types=1); +use ILIAS\Mail\Attachments\MailAttachments; +use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; + /** * Class ilForumCronNotificationDataProvider * @author Nadia Matuschek @@ -46,6 +49,7 @@ class ilForumCronNotificationDataProvider implements ilForumNotificationMailData private readonly string $import_name; /** @var list */ private array $attachments = []; + private ?MailAttachments $mail_attachments = null; /** @var int[] */ private array $cron_recipients = []; private readonly int $post_update_user_id; @@ -105,14 +109,28 @@ protected function read(): void private function readAttachments(): void { - if (ilForumProperties::isSendAttachmentsByMailEnabled()) { - $fileDataForum = new ilFileDataForum($this->getObjId(), $this->getPostId()); - $filesOfPost = $fileDataForum->getFilesOfPost(); + if (!ilForumProperties::isSendAttachmentsByMailEnabled()) { + return; + } + + $fileDataForum = new ilFileDataForum($this->getObjId(), $this->getPostId()); + foreach ($fileDataForum->getFilesOfPost() as $attachment) { + $this->attachments[] = $attachment['name']; + } + + if ($this->attachments === []) { + return; + } - foreach ($filesOfPost as $attachment) { - $this->attachments[] = $attachment['name']; - } + $post = new ilForumPost($this->getPostId()); + $rcid_string = $post->getRCID(); + if ($rcid_string === '' || $rcid_string === ilForumPost::NO_RCID) { + return; } + + $this->mail_attachments = MailAttachments::fromIrss( + new ResourceCollectionIdentification($rcid_string) + ); } public function addRecipient(int $user_id): void @@ -216,6 +234,11 @@ public function getAttachments(): array return $this->attachments; } + public function getMailAttachments(): MailAttachments + { + return $this->mail_attachments ?? MailAttachments::empty(); + } + public function getPosDisplayUserId(): int { return $this->pos_display_user_id; diff --git a/components/ILIAS/Forum/classes/Notification/class.ilForumMailEventNotificationSender.php b/components/ILIAS/Forum/classes/Notification/class.ilForumMailEventNotificationSender.php index b4935ab97a78..e083c2548ce3 100755 --- a/components/ILIAS/Forum/classes/Notification/class.ilForumMailEventNotificationSender.php +++ b/components/ILIAS/Forum/classes/Notification/class.ilForumMailEventNotificationSender.php @@ -18,6 +18,8 @@ declare(strict_types=1); +use ILIAS\Mail\Attachments\MailAttachments; + /** * @author Niels Theen */ @@ -360,7 +362,7 @@ private function createMailValueObjectsWithAttachments( '', ilStr::strLen($subjectText) > 255 ? ilStr::substr($subjectText, 0, 255) : $subjectText, $bodyText, - $this->provider->getAttachments(), + $this->provider->getMailAttachments(), false, false ); @@ -393,7 +395,7 @@ private function createMailValueObjectWithoutAttachments( '', ilStr::strLen($subjectText) > 255 ? ilStr::substr($subjectText, 0, 255) : $subjectText, $bodyText, - [], + MailAttachments::empty(), false, false ); diff --git a/components/ILIAS/Forum/classes/Notification/class.ilForumNotificationDataProvider.php b/components/ILIAS/Forum/classes/Notification/class.ilForumNotificationDataProvider.php index 3cd624cd6d11..d78e7cf650f3 100755 --- a/components/ILIAS/Forum/classes/Notification/class.ilForumNotificationDataProvider.php +++ b/components/ILIAS/Forum/classes/Notification/class.ilForumNotificationDataProvider.php @@ -18,6 +18,9 @@ declare(strict_types=1); +use ILIAS\Mail\Attachments\MailAttachments; +use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; + /** * Class ilForumNotificationDataProvider * @author Nadia Matuschek @@ -35,6 +38,7 @@ class ilForumNotificationDataProvider implements ilForumNotificationMailData protected string $thread_title = ''; /** @var array */ protected array $attachments = []; + private ?MailAttachments $mail_attachments = null; private readonly ilDBInterface $db; private readonly ilAccessHandler $access; private readonly ilObjUser $user; @@ -148,6 +152,11 @@ public function getAttachments(): array return $this->attachments; } + public function getMailAttachments(): MailAttachments + { + return $this->mail_attachments ?? MailAttachments::empty(); + } + public function getPosUserAlias(): string { return $this->objPost->getUserAlias(); @@ -314,17 +323,27 @@ public function determineClosestContainer(int $frm_ref_id): ?ilObject private function readAttachments(): void { - if (ilForumProperties::isSendAttachmentsByMailEnabled()) { - $fileDataForum = new ilFileDataForum($this->getObjId(), $this->objPost->getId()); - $filesOfPost = $fileDataForum->getFilesOfPost(); + if (!ilForumProperties::isSendAttachmentsByMailEnabled()) { + return; + } - $fileDataMail = new ilFileDataMail(ANONYMOUS_USER_ID); + $fileDataForum = new ilFileDataForum($this->getObjId(), $this->objPost->getId()); + foreach ($fileDataForum->getFilesOfPost() as $attachment) { + $this->attachments[$attachment['path']] = $attachment['name']; + } - foreach ($filesOfPost as $attachment) { - $this->attachments[$attachment['path']] = $attachment['name']; - $fileDataMail->copyAttachmentFile($attachment['path'], $attachment['name']); - } + if ($this->attachments === []) { + return; } + + $rcid_string = $this->objPost->getRCID(); + if ($rcid_string === '' || $rcid_string === ilForumPost::NO_RCID) { + return; + } + + $this->mail_attachments = MailAttachments::fromIrss( + new ResourceCollectionIdentification($rcid_string) + ); } /** diff --git a/components/ILIAS/Forum/classes/class.ilForumMailNotification.php b/components/ILIAS/Forum/classes/class.ilForumMailNotification.php index 33c8f2712187..4599cd19616f 100755 --- a/components/ILIAS/Forum/classes/class.ilForumMailNotification.php +++ b/components/ILIAS/Forum/classes/class.ilForumMailNotification.php @@ -77,7 +77,7 @@ protected function appendAttachments(): void $this->appendBody($this->getLanguageText('attachment') . ': ' . $attachment . "\n"); } $this->appendBody($this->getBlockBorder() . "\n"); - $this->setAttachments($this->provider->getAttachments()); + $this->setAttachments($this->provider->getMailAttachments()); } } diff --git a/components/ILIAS/Forum/interfaces/interface.ilForumNotificationMailData.php b/components/ILIAS/Forum/interfaces/interface.ilForumNotificationMailData.php index 191ad0b26bef..cd28220a4d8f 100755 --- a/components/ILIAS/Forum/interfaces/interface.ilForumNotificationMailData.php +++ b/components/ILIAS/Forum/interfaces/interface.ilForumNotificationMailData.php @@ -18,6 +18,8 @@ declare(strict_types=1); +use ILIAS\Mail\Attachments\MailAttachments; + /** * Interface ilForumNotificationMailData * @author Nadia Matuschek @@ -71,5 +73,7 @@ public function getCensorshipComment(): string; public function getAttachments(): array; + public function getMailAttachments(): MailAttachments; + public function getDeletedBy(): string; } diff --git a/components/ILIAS/LearningModule/classes/class.ilLearningModuleNotification.php b/components/ILIAS/LearningModule/classes/class.ilLearningModuleNotification.php index ee135bcbeebd..737d93067a66 100755 --- a/components/ILIAS/LearningModule/classes/class.ilLearningModuleNotification.php +++ b/components/ILIAS/LearningModule/classes/class.ilLearningModuleNotification.php @@ -16,6 +16,8 @@ * *********************************************************************/ +use ILIAS\Mail\Attachments\MailAttachments; + /** * Class ilLearningModuleNotification class * @@ -109,7 +111,7 @@ public function send(): void "", $subject, $message, - [] + MailAttachments::empty() ); } } diff --git a/components/ILIAS/Mail/README.md b/components/ILIAS/Mail/README.md index 5d6a7d44a418..e191a53cb3ae 100755 --- a/components/ILIAS/Mail/README.md +++ b/components/ILIAS/Mail/README.md @@ -17,6 +17,7 @@ interpreted as described in [RFC 2119](https://www.ietf.org/rfc/rfc2119.txt). * [Syntax Rules](#syntax-rules) * [Semantics](#semantics) * [Subject and Body](#subject-and-body) + * [Attachments](#attachments) * [ilMimeMail](#ilmimemail) * [Sender](#sender) * [Transport](#transport) @@ -150,6 +151,8 @@ as well but not intended to be used by consumers. Simple Example: ```php +use ILIAS\Mail\Attachments\MailAttachments; + global $DIC; $senderUserId = $DIC->user()->getId(); @@ -159,7 +162,7 @@ $cc = 'john.doe'; $bc = 'max.mustermann'; $subject = 'Make ILIAS great again!'; $message = "Lorem ipsum dolor sit amet,\nconsetetur sadipscing elitr,\nsed diam nonumy eirmod tempor."; -$attachments = []; +$attachments = MailAttachments::empty(); $mail = new \ilMail($senderUserId); $mail->enqueue( @@ -360,24 +363,48 @@ is applied afterwards on the originally passed message body. ### Attachments -The `$attachments` can be passed as an array of file names. -Each file MUST have been assigned to the sending user account -in a prior step. -This can be done as described in the following examples: +Since ILIAS 12 attachments are stored in the IRSS. The `$attachments` +argument of `enqueue()` MUST be an instance of +`ILIAS\Mail\Attachments\MailAttachments`, arrays of file names +are not accepted anymore. The same applies to `\ilMailValueObject`, +`\MailDeliveryData` and `\ilMailNotification::setAttachments()`. +`MailAttachments::empty()` MUST be passed if a message has no attachments. + +There are two ways to attach files: + +1. Files your component already stores in the IRSS are passed as + collection of your component: `MailAttachments::fromIrss($rcid)`. + The mail system copies these resources into its own collections. + Your collection and its resources are never changed or removed by + the mail system, so the same collection MAY be passed for several messages. +2. Files that only exist for the message (e.g. an iCal file) are stored + in a collection of the mail system with + `\ilFileDataMail::createCollectionFromContent()`. + This collection is handed over to the mail system: it becomes the collection + of the queued message and is removed together with its files once the last + mail referencing it has been deleted. It MUST NOT be passed + to a second `enqueue()` call and MUST NOT be removed by the consumer. + A `\DomainException` is thrown if the content exceeds the upload limit. + +A collection without any resource MUST NOT be passed, use +`MailAttachments::empty()` instead. Otherwise the message is rejected +with the error `mail_error_reading_attachment`. + +Internally every file of the mail system belongs to exactly one collection: +the attachment pool, the compose stage, a draft, a scheduled mail, or a sent mail. +A sent mail and all its received copies share one collection, which is removed +when the last of these mails is deleted. Whenever files change hands (opening a draft, +forwarding, queueing, choosing files of the pool) they are copied. ```php -$attachment = new \ilFileDataMail($senderUserId); +use ILIAS\Mail\Attachments\MailAttachments; -$attachment_filename = $attachment->storeAsAttachment( +$mail_file_data = new \ilFileDataMail($sender_usr_id); +$rcid = $mail_file_data->createCollectionFromContent( 'appointment.ics', $some_ical_string ); -$attachment->copyAttachmentFile( - '/temp/hello.jpg', - 'HelloWorld.jpg' -); - $mail = new \ilMail($sender_usr_id); $mail->enqueue( $to, @@ -385,20 +412,26 @@ $mail->enqueue( $bc, $subject, $message, - [ - $attachment_filename, - 'HelloWorld.jpg' - ] + $rcid !== null ? MailAttachments::fromIrss($rcid) : MailAttachments::empty() ); -// or $attachment->unlinkFiles(['HelloWorld.jpg', $attachment_filename]); -$attachment->unlinkFile('HelloWorld.jpg'); -$attachment->unlinkFile($attachment_filename); +// Files of your own component +$mail->enqueue( + $to, + $cc, + $bc, + $subject, + $message, + MailAttachments::fromIrss($collection_identification_of_your_component) +); ``` -As outlined above attachments have to be removed -manually by the consumer after the transport of an -email has been delegated to the mail system. +Derivatives of `\ilMailNotification` SHOULD use `initFileDataMail()`, +so the files belong to the sender of the notification. + +`MailAttachments::fromLegacyFilenames()` only exists for files in the legacy +attachment pool of the sender (stored as `_` in the mail +directory of the client) and MUST NOT be used for new code. ## ilMimeMail @@ -450,10 +483,19 @@ $mailer->Attach( 'inline', 'AnotherComposer.json' ); +$mailer->AttachResource( + $resource_identification, + 'Report.pdf', + 'application/pdf', + 'attachment' +); $mailer->Send(); ``` +Resources attached with `AttachResource()` are read from the IRSS when the +email is sent. A resource that cannot be read is skipped and logged. + Subject and body MUST be of type plain text and MUST NOT contain any HTML fragments. When preparing the email transport `\ilMimeMail` wraps an HTML structure around the message body (see: [HTML Mails with Skin](https://www.ilias.de/docu/goto_docu_wiki_wpage_3506_1357.html)) diff --git a/components/ILIAS/Mail/ROADMAP.md b/components/ILIAS/Mail/ROADMAP.md index d45a7c0e748b..853d455693fb 100755 --- a/components/ILIAS/Mail/ROADMAP.md +++ b/components/ILIAS/Mail/ROADMAP.md @@ -17,6 +17,9 @@ Current Problems: context which is responsible to provide/replace placeholders. * To solve the problem, the mail context implementations are currently created with the `Reflection API` in the setup context. -### IRSS Integration +## Done -Files should be handled by the IRSS \ No newline at end of file +### IRSS Integration (ILIAS 12) + +Mail attachments are stored and delivered via the IRSS (`MailAttachments` / resource collections). +Existing filesystem attachments are migrated by the setup migration `MigrateMailAttachmentsToIRSS`. diff --git a/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php b/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php index 9f375c4dc329..714b26bd6e6a 100644 --- a/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php +++ b/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php @@ -18,136 +18,86 @@ declare(strict_types=1); -use ILIAS\Filesystem\Stream\Streams; +use ILIAS\Mail\Attachments\MailAttachments; use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; -use ILIAS\ResourceStorage\Collection\ResourceCollection; trait FileDataRCHandling { /** - * @param list $path_to_files + * @return list */ - protected function getCurrentCollection( - array $path_to_files, - ilMailAttachmentStakeholder $stakeholder - ): \ILIAS\ResourceStorage\Collection\ResourceCollection { - set_error_handler(static function ($severity, $message, $file, $line): void { - throw new ErrorException($message, $severity, 0, $file, $line); - }); - - try { - global $DIC; - $file_system = $DIC->filesystem()->storage(); - $rcid = $this->storage->collection()->id(); - $collection = $this->storage->collection()->get($rcid); - foreach ($path_to_files as $path_to_file) { - $base_dir = (new \ILIAS\FileDelivery\Setup\BaseDirObjective())::get(); - $path_to_file = str_replace($base_dir, '/', $path_to_file); - $rid = $this->storage->manage()->stream( - $file_system->readStream($path_to_file), - $stakeholder, - md5(basename($path_to_file)) - ); - $collection->add($rid); - } - $this->storage->collection()->store($collection); - } catch (Exception $e) { - throw new Exception("Storing file into collection failed: " . $e->getMessage()); - } finally { - restore_error_handler(); - } - - return $collection; + public function FilesFromIRSSToLegacy(ResourceCollectionIdentification $identification): array + { + return $this->fdm->getRidsFromCollection($identification); } /** - * @param array $mail_data - * @return list + * Uploaded files are already part of the compose stage. The stage is reduced to the files + * of the submitted form, no files in the form means no attachments. + * + * @param list $form_attachment_rids + * @throws ilMailAttachmentsTotalSizeLimitExceededException */ - public function filesFromLegacyToIRSS(array $mail_data): array + protected function attachmentsFromFormUpload(array $form_attachment_rids): MailAttachments { - $files = []; - $path_to_files = []; - foreach ($mail_data['attachments'] as $file) { - $path_to_files[] = $this->fdm->getAbsoluteAttachmentPoolPathByFilename($file); + if ($form_attachment_rids === []) { + return MailAttachments::empty(); } - $collection = $this->getCurrentCollection($path_to_files, new ilMailAttachmentStakeholder()); - foreach ($collection->getResourceIdentifications() as $rcid) { - $files[] = $rcid->serialize(); + + $limit = $this->fdm->getAttachmentsTotalSizeLimit(); + $total_size = 0; + foreach ($form_attachment_rids as $attachment) { + $info = $this->upload_handler->getInfoResult($attachment); + if ($info->getFileIdentifier() !== 'unknown') { + $total_size += $info->getSize(); + } + } + if ($limit !== null && $total_size > $limit) { + throw new ilMailAttachmentsTotalSizeLimitExceededException( + $this->lng->txt('mail_max_size_attachments_total_error') . ' ' . ilUtil::formatSize((int) $limit) + ); } - return $files; + $stage_rcid = $this->fdm->restrictStageTo($form_attachment_rids); + + return $stage_rcid !== null + ? MailAttachments::fromIrss($stage_rcid) + : MailAttachments::empty(); } /** - * @param array $mail_data + * Drafts, scheduled and forwarded mails keep their collection, the compose stage works on a copy */ - public function getIdforCollection(array $mail_data): ?ResourceCollectionIdentification + protected function stageAttachmentsFromMailAttachments(MailAttachments $attachments): MailAttachments { - $files = []; - $path_to_files = []; - foreach ($mail_data as $attachment) { - $path_to_files[] = $this->fdm->getAbsoluteAttachmentPoolPathByFilename($attachment); + if ($attachments->isEmpty()) { + return MailAttachments::empty(); } - $collection = $this->getCurrentCollection($path_to_files, new ilMailAttachmentStakeholder()); - $rcid = $collection->getIdentification(); - return $rcid; - } + if ($attachments->isIrss()) { + $stage_rcid = $this->fdm->copyCollection($attachments->rcid()); - /** - * @return list - */ - public function FilesFromIRSSToLegacy(ResourceCollectionIdentification $identification): array - { - $files = []; - $collection = $this->storage->collection()->get($identification); - $all_ids = $collection->getResourceIdentifications(); - foreach ($all_ids as $id) { - $files[] = $id->serialize(); + return $stage_rcid !== null + ? MailAttachments::fromIrss($stage_rcid) + : MailAttachments::empty(); } - return $files; + $rcid = $this->fdm->createCollectionFromPoolFilenames($attachments->legacyFilenames()); + + return $rcid !== null + ? MailAttachments::fromIrss($rcid) + : MailAttachments::empty(); } /** - * @param array $attachments * @return list - * @throws ilMailAttachmentsTotalSizeLimitExceededException */ - protected function handleAttachments(array $attachments): array + protected function formRidsFromMailAttachments(MailAttachments $attachments): array { - $limit = $this->fdm->getAttachmentsTotalSizeLimit(); - $total_size = 0; - foreach ($attachments as $attachment) { - $info = $this->upload_handler->getInfoResult($attachment); - if ($info->getFileIdentifier() !== 'unknown') { - $total_size += $info->getSize(); - } - } - if ($limit !== null && $total_size > $limit) { - throw new ilMailAttachmentsTotalSizeLimitExceededException( - $this->lng->txt('mail_max_size_attachments_total_error') . ' ' . ilUtil::formatSize((int) $limit) - ); - } - - $files = []; - foreach ($attachments as $attachment) { - $info = $this->upload_handler->getInfoResult($attachment); - if ($info->getFileIdentifier() !== 'unknown') { - $src = $this->upload_handler->getStreamConsumer($attachment); - $stored = $this->fdm->storeAsAttachment( - $info->getName(), - (string) $src->getStream() - ); - if ($stored === false) { - throw new Exception("File '" . $info->getName() . "' could not be stored"); - } - $files[] = ilFileUtils::_sanitizeFilemame($info->getName()); - $this->upload_handler->removeFileForIdentifier($attachment); - } + if ($attachments->isIrss()) { + return $this->FilesFromIRSSToLegacy($attachments->rcid()); } - return $files; + return []; } } diff --git a/components/ILIAS/Mail/classes/Attachments/MailAttachmentTableGUI.php b/components/ILIAS/Mail/classes/Attachments/MailAttachmentTableGUI.php index 95513b69b037..65eaf292a892 100644 --- a/components/ILIAS/Mail/classes/Attachments/MailAttachmentTableGUI.php +++ b/components/ILIAS/Mail/classes/Attachments/MailAttachmentTableGUI.php @@ -31,7 +31,7 @@ class MailAttachmentTableGUI implements \ILIAS\UI\Component\Table\DataRetrieval, private readonly \ILIAS\Data\Factory $data_factory; /** - * @param list $records + * @param list $records */ public function __construct( private readonly \ilMailAttachmentGUI $parent_gui, @@ -141,7 +141,7 @@ private function getActions(): array } /** - * @return list + * @return list */ private function getRecords(\ILIAS\Data\Range $range, \ILIAS\Data\Order $order): array { @@ -185,7 +185,7 @@ public function getRows( ]; yield $row_builder - ->buildDataRow(urlencode($record['filename']), $record); + ->buildDataRow($item['rid'], $record); } } diff --git a/components/ILIAS/Mail/classes/BackgroundTask/class.ilMailDeliveryJob.php b/components/ILIAS/Mail/classes/BackgroundTask/class.ilMailDeliveryJob.php index 24f51d6f433d..a6c885983418 100755 --- a/components/ILIAS/Mail/classes/BackgroundTask/class.ilMailDeliveryJob.php +++ b/components/ILIAS/Mail/classes/BackgroundTask/class.ilMailDeliveryJob.php @@ -26,6 +26,7 @@ use ILIAS\BackgroundTasks\Types\SingleType; use ILIAS\BackgroundTasks\Types\Type; use ILIAS\BackgroundTasks\Value; +use ILIAS\Mail\Attachments\MailAttachments; class ilMailDeliveryJob extends AbstractJob { @@ -69,7 +70,7 @@ public function run(array $input, Observer $observer): Value (string) $input[3]->getValue(), // Bcc (string) $input[4]->getValue(), // Subject (string) $input[5]->getValue(), // Message - (array) unserialize($input[6]->getValue(), ['allowed_classes' => false]), // Attachments + MailAttachments::fromBackgroundTask($input[6]->getValue()), // Attachments (bool) $input[7]->getValue() // Use Placeholders ); $mail->sendMail($mail_data); diff --git a/components/ILIAS/Mail/classes/Cron/ExpiredOrOrphanedMails/MailDeletionHandler.php b/components/ILIAS/Mail/classes/Cron/ExpiredOrOrphanedMails/MailDeletionHandler.php index 8faac45a6718..dc669a8152cf 100755 --- a/components/ILIAS/Mail/classes/Cron/ExpiredOrOrphanedMails/MailDeletionHandler.php +++ b/components/ILIAS/Mail/classes/Cron/ExpiredOrOrphanedMails/MailDeletionHandler.php @@ -31,7 +31,9 @@ use Throwable; use RecursiveIteratorIterator; use RecursiveDirectoryIterator; +use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; use SplFileInfo; +use ilFileDataMail; class MailDeletionHandler { @@ -53,7 +55,8 @@ public function __construct( ?ilDBInterface $db = null, ?ilSetting $setting = null, ?ilLogger $logger = null, - ?callable $delete_directory_callback = null + ?callable $delete_directory_callback = null, + private readonly ?ilFileDataMail $mail_file_data = null, ) { global $DIC; @@ -68,6 +71,32 @@ public function __construct( ); } + /** + * @return list + */ + private function determineAttachmentRcidsOfDeletedMails(): array + { + $rcids = []; + $res = $this->db->query( + ' + SELECT DISTINCT rcid + FROM mail_attachment + WHERE rcid IS NOT NULL AND rcid != "" AND rcid != "-" + AND ' . $this->db->in( + 'mail_id', + $this->collector->mailIdsToDelete(), + false, + ilDBConstants::T_INTEGER + ) + ); + + while ($row = $this->db->fetchAssoc($res)) { + $rcids[] = (string) $row['rcid']; + } + + return $rcids; + } + /** * @return list */ @@ -79,7 +108,8 @@ private function determineDeletableAttachmentPaths(): array ' SELECT path, COUNT(mail_id) cnt_mail_ids FROM mail_attachment - WHERE ' . $this->db->in( + WHERE path IS NOT NULL AND path != "" + AND ' . $this->db->in( 'mail_id', $this->collector->mailIdsToDelete(), false, @@ -101,7 +131,8 @@ private function determineDeletableAttachmentPaths(): array )['cnt']; $num_usages_within_deleted_mails = (int) $row['cnt_mail_ids']; - if ($num_usages_within_deleted_mails >= $num_usages_total) { + // IRSS based attachments have an empty path, which must never resolve to the mail directory itself + if ($num_usages_within_deleted_mails >= $num_usages_total && trim((string) $row['path'], '/. ') !== '') { $attachment_paths[] = $row['path']; } @@ -122,6 +153,7 @@ private function deleteDirectory(string $directory): void private function deleteAttachments(): void { + $attachment_rcids = $this->determineAttachmentRcidsOfDeletedMails(); $attachment_paths = $this->determineDeletableAttachmentPaths(); $i = 0; @@ -185,6 +217,17 @@ private function deleteAttachments(): void 'DELETE FROM mail_attachment WHERE ' . $this->db->in('mail_id', $this->collector->mailIdsToDelete(), false, ilDBConstants::T_INTEGER) ); + + if ($attachment_rcids !== []) { + $mail_file_data = $this->mail_file_data ?? new ilFileDataMail(); + foreach ($attachment_rcids as $rcid) { + try { + $mail_file_data->releaseCollectionIfUnreferenced(new ResourceCollectionIdentification($rcid)); + } catch (Throwable $e) { + $this->logger->warning($e->getMessage()); + } + } + } } private function deleteMails(): void diff --git a/components/ILIAS/Mail/classes/Folder/OutboxDatabaseRepository.php b/components/ILIAS/Mail/classes/Folder/OutboxDatabaseRepository.php index 956c9ec0d819..62b1c8a4f3b5 100644 --- a/components/ILIAS/Mail/classes/Folder/OutboxDatabaseRepository.php +++ b/components/ILIAS/Mail/classes/Folder/OutboxDatabaseRepository.php @@ -27,6 +27,7 @@ use MailDeliveryData; use DateTimeImmutable; use ILIAS\Data\Clock\ClockFactory; +use ILIAS\Mail\Attachments\MailAttachments; use ILIAS\Mail\Message\MailRecordMapper; readonly class OutboxDatabaseRepository implements OutboxRepository @@ -87,15 +88,13 @@ public function getOutboxMails(): Generator continue; } - $attachments = $record->getAttachments(); - yield new MailDeliveryData( $record->getRcpTo() ?? '', $record->getRcpCc() ?? '', $record->getRcpBc() ?? '', $record->getSubject() ?? '', $record->getMessage() ?? '', - is_array($attachments) ? $attachments : [], + $record->getAttachments() ?? MailAttachments::empty(), (bool) $record->getUsePlaceholders(), $record->getMailId(), $record->getUserId() diff --git a/components/ILIAS/Mail/classes/MailDeliveryData.php b/components/ILIAS/Mail/classes/MailDeliveryData.php index 5ba522cf6783..187cb0860970 100755 --- a/components/ILIAS/Mail/classes/MailDeliveryData.php +++ b/components/ILIAS/Mail/classes/MailDeliveryData.php @@ -18,6 +18,8 @@ declare(strict_types=1); +use ILIAS\Mail\Attachments\MailAttachments; + final class MailDeliveryData { public function __construct( @@ -26,7 +28,7 @@ public function __construct( private readonly string $bcc, private readonly string $subject, private readonly string $message, - private readonly array $attachments, + private MailAttachments $attachments, private readonly bool $use_placeholder, private ?int $internal_mail_id = null, private readonly int $user_id = 0 @@ -58,7 +60,7 @@ public function getMessage(): string return $this->message; } - public function getAttachments(): array + public function getAttachments(): MailAttachments { return $this->attachments; } @@ -85,4 +87,11 @@ public function withInternalMailId(int $internal_mail_id): MailDeliveryData return $clone; } + + public function withAttachments(MailAttachments $attachments): MailDeliveryData + { + $clone = clone $this; + $clone->attachments = $attachments; + return $clone; + } } diff --git a/components/ILIAS/Mail/classes/Message/MailBoxQuery.php b/components/ILIAS/Mail/classes/Message/MailBoxQuery.php index 807fadfc12a6..0cab65335765 100755 --- a/components/ILIAS/Mail/classes/Message/MailBoxQuery.php +++ b/components/ILIAS/Mail/classes/Message/MailBoxQuery.php @@ -26,7 +26,7 @@ use DateTimeZone; use ILIAS\Data\Order; use ilUserSearchOptions; -use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; +use ILIAS\Mail\Attachments\MailAttachments; class MailBoxQuery { @@ -287,11 +287,8 @@ public function query($short): array $set = []; while ($row = $this->db->fetchAssoc($res)) { - if (isset($row['attachments']) && \is_string($row['attachments']) && str_contains($row['attachments'], '{')) { - $unserialized_attachments = unserialize($row['attachments'], ['allowed_classes' => false]); - $row['attachments'] = \is_array($unserialized_attachments) ? $unserialized_attachments : null; - } elseif (isset($row['attachments']) && \is_string($row['attachments']) && $row['attachments'] !== '') { - $row['attachments'] = new ResourceCollectionIdentification($row['attachments']); + if (isset($row['attachments']) && is_string($row['attachments'])) { + $row['attachments'] = MailAttachments::fromDb($row['attachments']); } else { $row['attachments'] = null; } diff --git a/components/ILIAS/Mail/classes/Message/MailRecordData.php b/components/ILIAS/Mail/classes/Message/MailRecordData.php index 53bc3feb3e64..3e27d05a7806 100644 --- a/components/ILIAS/Mail/classes/Message/MailRecordData.php +++ b/components/ILIAS/Mail/classes/Message/MailRecordData.php @@ -21,7 +21,7 @@ namespace ILIAS\Mail\Message; use DateTimeImmutable; -use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; +use ILIAS\Mail\Attachments\MailAttachments; class MailRecordData { @@ -29,7 +29,7 @@ class MailRecordData public const string STATUS_UNREAD = 'unread'; /** - * @param null|non-empty-list|ResourceCollectionIdentification $attachments + * @param null|MailAttachments $attachments */ public function __construct( private readonly int $mail_id, @@ -45,7 +45,7 @@ public function __construct( private readonly ?string $rcp_to = null, private readonly ?string $rcp_cc = null, private readonly ?string $rcp_bc = null, - private readonly null|array|ResourceCollectionIdentification $attachments = null, + private readonly ?MailAttachments $attachments = null, private readonly ?string $tpl_ctx_id = null, private readonly ?string $tpl_ctx_params = null ) { @@ -116,10 +116,7 @@ public function getRcpBc(): ?string return $this->rcp_bc; } - /** - * @return null|non-empty-list|ResourceCollectionIdentification - */ - public function getAttachments(): null|array|ResourceCollectionIdentification + public function getAttachments(): ?MailAttachments { return $this->attachments; } @@ -141,7 +138,7 @@ public function isRead(): bool public function hasAttachments(): bool { - return !empty($this->attachments); + return $this->attachments !== null && !$this->attachments->isEmpty(); } public function hasPersonalSender(): bool diff --git a/components/ILIAS/Mail/classes/Message/MailRecordMapper.php b/components/ILIAS/Mail/classes/Message/MailRecordMapper.php index 031e15c330fb..a205eb661d15 100644 --- a/components/ILIAS/Mail/classes/Message/MailRecordMapper.php +++ b/components/ILIAS/Mail/classes/Message/MailRecordMapper.php @@ -21,7 +21,7 @@ namespace ILIAS\Mail\Message; use DateTimeImmutable; -use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; +use ILIAS\Mail\Attachments\MailAttachments; final readonly class MailRecordMapper { @@ -72,11 +72,8 @@ public function normalizeRow(?array $row): ?array return null; } - if (isset($row['attachments']) && is_string($row['attachments']) && str_contains($row['attachments'], '{')) { - $unserialized_attachments = unserialize($row['attachments'], ['allowed_classes' => false]); - $row['attachments'] = is_array($unserialized_attachments) ? $unserialized_attachments : null; - } elseif (isset($row['attachments']) && is_string($row['attachments']) && $row['attachments'] !== '') { - $row['attachments'] = new ResourceCollectionIdentification($row['attachments']); + if (isset($row['attachments']) && is_string($row['attachments'])) { + $row['attachments'] = MailAttachments::fromDb($row['attachments']); } else { $row['attachments'] = null; } diff --git a/components/ILIAS/Mail/classes/Mime/Transport/class.ilMailMimeTransportBase.php b/components/ILIAS/Mail/classes/Mime/Transport/class.ilMailMimeTransportBase.php index d08e72952133..81853ea0e7a0 100755 --- a/components/ILIAS/Mail/classes/Mime/Transport/class.ilMailMimeTransportBase.php +++ b/components/ILIAS/Mail/classes/Mime/Transport/class.ilMailMimeTransportBase.php @@ -19,13 +19,18 @@ declare(strict_types=1); use PHPMailer\PHPMailer\PHPMailer; +use ILIAS\Mail\Mime\MailMimeAttachment; +use ILIAS\ResourceStorage\Services as IRSS; abstract class ilMailMimeTransportBase implements ilMailMimeTransport { protected PHPMailer $mailer; - public function __construct(protected ilSetting $settings, private readonly ilAppEventHandler $event_handler) - { + public function __construct( + protected ilSetting $settings, + private readonly ilAppEventHandler $event_handler, + private readonly IRSS $irss + ) { $mail = new PHPMailer(); $this->setMailer($mail); } @@ -101,8 +106,8 @@ final public function send(ilMimeMail $mail): bool ilLoggerFactory::getLogger('mail')->warning('{error}', ['error' => $this->getMailer()->ErrorInfo]); } - foreach ($mail->getAttachments() as $attachment) { - if (!$this->getMailer()->addAttachment($attachment['path'], $attachment['name'])) { + foreach ($mail->getMimeAttachments() as $attachment) { + if (!$this->addMimeAttachment($attachment)) { ilLoggerFactory::getLogger('mail')->warning('{error}', ['error' => $this->getMailer()->ErrorInfo]); } } @@ -187,4 +192,56 @@ final public function send(ilMimeMail $mail): bool return $result; } + + private function addMimeAttachment(MailMimeAttachment $attachment): bool + { + if ($attachment->isResource()) { + return $this->addResourceAttachment($attachment); + } + + $path = $attachment->getPath(); + if ($path === null) { + return false; + } + + return $this->getMailer()->addAttachment( + $path, + $attachment->getDisplayName(), + PHPMailer::ENCODING_BASE64, + $attachment->getMimeType(), + $attachment->getDisposition() + ); + } + + private function addResourceAttachment(MailMimeAttachment $attachment): bool + { + $resource_identification = $attachment->getResourceIdentification(); + if ($resource_identification === null) { + return false; + } + + try { + $bytes = $this->irss->consume()->stream($resource_identification)->getStream()->getContents(); + } catch (\Throwable $exception) { + ilLoggerFactory::getLogger('mail')->warning( + 'Could not read IRSS mail attachment {rid}: {error}', + [ + 'rid' => $resource_identification->serialize(), + 'error' => $exception->getMessage(), + ] + ); + + return false; + } + + return $this->getMailer()->addStringAttachment( + $bytes, + $attachment->getDisplayName() !== '' + ? $attachment->getDisplayName() + : $resource_identification->serialize(), + PHPMailer::ENCODING_BASE64, + $attachment->getMimeType(), + $attachment->getDisposition() + ); + } } diff --git a/components/ILIAS/Mail/classes/Mime/Transport/class.ilMailMimeTransportFactory.php b/components/ILIAS/Mail/classes/Mime/Transport/class.ilMailMimeTransportFactory.php index e9ea446223fc..f56d08a32c00 100755 --- a/components/ILIAS/Mail/classes/Mime/Transport/class.ilMailMimeTransportFactory.php +++ b/components/ILIAS/Mail/classes/Mime/Transport/class.ilMailMimeTransportFactory.php @@ -18,10 +18,15 @@ declare(strict_types=1); +use ILIAS\ResourceStorage\Services as IRSS; + class ilMailMimeTransportFactory { - public function __construct(protected ilSetting $settings, private readonly ilAppEventHandler $event_handler) - { + public function __construct( + protected ilSetting $settings, + private readonly ilAppEventHandler $event_handler, + private readonly IRSS $irss + ) { } public function getTransport(): ilMailMimeTransport @@ -31,9 +36,9 @@ public function getTransport(): ilMailMimeTransport } if ($this->settings->get('mail_smtp_status', '0')) { - return new ilMailMimeTransportSmtp($this->settings, $this->event_handler); + return new ilMailMimeTransportSmtp($this->settings, $this->event_handler, $this->irss); } - return new ilMailMimeTransportSendmail($this->settings, $this->event_handler); + return new ilMailMimeTransportSendmail($this->settings, $this->event_handler, $this->irss); } } diff --git a/components/ILIAS/Mail/classes/Object/class.ilMailValueObject.php b/components/ILIAS/Mail/classes/Object/class.ilMailValueObject.php index e7dbc3e223bf..de1236a63fd7 100755 --- a/components/ILIAS/Mail/classes/Object/class.ilMailValueObject.php +++ b/components/ILIAS/Mail/classes/Object/class.ilMailValueObject.php @@ -18,13 +18,13 @@ declare(strict_types=1); +use ILIAS\Mail\Attachments\MailAttachments; + class ilMailValueObject { - /** @var list */ - private readonly array $attachments; + private readonly MailAttachments $attachments; /** - * @param list $attachments * @throws InvalidArgumentException */ public function __construct( @@ -34,14 +34,14 @@ public function __construct( private readonly string $recipients_bcc, private readonly string $subject, private readonly string $body, - array $attachments, + ?MailAttachments $attachments = null, private readonly bool $use_placeholders = false, private readonly bool $save_in_sent_box = false ) { if (ilStr::strLen($this->subject) > 255) { throw new InvalidArgumentException('Subject must not be longer than 255 characters'); } - $this->attachments = array_filter(array_map('trim', $attachments)); + $this->attachments = $attachments ?? MailAttachments::empty(); } public function getRecipients(): string @@ -69,10 +69,7 @@ public function getBody(): string return $this->body; } - /** - * @return list - */ - public function getAttachments(): array + public function getAttachments(): MailAttachments { return $this->attachments; } diff --git a/components/ILIAS/Mail/classes/Object/class.ilMailValueObjectJsonService.php b/components/ILIAS/Mail/classes/Object/class.ilMailValueObjectJsonService.php index 2e02d8dfd1d3..080455b3bfab 100755 --- a/components/ILIAS/Mail/classes/Object/class.ilMailValueObjectJsonService.php +++ b/components/ILIAS/Mail/classes/Object/class.ilMailValueObjectJsonService.php @@ -18,6 +18,8 @@ declare(strict_types=1); +use ILIAS\Mail\Attachments\MailAttachments; + class ilMailValueObjectJsonService { /** @@ -32,7 +34,7 @@ public function convertToJson(array $mail_value_objects): string $mail_data['recipients'] = $mail_value_object->getRecipients(); $mail_data['recipients_cc'] = $mail_value_object->getRecipientsCC(); $mail_data['recipients_bcc'] = $mail_value_object->getRecipientsBCC(); - $mail_data['attachments'] = $mail_value_object->getAttachments(); + $mail_data['attachments'] = $mail_value_object->getAttachments()->toBackgroundTask(); $mail_data['body'] = $mail_value_object->getBody(); $mail_data['subject'] = $mail_value_object->getSubject(); $mail_data['is_using_placholders'] = $mail_value_object->isUsingPlaceholders(); @@ -60,7 +62,7 @@ public function convertFromJson(string $json): array $object_values['recipients_bcc'], ilStr::strLen($object_values['subject']) > 255 ? ilStr::substr($object_values['subject'], 0, 255) : $object_values['subject'], $object_values['body'], - $object_values['attachments'], + $this->attachmentsFromJson($object_values['attachments'] ?? null), $object_values['is_using_placholders'], $object_values['should_save_in_sent_box'] ); @@ -68,4 +70,17 @@ public function convertFromJson(string $json): array return $result; } + + private function attachmentsFromJson(mixed $attachments): MailAttachments + { + if (is_string($attachments)) { + return MailAttachments::fromBackgroundTask($attachments); + } + + if (is_array($attachments)) { + return MailAttachments::fromLegacyFilenames($attachments); + } + + return MailAttachments::empty(); + } } diff --git a/components/ILIAS/Mail/classes/Service/MailService.php b/components/ILIAS/Mail/classes/Service/MailService.php index 844a38ecde5f..472efa94956e 100755 --- a/components/ILIAS/Mail/classes/Service/MailService.php +++ b/components/ILIAS/Mail/classes/Service/MailService.php @@ -70,7 +70,7 @@ public static function init(Container $container): void }; $container['mail.mime.transport.factory'] = static function (Container $c): ilMailMimeTransportFactory { - return new ilMailMimeTransportFactory($c->settings(), $c->event()); + return new ilMailMimeTransportFactory($c->settings(), $c->event(), $c->resourceStorage()); }; $container['mail.mime.sender.factory'] = static function (Container $c): ilMailMimeSenderFactory { diff --git a/components/ILIAS/Mail/classes/Setup/class.ilMailSetupAgent.php b/components/ILIAS/Mail/classes/Setup/class.ilMailSetupAgent.php index c0c184280aa5..6fcca805e8c4 100755 --- a/components/ILIAS/Mail/classes/Setup/class.ilMailSetupAgent.php +++ b/components/ILIAS/Mail/classes/Setup/class.ilMailSetupAgent.php @@ -22,7 +22,9 @@ use ILIAS\Refinery; use ILIAS\Setup\ObjectiveCollection; use ILIAS\Mail\Setup\MailDBUpdateSteps11; +use ILIAS\Mail\Setup\Database\V12\MailDBUpdateSteps12; use ILIAS\Mail\Setup\Migration\MailOutboxMigration; +use ILIAS\Mail\Setup\Migration\MigrateMailAttachmentsToIRSS; class ilMailSetupAgent implements Setup\Agent { @@ -53,6 +55,7 @@ public function getUpdateObjective(?Setup\Config $config = null): Setup\Objectiv true, new ilDatabaseUpdateStepsExecutedObjective(new ilMailDatabaseUpdateSteps()), new ilDatabaseUpdateStepsExecutedObjective(new MailDBUpdateSteps11()), + new ilDatabaseUpdateStepsExecutedObjective(new MailDBUpdateSteps12()), ); } @@ -68,13 +71,15 @@ public function getStatusObjective(Setup\Metrics\Storage $storage): Setup\Object true, new ilDatabaseUpdateStepsMetricsCollectedObjective($storage, new ilMailDatabaseUpdateSteps()), new ilDatabaseUpdateStepsMetricsCollectedObjective($storage, new MailDBUpdateSteps11()), + new ilDatabaseUpdateStepsMetricsCollectedObjective($storage, new MailDBUpdateSteps12()), ); } public function getMigrations(): array { return [ - new MailOutboxMigration() + new MailOutboxMigration(), + new MigrateMailAttachmentsToIRSS(), ]; } } diff --git a/components/ILIAS/Mail/classes/class.ilAccountMail.php b/components/ILIAS/Mail/classes/class.ilAccountMail.php index c0fc6f57141f..b288f3bfa47d 100755 --- a/components/ILIAS/Mail/classes/class.ilAccountMail.php +++ b/components/ILIAS/Mail/classes/class.ilAccountMail.php @@ -23,6 +23,7 @@ use ILIAS\User\Settings\NewAccountMail\Repository as NewAccountMailRepository; use ILIAS\User\Settings\NewAccountMail\Mail as NewAccountMail; use ILIAS\ResourceStorage\Services as ResourceStorage; +use ILIAS\ResourceStorage\Identification\ResourceIdentification; class ilAccountMail { @@ -169,9 +170,17 @@ public function send(): bool $mail_body .= $tmp_lang->txt('reg_mail_body_text3') . "\n\r"; $mail_body .= $user->getProfileAsString($tmp_lang); } else { - $attachment = $amail->getAttachment($this->irss); - if ($attachment !== null) { - $mmail->Attach($attachment->getPath(), '', 'attachment', $attachment->getFilename()); + if ($amail->getAttachmentRid() !== null) { + $rid = $this->irss->manage()->find($amail->getAttachmentRid()); + if ($rid instanceof ResourceIdentification) { + $filename = $this->irss->manage()->getCurrentRevision($rid)->getInformation()->getTitle(); + $mmail->AttachResource($rid, $filename, '', 'attachment'); + } + } else { + $attachment = $amail->getAttachment($this->irss); + if ($attachment !== null && is_file($attachment->getPath())) { + $mmail->Attach($attachment->getPath(), '', 'attachment', $attachment->getFilename()); + } } // replace placeholders diff --git a/components/ILIAS/Mail/classes/class.ilFileDataMail.php b/components/ILIAS/Mail/classes/class.ilFileDataMail.php index ba439f571bc2..854b64df2a43 100755 --- a/components/ILIAS/Mail/classes/class.ilFileDataMail.php +++ b/components/ILIAS/Mail/classes/class.ilFileDataMail.php @@ -19,16 +19,34 @@ declare(strict_types=1); use ILIAS\Filesystem\Filesystem; +use ILIAS\ResourceStorage\Services; +use ILIAS\Filesystem\Stream\Streams; use ILIAS\FileUpload\DTO\UploadResult; +use ILIAS\Mail\Attachments\MailAttachments; +use ILIAS\FileDelivery\Delivery as FileDelivery; +use ILIAS\ResourceStorage\Collection\ResourceCollection; +use ILIAS\ResourceStorage\Information\FileInformation; +use ILIAS\ResourceStorage\Identification\ResourceIdentification; +use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; +/** + * Every resource of the mail stakeholder belongs to exactly one collection (pool, compose stage, + * draft, scheduled or sent mail). Collections are copied when they change hands, so releasing a + * collection that no mail, stage or pool references removes its resources as well. + */ class ilFileDataMail extends ilFileData { + public const string POOL_RCID_PREF_KEY = 'mail_attachment_pool_rcid'; + public const string LEGACY_POOL_ITEM_PREFIX = 'legacy:'; + public string $mail_path; protected int $mail_max_upload_file_size; protected Filesystem $tmp_directory; protected Filesystem $storage_directory; protected ilDBInterface $db; protected ILIAS $ilias; + private readonly Services $irss; + private readonly ilMailAttachmentStakeholder $stakeholder; public function __construct(public int $user_id = 0) { @@ -43,23 +61,14 @@ public function __construct(public int $user_id = 0) $this->db = $DIC->database(); $this->tmp_directory = $DIC->filesystem()->temp(); $this->storage_directory = $DIC->filesystem()->storage(); + $this->irss = $DIC->resourceStorage(); + $this->stakeholder = new ilMailAttachmentStakeholder(); + $this->stakeholder->setOwner($this->user_id); $this->checkReadWrite(); $this->initAttachmentMaxUploadSize(); } - public function initDirectory(): bool - { - if (is_writable($this->getPath()) - && mkdir($this->getPath() . '/' . MAILPATH) - && chmod($this->getPath() . '/' . MAILPATH, 0755)) { - $this->mail_path = $this->getPath() . '/' . MAILPATH; - return true; - } - - return false; - } - public function getUploadLimit(): int { return $this->mail_max_upload_file_size; @@ -86,11 +95,27 @@ public function getAbsoluteAttachmentPoolPathPrefix(): string } /** - * @return array{path: string, filename: string} An array containing 'path' and 'filename' for the passed MD5 hash + * @return array{path: string, filename: string, rcid?: ResourceCollectionIdentification, md5?: string} An array containing 'path' and 'filename' for the passed MD5 hash * @throws OutOfBoundsException */ public function getAttachmentPathAndFilenameByMd5Hash(string $md5FileHash, int $mail_id): array { + $rcid = $this->getRcidForMailOfUser($mail_id); + if ($rcid !== null) { + $resource_identification = $this->getResourceIdByHash($rcid, $md5FileHash); + if ($resource_identification === null) { + throw new OutOfBoundsException(); + } + $info = $this->irss->manage()->getCurrentRevision($resource_identification)->getInformation(); + + return [ + 'path' => '', + 'filename' => $info->getTitle(), + 'rcid' => $rcid, + 'md5' => $md5FileHash, + ]; + } + $res = $this->db->queryF( ' SELECT mail_attachment.path @@ -125,7 +150,6 @@ public function getAttachmentPathAndFilenameByMd5Hash(string $md5FileHash, int $ throw new OutOfBoundsException(); } - private function getAttachmentPathByMailId(int $mail_id): string { $query = $this->db->query( @@ -139,30 +163,9 @@ private function getAttachmentPathByMailId(int $mail_id): string return ''; } - public function getAttachmentPath(string $a_filename, int $a_mail_id): string - { - $path = $this->getMailPath() . '/' . $this->getAttachmentPathByMailId($a_mail_id) . '/' . $a_filename; - - if (is_readable($path)) { - return $path; - } - - return ''; - } - - /** - * @param list $a_attachments - */ - public function adoptAttachments(array $a_attachments, int $a_mail_id): string + public function hasLegacyAttachmentDirectory(int $mail_id): bool { - foreach ($a_attachments as $file) { - $path = $this->getAttachmentPath($file, $a_mail_id); - if (!copy($path, $this->getMailPath() . '/' . $this->user_id . '_' . $file)) { - return 'ERROR: ' . $this->getMailPath() . '/' . $this->user_id . '_' . $file . ' cannot be created'; - } - } - - return ''; + return $this->getAttachmentPathByMailId($mail_id) !== ''; } public function checkReadWrite(): bool @@ -180,14 +183,6 @@ public function checkReadWrite(): bool return false; } - /** - * @return list - */ - public function getUserFilesData(): array - { - return $this->getUnsentFiles(); - } - /** * @return list */ @@ -215,93 +210,373 @@ private function getUnsentFiles(): array return $files; } - public function storeAsAttachment(string $a_filename, string $a_content): string + public function uploadToPool(UploadResult $result): ResourceIdentification { - if (strlen($a_content) >= $this->getUploadLimit()) { - throw new DomainException( - sprintf( - 'Mail upload limit reached for user with id %s', - $this->user_id - ) + $rid = $this->uploadToIrss($result); + + return $this->addResourceToPool($rid); + } + + /** + * Adds a resource to the user's pool. A pool file with the same name is replaced, + * like the legacy pool overwrote files with the same name. + */ + private function addResourceToPool(ResourceIdentification $rid): ResourceIdentification + { + $pool_rcid = $this->resolveUserPoolRcid(); + if ($pool_rcid !== null) { + $existing = $this->resourceIdByHashInCollection( + $this->getCollection($pool_rcid), + $this->poolResourceHash($rid) ); + if ($existing !== null && $existing->serialize() !== $rid->serialize()) { + $this->removeFromPool($existing); + } } - $name = ilFileUtils::_sanitizeFilemame($a_filename); - $this->rotateFiles($this->getMailPath() . '/' . $this->user_id . '_' . $name); + $updated_rcid = $this->adoptPoolResourcesToCollection($pool_rcid, [$rid]); + if ($updated_rcid === null) { + $this->deleteResource($rid); + throw new RuntimeException('Could not store mail attachment in pool.'); + } - $abs_path = $this->getMailPath() . '/' . $this->user_id . '_' . $name; + if ($pool_rcid === null || $pool_rcid->serialize() !== $updated_rcid->serialize()) { + $this->persistUserPoolRcid($updated_rcid); + } - $fp = fopen($abs_path, 'wb+'); - if (!is_resource($fp)) { - throw new RuntimeException( - sprintf( - 'Could not read file: %s', - $abs_path - ) - ); + return $rid; + } + + public function getUserPoolRcid(): ResourceCollectionIdentification + { + $pool_rcid = $this->resolveUserPoolRcid(); + if ($pool_rcid === null) { + throw new RuntimeException('Mail attachment pool does not exist.'); } - if (fwrite($fp, $a_content) === false) { - fclose($fp); - throw new RuntimeException( - sprintf( - 'Could not write file: %s', - $abs_path - ) - ); + return $pool_rcid; + } + + private function resolveUserPoolRcid(): ?ResourceCollectionIdentification + { + $pref = ilObjUser::_lookupPref($this->user_id, self::POOL_RCID_PREF_KEY); + if (!is_string($pref) || $pref === '') { + return null; } - fclose($fp); + $rcid = new ResourceCollectionIdentification($pref); + if (!$this->collectionIsKnown($rcid)) { + return null; + } - return $name; + return $rcid; } - public function storeUploadedFile(UploadResult $result): string + private function resolveStageRcid(): ?ResourceCollectionIdentification { - $filename = ilFileUtils::_sanitizeFilemame( - $result->getName() + $res = $this->db->queryF( + 'SELECT attachments FROM mail_saved WHERE user_id = %s', + [ilDBConstants::T_INTEGER], + [$this->user_id] ); + $row = $this->db->fetchAssoc($res); + if (!is_array($row)) { + return null; + } + + $stage = MailAttachments::fromDb(is_string($row['attachments'] ?? null) ? $row['attachments'] : null); + if ($stage === null || !$stage->isIrss()) { + return null; + } - $this->rotateFiles($this->getMailPath() . '/' . $this->user_id . '_' . $filename); + return $stage->rcid(); + } - ilFileUtils::moveUploadedFile( - $result->getPath(), - $filename, - $this->getMailPath() . '/' . $this->user_id . '_' . $filename - ); + private function collectionContainsResource( + ?ResourceCollectionIdentification $rcid, + ResourceIdentification $identification + ): bool { + if ($rcid === null) { + return false; + } - return $filename; + return in_array($identification->serialize(), $this->getAssignedRidStrings($rcid), true); } - public function copyAttachmentFile(string $a_abs_path, string $a_new_name): bool + /** + * @return \Generator + */ + private function iteratePoolResourceIdentifications(ResourceCollectionIdentification $pool_rcid): Generator { - @copy($a_abs_path, $this->getMailPath() . '/' . $this->user_id . '_' . $a_new_name); + foreach ($this->getAssignedRidStrings($pool_rcid) as $rid_string) { + $resource_identification = $this->irss->manage()->find($rid_string); + if ($resource_identification !== null) { + yield $resource_identification; + } + } + } - return true; + /** + * @return list + */ + public function getUserPoolFilesData(): array + { + $files = []; + + $pool_rcid = $this->resolveUserPoolRcid(); + if ($pool_rcid !== null) { + foreach ($this->iteratePoolResourceIdentifications($pool_rcid) as $resource_identification) { + $revision = $this->irss->manage()->getCurrentRevision($resource_identification); + $info = $revision->getInformation(); + $files[] = [ + 'rid' => $resource_identification->serialize(), + 'name' => $info->getTitle(), + 'size' => $info->getSize(), + 'ctime' => $info->getCreationDate()->getTimestamp(), + 'md5' => $revision->getTitle(), + ]; + } + } + + foreach ($this->getUnsentFiles() as $file) { + $files[] = [ + 'rid' => self::LEGACY_POOL_ITEM_PREFIX . $file['name'], + 'name' => $file['name'], + 'size' => (int) $file['size'], + 'ctime' => (int) $file['ctime'], + 'md5' => md5($file['name']), + ]; + } + + return $files; + } + + public function isLegacyPoolItemIdentifier(string $identifier): bool + { + return str_starts_with($identifier, self::LEGACY_POOL_ITEM_PREFIX); + } + + public function legacyPoolFilenameFromIdentifier(string $identifier): string + { + return substr($identifier, strlen(self::LEGACY_POOL_ITEM_PREFIX)); } - private function rotateFiles(string $a_path): bool + public function migrateLegacyPoolFilenameToResource(string $filename): ?ResourceIdentification { - if (is_file($a_path)) { - $this->rotateFiles($a_path . '.old'); - return ilFileUtils::rename($a_path, $a_path . '.old'); + $path = $this->getAbsoluteAttachmentPoolPathByFilename($filename); + if (!is_file($path)) { + return null; } - return true; + $rid = $this->streamFromPath($path, md5($filename)); + $this->applyResourceFileName($rid, $filename); + try { + $rid = $this->addResourceToPool($rid); + } catch (RuntimeException) { + return null; + } + + $this->unlinkFile($filename); + + return $rid; } /** - * @param list $a_filenames + * @param list $identifiers + * @return list */ - public function unlinkFiles(array $a_filenames): string + public function resolvePoolIdentifiersToResources(array $identifiers): array { - foreach ($a_filenames as $file) { - if (!$this->unlinkFile($file)) { - return $file; + $resource_identifications = []; + + foreach ($identifiers as $identifier) { + if ($this->isLegacyPoolItemIdentifier($identifier)) { + $resource_identification = $this->migrateLegacyPoolFilenameToResource( + $this->legacyPoolFilenameFromIdentifier($identifier) + ); + } else { + $resource_identification = $this->irss->manage()->find($identifier); + if ($resource_identification !== null && !$this->isInUserPool($resource_identification)) { + $resource_identification = null; + } + } + + if ($resource_identification !== null) { + $resource_identifications[] = $resource_identification; } } - return ''; + return $resource_identifications; + } + + public function isInUserPool(ResourceIdentification $identification): bool + { + return $this->collectionContainsResource($this->resolveUserPoolRcid(), $identification); + } + + /** + * @param list $resource_identifications + */ + public function adoptPoolResourcesToCollection( + ?ResourceCollectionIdentification $rcid, + array $resource_identifications + ): ?ResourceCollectionIdentification { + if ($resource_identifications === []) { + return null; + } + + $collection = $this->loadPoolCollectionForMutation($rcid); + $added = false; + + foreach ($resource_identifications as $resource_identification) { + $hash = $this->irss->manage()->getCurrentRevision($resource_identification)->getTitle(); + if ($this->resourceIdByHashInCollection($collection, $hash) !== null) { + continue; + } + + $collection->add($resource_identification); + $added = true; + } + + if (!$added) { + return null; + } + + $this->irss->collection()->store($collection); + + return $collection->getIdentification(); + } + + public function removeFromPool(ResourceIdentification $rid): void + { + $pool_rcid = $this->resolveUserPoolRcid(); + if ($pool_rcid === null) { + return; + } + + $collection = $this->getCollection($pool_rcid); + if (!$collection->isIn($rid)) { + return; + } + + $collection->remove($rid); + $this->irss->collection()->store($collection); + + $this->deleteResource($rid); + } + + public function poolResourceHash(ResourceIdentification $rid): string + { + return $this->irss->manage()->getCurrentRevision($rid)->getTitle(); + } + + private function persistUserPoolRcid(ResourceCollectionIdentification $rcid): void + { + $this->db->replace( + 'usr_pref', + [ + 'usr_id' => [ilDBConstants::T_INTEGER, $this->user_id], + 'keyword' => [ilDBConstants::T_TEXT, self::POOL_RCID_PREF_KEY], + ], + [ + 'value' => [ilDBConstants::T_TEXT, $rcid->serialize()], + ] + ); + } + + /** + * A Mail collection is referenced as long as a mail (sent, received, draft, outbox), + * a compose stage or a user's attachment pool points to it. + */ + public function isCollectionReferenced(ResourceCollectionIdentification $rcid): bool + { + $serialized = $rcid->serialize(); + + $this->db->setLimit(1, 0); + $res = $this->db->queryF( + 'SELECT 1 FROM mail_attachment WHERE rcid = %s', + [ilDBConstants::T_TEXT], + [$serialized] + ); + if ($this->db->numRows($res) > 0) { + return true; + } + + $this->db->setLimit(1, 0); + $res = $this->db->queryF( + 'SELECT 1 FROM mail_saved WHERE attachments = %s', + [ilDBConstants::T_TEXT], + [$serialized] + ); + if ($this->db->numRows($res) > 0) { + return true; + } + + $res = $this->db->queryF( + 'SELECT value FROM usr_pref WHERE usr_id = %s AND keyword = %s', + [ilDBConstants::T_INTEGER, ilDBConstants::T_TEXT], + [$this->user_id, self::POOL_RCID_PREF_KEY] + ); + $row = $this->db->fetchAssoc($res); + + return is_array($row) && (string) $row['value'] === $serialized; + } + + /** + * Removes a Mail collection nobody references anymore, together with its resources. + * Collections containing resources of other components are never touched. + */ + public function releaseCollectionIfUnreferenced(ResourceCollectionIdentification $rcid): void + { + $serialized = $rcid->serialize(); + if ($serialized === '' || $serialized === '-') { + return; + } + + if ($this->isCollectionReferenced($rcid) || !$this->collectionIsKnown($rcid)) { + return; + } + + foreach ($this->getRidsFromCollection($rcid) as $rid_string) { + $resource_identification = $this->irss->manage()->find($rid_string); + if ($resource_identification !== null + && !$this->resourceHasOnlyMailStakeholder($resource_identification)) { + return; + } + } + + $this->removeCollection($rcid, true); + } + + private function deleteResource(ResourceIdentification $rid): void + { + $this->irss->manage()->remove($rid, $this->stakeholder); + } + + /** + * True if the collection can be handed over to delivered mails without copying it: + * it is Mail-owned and nobody references it. + */ + public function isCollectionExclusivelyOwnedByMail(ResourceCollectionIdentification $rcid): bool + { + if ($this->isCollectionReferenced($rcid) || !$this->collectionIsKnown($rcid)) { + return false; + } + + $rid_strings = $this->getRidsFromCollection($rcid); + if ($rid_strings === []) { + return false; + } + + foreach ($rid_strings as $rid_string) { + $resource_identification = $this->irss->manage()->find($rid_string); + if ($resource_identification === null + || !$this->resourceHasOnlyMailStakeholder($resource_identification)) { + return false; + } + } + + return true; } public function unlinkFile(string $a_filename): bool @@ -340,92 +615,127 @@ private function isValidAttachmentPoolFilename(string $filename): bool } /** - * Saves all attachment files in a specific mail directory .../mail//mail__/... - * @param list $a_attachments + * @param list $a_files */ - public function saveFiles(int $a_mail_id, array $a_attachments): void + public function checkFilesExist(array $a_files): bool { - if (!is_numeric($a_mail_id) || $a_mail_id < 1) { - throw new InvalidArgumentException('The passed mail_id must be a valid integer!'); + if ($a_files !== []) { + foreach ($a_files as $file) { + if (!is_file($this->mail_path . '/' . $this->user_id . '_' . $file)) { + return false; + } + } } - foreach ($a_attachments as $attachment) { - $this->saveFile($a_mail_id, $attachment); - } + return true; } - public static function getStorage(int $a_mail_id, int $a_usr_id): ilFSStorageMail - { - static $fsstorage_cache = []; - - $fsstorage_cache[$a_mail_id][$a_usr_id] = new ilFSStorageMail($a_mail_id, $a_usr_id); - - return $fsstorage_cache[$a_mail_id][$a_usr_id]; + public function assignAttachmentsToCollection( + int $mail_id, + ResourceCollectionIdentification $rcid + ): void { + $this->db->manipulateF( + 'INSERT INTO mail_attachment (mail_id, path, rcid) VALUES (%s, %s, %s)', + [ilDBConstants::T_INTEGER, ilDBConstants::T_TEXT, ilDBConstants::T_TEXT], + [$mail_id, '', $rcid->serialize()] + ); } /** - * Save attachment file in a specific mail directory .../mail//mail__/... + * Points a mail (draft, outbox) to the passed collection (or to none). A previously referenced + * collection is released if nothing else references it anymore. */ - public function saveFile(int $a_mail_id, string $a_attachment): bool + public function replaceMailAttachmentCollection(int $mail_id, ?ResourceCollectionIdentification $rcid): void { - $storage = self::getStorage($a_mail_id, $this->user_id); - $storage->create(); - $storage_directory = $storage->getAbsolutePath(); + if ($this->hasLegacyAttachmentDirectory($mail_id)) { + return; + } - if (!is_dir($storage_directory)) { - return false; + $previous_rcid = $this->getRcidForMail($mail_id); + if ($previous_rcid !== null && $rcid !== null && $previous_rcid->serialize() === $rcid->serialize()) { + return; } - return copy( - $this->mail_path . '/' . $this->user_id . '_' . $a_attachment, - $storage_directory . '/' . $a_attachment + $this->db->manipulateF( + 'DELETE FROM mail_attachment WHERE mail_id = %s AND (path IS NULL OR path = %s)', + [ilDBConstants::T_INTEGER, ilDBConstants::T_TEXT], + [$mail_id, ''] ); + + if ($rcid !== null) { + $this->assignAttachmentsToCollection($mail_id, $rcid); + } + + if ($previous_rcid !== null) { + $this->releaseCollectionIfUnreferenced($previous_rcid); + } } - /** - * @param list $a_files - */ - public function checkFilesExist(array $a_files): bool + private function getRcidForMailOfUser(int $mail_id): ?ResourceCollectionIdentification { - if ($a_files !== []) { - foreach ($a_files as $file) { - if (!is_file($this->mail_path . '/' . $this->user_id . '_' . $file)) { - return false; - } - } - } + $res = $this->db->queryF( + 'SELECT mail_attachment.rcid FROM mail_attachment + INNER JOIN mail ON mail.mail_id = mail_attachment.mail_id + WHERE mail_attachment.mail_id = %s AND mail.user_id = %s', + [ilDBConstants::T_INTEGER, ilDBConstants::T_INTEGER], + [$mail_id, $this->user_id] + ); + $row = $this->db->fetchAssoc($res); + $rcid = is_array($row) ? (string) ($row['rcid'] ?? '') : ''; - return true; + return $rcid !== '' && $rcid !== '-' ? new ResourceCollectionIdentification($rcid) : null; } - public function assignAttachmentsToDirectory(int $a_mail_id, int $a_sent_mail_id): void + public function getRcidForMail(int $mail_id): ?ResourceCollectionIdentification { - $storage = self::getStorage($a_sent_mail_id, $this->user_id); - $this->db->manipulateF( - ' - INSERT INTO mail_attachment - ( mail_id, path) VALUES (%s, %s)', - ['integer', 'text'], - [$a_mail_id, $storage->getRelativePathExMailDirectory()] + $res = $this->db->queryF( + 'SELECT rcid FROM mail_attachment WHERE mail_id = %s', + ['integer'], + [$mail_id] ); + + if ($this->db->numRows($res) !== 1) { + return null; + } + + $row = $this->db->fetchAssoc($res); + $rcid = (string) ($row['rcid'] ?? ''); + if ($rcid === '' || $rcid === '-') { + return null; + } + + return new ResourceCollectionIdentification($rcid); } public function deassignAttachmentFromDirectory(int $a_mail_id): bool { $res = $this->db->query( - 'SELECT path FROM mail_attachment WHERE mail_id = ' . $this->db->quote($a_mail_id, 'integer') + 'SELECT path, rcid FROM mail_attachment WHERE mail_id = ' . $this->db->quote($a_mail_id, 'integer') ); $path = ''; + $rcid = ''; while ($row = $this->db->fetchObject($res)) { $path = (string) $row->path; + $rcid = (string) ($row->rcid ?? ''); + } + + if ($rcid !== '' && $rcid !== '-') { + $this->db->manipulateF( + 'DELETE FROM mail_attachment WHERE mail_id = %s', + ['integer'], + [$a_mail_id] + ); + $this->releaseCollectionIfUnreferenced(new ResourceCollectionIdentification($rcid)); + + return true; } if ($path !== '') { $res = $this->db->query( 'SELECT COUNT(mail_id) count_mail_id FROM mail_attachment WHERE path = ' . $this->db->quote($path, 'text') - ) ; + ); $cnt_mail_id = 0; while ($row = $this->db->fetchObject($res)) { @@ -461,7 +771,7 @@ protected function initAttachmentMaxUploadSize(): void // get the value for the maximal post data from the php.ini (if available) $pms = ini_get('post_max_size'); - //convert from short-string representation to "real" bytes + // convert from short-string representation to "real" bytes $multiplier_a = ['K' => 1024, 'M' => 1024 * 1024, 'G' => 1024 * 1024 * 1024]; $umf_parts = preg_split( @@ -496,6 +806,35 @@ protected function initAttachmentMaxUploadSize(): void public function onUserDelete(): void { + $rcids_to_release = []; + + $pool_rcid = $this->resolveUserPoolRcid(); + if ($pool_rcid !== null) { + $rcids_to_release[$pool_rcid->serialize()] = $pool_rcid; + } + $this->db->manipulateF( + 'DELETE FROM usr_pref WHERE usr_id = %s AND keyword = %s', + [ilDBConstants::T_INTEGER, ilDBConstants::T_TEXT], + [$this->user_id, self::POOL_RCID_PREF_KEY] + ); + + $res = $this->db->queryF( + 'SELECT attachments FROM mail_saved WHERE user_id = %s', + [ilDBConstants::T_INTEGER], + [$this->user_id] + ); + while ($row = $this->db->fetchAssoc($res)) { + $stage = MailAttachments::fromDb(is_string($row['attachments']) ? $row['attachments'] : null); + if ($stage !== null && $stage->isIrss()) { + $rcids_to_release[$stage->rcid()->serialize()] = $stage->rcid(); + } + } + $this->db->manipulateF( + 'UPDATE mail_saved SET attachments = NULL WHERE user_id = %s', + [ilDBConstants::T_INTEGER], + [$this->user_id] + ); + // Delete uploaded mail files which are not attached to any message try { $iter = new RegexIterator( @@ -518,6 +857,7 @@ public function onUserDelete(): void INNER JOIN mail ON mail.mail_id = ma1.mail_id WHERE mail.user_id = %s + AND ma1.path IS NOT NULL AND ma1.path != "" AND (SELECT COUNT(tmp.path) FROM mail_attachment tmp WHERE tmp.path = ma1.path) = 1 '; $res = $this->db->queryF( @@ -545,6 +885,21 @@ public function onUserDelete(): void } } + $rcid_res = $this->db->queryF( + ' + SELECT DISTINCT(ma1.rcid) + FROM mail_attachment ma1 + INNER JOIN mail ON mail.mail_id = ma1.mail_id + WHERE mail.user_id = %s + AND ma1.rcid IS NOT NULL AND ma1.rcid != "" AND ma1.rcid != "-" + ', + [ilDBConstants::T_INTEGER], + [$this->user_id] + ); + while ($row = $this->db->fetchAssoc($rcid_res)) { + $rcids_to_release[(string) $row['rcid']] = new ResourceCollectionIdentification((string) $row['rcid']); + } + // Delete each mail attachment rows assigned to a message of the deleted user. $this->db->manipulateF( ' @@ -559,6 +914,14 @@ public function onUserDelete(): void ['integer'], [$this->user_id] ); + + // Collections shared with other users' mails stay referenced and are kept + foreach ($rcids_to_release as $rcid) { + try { + $this->releaseCollectionIfUnreferenced($rcid); + } catch (Throwable) { + } + } } /** @@ -570,6 +933,13 @@ public function deliverAttachmentsAsZip( array $files = [], bool $is_draft = false ): void { + $rcid = $this->getRcidForMail($mail_id); + if ($rcid !== null && !$is_draft) { + $this->deliverCollectionAsZip($rcid, $basename); + + return; + } + $path = ''; if (!$is_draft) { $path = $this->getAttachmentPathByMailId($mail_id); @@ -623,4 +993,612 @@ public function deliverAttachmentsAsZip( ilFileUtils::getValidFilename($download_filename . '.zip') ); } + + public function getStakeholder(): ilMailAttachmentStakeholder + { + return $this->stakeholder; + } + + /** + * The compose form only shows files of the user's compose stage + */ + public function canUseResourceInCompose(ResourceIdentification $identification): bool + { + return $this->collectionContainsResource($this->resolveStageRcid(), $identification); + } + + /** + * Adds a just uploaded resource to the compose stage. The returned collection must be + * persisted as stage if it was created. + */ + public function addResourceToStage(ResourceIdentification $identification): ResourceCollectionIdentification + { + $stage_rcid = $this->resolveStageRcid(); + $collection = $stage_rcid !== null && $this->collectionIsKnown($stage_rcid) + ? $this->getCollection($stage_rcid) + : $this->irss->collection()->get($this->irss->collection()->id(null, $this->user_id)); + + $collection->add($identification); + $this->irss->collection()->store($collection); + + return $collection->getIdentification(); + } + + public function removeFromStage(ResourceIdentification $identification): bool + { + $stage_rcid = $this->resolveStageRcid(); + if (!$this->collectionContainsResource($stage_rcid, $identification)) { + return false; + } + + $collection = $this->getCollection($stage_rcid); + $collection->remove($identification); + $this->irss->collection()->store($collection); + $this->deleteResource($identification); + + return true; + } + + /** + * Removes all files from the compose stage which are not part of the submitted form. + * + * @param list $rid_strings + */ + public function restrictStageTo(array $rid_strings): ?ResourceCollectionIdentification + { + $stage_rcid = $this->resolveStageRcid(); + if ($stage_rcid === null || !$this->collectionIsKnown($stage_rcid)) { + return null; + } + + $kept = array_flip($rid_strings); + $collection = $this->getCollection($stage_rcid); + $removed = []; + foreach ($collection->getResourceIdentifications() as $identification) { + if (!isset($kept[$identification->serialize()])) { + $collection->remove($identification); + $removed[] = $identification; + } + } + + if ($removed !== []) { + $this->irss->collection()->store($collection); + foreach ($removed as $identification) { + $this->deleteResource($identification); + } + } + + return $collection->count() > 0 ? $collection->getIdentification() : null; + } + + public function streamFromPath(string $absolute_path, ?string $revision_title = null): ResourceIdentification + { + $stream = Streams::ofResource(fopen($absolute_path, 'rb')); + + return $this->irss->manage()->stream( + $stream, + $this->stakeholder, + $revision_title ?? md5(basename($absolute_path)) + ); + } + + public function uploadToIrss(UploadResult $result): ResourceIdentification + { + return $this->irss->manage()->upload( + $result, + $this->stakeholder, + md5($result->getName()) + ); + } + + /** + * @param list $resource_identifications Resources not yet part of any collection + */ + private function createCollectionFromResourceIdentifications( + array $resource_identifications + ): ResourceCollectionIdentification { + $rcid = $this->irss->collection()->id(null, $this->user_id); + $collection = $this->irss->collection()->get($rcid); + foreach ($resource_identifications as $resource_identification) { + $collection->add($resource_identification); + } + $this->irss->collection()->store($collection); + + return $collection->getIdentification(); + } + + /** + * @param list $filenames Pool filenames without user prefix + */ + public function createCollectionFromPoolFilenames(array $filenames): ?ResourceCollectionIdentification + { + return $this->adoptPoolFilenamesToCollection(null, $filenames); + } + + public function migrateLegacyPoolAttachments(MailAttachments $attachments): ?MailAttachments + { + if (!$attachments->isLegacy()) { + return $attachments; + } + + if (!$this->checkFilesExist($attachments->legacyFilenames())) { + return null; + } + + $rcid = $this->createCollectionFromPoolFilenames($attachments->legacyFilenames()); + + return $rcid !== null ? MailAttachments::fromIrss($rcid) : null; + } + + /** + * @param list $filenames Pool filenames without user prefix + */ + public function adoptPoolFilenamesToCollection( + ?ResourceCollectionIdentification $rcid, + array $filenames + ): ?ResourceCollectionIdentification { + if ($filenames === []) { + return null; + } + + if ($rcid !== null && $this->irss->collection()->exists($rcid->serialize())) { + $collection_id = $this->irss->collection()->id($rcid->serialize(), $this->user_id); + } else { + $collection_id = $this->irss->collection()->id(null, $this->user_id); + } + + $collection = $this->irss->collection()->get($collection_id); + $added = false; + + foreach ($filenames as $filename) { + $path = $this->getAbsoluteAttachmentPoolPathByFilename($filename); + if (!is_file($path)) { + continue; + } + + $hash = md5($filename); + if ($this->resourceIdByHashInCollection($collection, $hash) !== null) { + continue; + } + + $resource_identification = $this->streamFromPath($path, $hash); + $this->applyResourceFileName($resource_identification, $filename); + $collection->add($resource_identification); + $added = true; + } + + if (!$added) { + return null; + } + + $this->irss->collection()->store($collection); + + return $collection->getIdentification(); + } + + /** + * Creates a stage collection from a not yet migrated (directory based) mail, e.g. when forwarding it. + * + * @param list $filenames + */ + public function createStageFromLegacyMailAttachments(int $mail_id, array $filenames): MailAttachments + { + // Without a directory the files are gone, the names must not resolve to the forwarding user's pool + if (!$this->hasLegacyAttachmentDirectory($mail_id)) { + return MailAttachments::empty(); + } + + $resource_identifications = []; + foreach ($filenames as $filename) { + try { + $file = $this->getAttachmentPathAndFilenameByMd5Hash(md5($filename), $mail_id); + } catch (OutOfBoundsException) { + continue; + } + + if (isset($file['rcid']) && $file['rcid'] instanceof ResourceCollectionIdentification) { + $rcid = $this->copyCollection($file['rcid']); + + return $rcid !== null ? MailAttachments::fromIrss($rcid) : MailAttachments::empty(); + } + + if (is_file($file['path'])) { + $resource_identifications[] = $this->streamFromPath($file['path'], md5($file['filename'])); + } + } + + if ($resource_identifications === []) { + return MailAttachments::empty(); + } + + return MailAttachments::fromIrss($this->createCollectionFromResourceIdentifications($resource_identifications)); + } + + public function createCollectionFromContent(string $name, string $content): ?ResourceCollectionIdentification + { + if ($name === '' || $content === '') { + return null; + } + + if (strlen($content) >= $this->getUploadLimit()) { + throw new DomainException( + sprintf( + 'Mail upload limit reached for user with id %s', + $this->user_id + ) + ); + } + + $sanitized_name = ilFileUtils::_sanitizeFilemame($name); + $resource_identification = $this->irss->manage()->stream( + Streams::ofString($content), + $this->stakeholder, + md5($sanitized_name) + ); + $this->applyResourceFileName($resource_identification, $sanitized_name); + + return $this->createCollectionFromResourceIdentifications([$resource_identification]); + } + + /** + * Copies all files of the passed collection (of the mail system or of another component) + * into a new collection of the mail system. The source stays untouched. + */ + public function copyCollection(ResourceCollectionIdentification $source): ?ResourceCollectionIdentification + { + if (!$this->collectionIsKnown($source)) { + return null; + } + + $copies = $this->copyResources(array_values($this->getCollection($source)->getResourceIdentifications())); + if ($copies === []) { + return null; + } + + return $this->createCollectionFromResourceIdentifications($copies); + } + + /** + * Applies a selection of the attachment pool to the compose stage: pool files are matched by their + * hashed name, selected files missing in the stage are copied into it, deselected ones are removed. + * + * @param list $selected_pool_resources + */ + public function applyPoolSelectionToStage(array $selected_pool_resources): ?ResourceCollectionIdentification + { + $pool_hashes = []; + foreach ($this->getUserPoolResources() as $pool_resource) { + $pool_hashes[$this->poolResourceHash($pool_resource)] = true; + } + + $selected = []; + foreach ($selected_pool_resources as $pool_resource) { + $selected[$this->poolResourceHash($pool_resource)] = $pool_resource; + } + + $stage_rcid = $this->resolveStageRcid(); + $collection = $stage_rcid !== null && $this->collectionIsKnown($stage_rcid) + ? $this->getCollection($stage_rcid) + : $this->irss->collection()->get($this->irss->collection()->id(null, $this->user_id)); + + $removed = []; + foreach ($collection->getResourceIdentifications() as $identification) { + $hash = $this->poolResourceHash($identification); + if (isset($pool_hashes[$hash]) && !isset($selected[$hash])) { + $collection->remove($identification); + $removed[] = $identification; + continue; + } + unset($selected[$hash]); + } + + foreach ($this->copyResources(array_values($selected)) as $copy) { + $collection->add($copy); + } + + $this->irss->collection()->store($collection); + foreach ($removed as $identification) { + $this->deleteResource($identification); + } + + return $collection->count() > 0 ? $collection->getIdentification() : null; + } + + /** + * @return list Resource ids of the pool files the compose stage contains a copy of + */ + public function getPoolResourcesInStage(ResourceCollectionIdentification $stage_rcid): array + { + $stage_hashes = []; + foreach ($this->getRidsFromCollection($stage_rcid) as $rid_string) { + $identification = $this->irss->manage()->find($rid_string); + if ($identification !== null) { + $stage_hashes[$this->poolResourceHash($identification)] = true; + } + } + + $pool_resources = []; + foreach ($this->getUserPoolResources() as $pool_resource) { + if (isset($stage_hashes[$this->poolResourceHash($pool_resource)])) { + $pool_resources[] = $pool_resource->serialize(); + } + } + + return $pool_resources; + } + + /** + * @return list + */ + private function getUserPoolResources(): array + { + $pool_rcid = $this->resolveUserPoolRcid(); + if ($pool_rcid === null) { + return []; + } + + return iterator_to_array($this->iteratePoolResourceIdentifications($pool_rcid), false); + } + + /** + * @param list $resource_identifications + * @return list + */ + private function copyResources(array $resource_identifications): array + { + $copies = []; + foreach ($resource_identifications as $source) { + $revision = $this->irss->manage()->getCurrentRevision($source); + $stream = $this->irss->consume()->stream($source); + $copy = $this->irss->manage()->stream( + $stream->getStream(), + $this->stakeholder, + $revision->getTitle() + ); + $this->applyResourceFileName($copy, $revision->getInformation()->getTitle()); + $copies[] = $copy; + } + + return $copies; + } + + public function getCollection(ResourceCollectionIdentification $rcid): ResourceCollection + { + if (!$this->collectionIsKnown($rcid)) { + throw new OutOfBoundsException( + sprintf('Mail attachment collection "%s" does not exist.', $rcid->serialize()) + ); + } + + $this->irss->preloadCollections([$rcid->serialize()]); + + return $this->irss->collection()->get($rcid, $this->user_id); + } + + private function loadPoolCollectionForMutation(?ResourceCollectionIdentification $pool_rcid): ResourceCollection + { + if ($pool_rcid !== null && $this->collectionIsKnown($pool_rcid)) { + $this->irss->preloadCollections([$pool_rcid->serialize()]); + + return $this->irss->collection()->get($pool_rcid, $this->user_id); + } + + $collection_id = $this->irss->collection()->id(null, $this->user_id); + + return $this->irss->collection()->get($collection_id, $this->user_id); + } + + /** + * @return list + */ + private function getAssignedRidStrings(ResourceCollectionIdentification $rcid): array + { + if (!$this->collectionIsKnown($rcid)) { + return []; + } + + return array_values(array_map( + static fn(ResourceIdentification $identification): string => $identification->serialize(), + $this->irss->collection()->get($rcid)->getResourceIdentifications() + )); + } + + private function collectionIsKnown(ResourceCollectionIdentification $rcid): bool + { + return $this->irss->collection()->exists($rcid->serialize()); + } + + public function getResourceIdByHash( + ResourceCollectionIdentification $rcid, + string $hash + ): ?ResourceIdentification { + if (!$this->collectionIsKnown($rcid)) { + return null; + } + + // Mails whose column still lists legacy filenames address their files by the hashed name (revision title) + $title_match = null; + foreach ($this->getAssignedRidStrings($rcid) as $rid) { + $resource_identification = $this->irss->manage()->find($rid); + if ($resource_identification === null) { + continue; + } + + if ($this->attachmentHash($resource_identification) === $hash) { + return $resource_identification; + } + + if ($title_match === null + && $this->irss->manage()->getCurrentRevision($resource_identification)->getTitle() === $hash) { + $title_match = $resource_identification; + } + } + + return $title_match; + } + + /** + * Unique per resource: equal file names in one collection must stay distinguishable + */ + private function attachmentHash(ResourceIdentification $identification): string + { + return md5($identification->serialize()); + } + + private function resourceIdByHashInCollection(ResourceCollection $collection, string $hash): ?ResourceIdentification + { + foreach ($collection->getResourceIdentifications() as $resource_identification) { + $revision = $this->irss->manage()->getCurrentRevision($resource_identification); + if ($revision->getTitle() === $hash) { + return $resource_identification; + } + } + + return null; + } + + /** + * @return list + */ + public function getRidsFromCollection(ResourceCollectionIdentification $rcid): array + { + $rids = []; + foreach ($this->getAssignedRidStrings($rcid) as $rid) { + if ($this->irss->manage()->find($rid) !== null) { + $rids[] = $rid; + } + } + + return $rids; + } + + /** + * Keyed by resource id. Equal display titles must not collapse two download hashes. + * + * @return array + */ + public function getAttachmentListing(ResourceCollectionIdentification $rcid): array + { + $files = []; + foreach ($this->getAssignedRidStrings($rcid) as $rid) { + $resource_identification = $this->irss->manage()->find($rid); + if ($resource_identification === null) { + continue; + } + + $revision = $this->irss->manage()->getCurrentRevision($resource_identification); + $info = $revision->getInformation(); + $file_title = $info->getTitle(); + $files[$resource_identification->serialize()] = [ + 'md5' => $this->attachmentHash($resource_identification), + 'name' => $file_title, + 'size' => $info->getSize(), + 'ctime' => $info->getCreationDate()->format('Y-m-d H:i:s'), + ]; + } + + return $files; + } + + public function deliverFile(ResourceCollectionIdentification $rcid, string $md5_hash): void + { + $resource_identification = $this->getResourceIdByHash($rcid, $md5_hash); + if ($resource_identification === null) { + throw new OutOfBoundsException('mail_error_reading_attachment'); + } + + $this->irss->consume()->download($resource_identification)->run(); + } + + public function deliverCollectionAsZip(ResourceCollectionIdentification $rcid, string $zip_basename): void + { + $zip_filename = FileDelivery::returnASCIIFileName($zip_basename . '.zip'); + $this->irss + ->consume() + ->downloadCollection($rcid, $zip_filename) + ->useRevisionTitlesForFileNames(false) + ->run(); + } + + /** + * Removes the collection. Resources are only deleted if explicitly requested; + * prefer releaseCollectionIfUnreferenced() which respects references of mails, stage and pool. + */ + public function removeCollection(ResourceCollectionIdentification $rcid, bool $delete_resources = false): void + { + if (!$this->irss->collection()->exists($rcid->serialize())) { + return; + } + + $this->irss->collection()->remove( + $rcid, + $this->stakeholder, + $delete_resources + ); + } + + /** + * Drops the source collection after it has been delivered. + * Foreign collections (Exercise, Test, …), the user pool and collections still referenced + * by mails or a compose stage stay untouched. + */ + public function releaseSourceCollectionAfterDelivery(ResourceCollectionIdentification $rcid): void + { + $this->releaseCollectionIfUnreferenced($rcid); + } + + private function applyResourceFileName(ResourceIdentification $rid, string $file_name): void + { + if ($file_name === '') { + return; + } + + $revision = $this->irss->manage()->getCurrentRevision($rid); + $information = $revision->getInformation(); + if (!$information instanceof FileInformation) { + return; + } + $information->setTitle($file_name); + $suffix = pathinfo($file_name, PATHINFO_EXTENSION); + if ($suffix !== '') { + $information->setSuffix($suffix); + } + $revision->setInformation($information); + $this->irss->manage()->updateRevision($revision); + } + + private function resourceHasOnlyMailStakeholder(ResourceIdentification $rid): bool + { + $has_mail = false; + foreach ($this->irss->manage()->getResource($rid)->getStakeholders() as $stakeholder) { + if ($stakeholder->getId() !== $this->stakeholder->getId()) { + return false; + } + $has_mail = true; + } + + return $has_mail; + } + + /** + * @return list + */ + public function getIrssMimeAttachments(ResourceCollectionIdentification $rcid): array + { + $attachments = []; + foreach ($this->getAssignedRidStrings($rcid) as $rid) { + $resource_identification = $this->irss->manage()->find($rid); + if ($resource_identification === null) { + continue; + } + + $info = $this->irss->manage()->getCurrentRevision($resource_identification)->getInformation(); + $attachments[] = [ + 'rid' => $rid, + 'name' => $info->getTitle(), + ]; + } + + return $attachments; + } } diff --git a/components/ILIAS/Mail/classes/class.ilMail.php b/components/ILIAS/Mail/classes/class.ilMail.php index 65e5419939c6..0469c0b733af 100755 --- a/components/ILIAS/Mail/classes/class.ilMail.php +++ b/components/ILIAS/Mail/classes/class.ilMail.php @@ -27,11 +27,11 @@ use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; use ILIAS\Mail\Folder\MailScheduleData; use ILIAS\Mail\Message\MailRecordMapper; +use ILIAS\Mail\Attachments\MailAttachments; +use ILIAS\ResourceStorage\Identification\ResourceIdentification; class ilMail { - use FileDataRCHandling; - public const string ILIAS_HOST = 'ilias'; public const string PROP_CONTEXT_SUBJECT_PREFIX = 'subject_prefix'; @@ -44,6 +44,9 @@ class ilMail private bool $save_in_sentbox; private bool $append_installation_signature = false; private bool $append_user_signature = false; + private bool $share_attachments = true; + private bool $attachment_copy_failed = false; + private ?MailAttachments $shared_delivery_attachments = null; private ?string $context_id = null; private array $context_parameters = []; @@ -156,6 +159,51 @@ public function getSaveInSentbox(): bool return $this->save_in_sentbox; } + public function setShareAttachments(bool $share_attachments): void + { + $this->share_attachments = $share_attachments; + } + + private function getDeliveryAttachments(MailAttachments $source): MailAttachments + { + if ($source->isEmpty() || !$source->isIrss()) { + return $source; + } + + if ($this->share_attachments) { + // One collection for the sent mail and all recipients, reference counted via `mail_attachment` + if ($this->shared_delivery_attachments === null) { + $this->shared_delivery_attachments = $this->createDeliveryAttachments($source); + } + + return $this->shared_delivery_attachments; + } + + return $this->createDeliveryAttachments($source); + } + + private function createDeliveryAttachments(MailAttachments $source): MailAttachments + { + // A collection nobody else uses (e.g. the snapshot of a queued mail) is taken over without copying + if ($this->mail_file_data->isCollectionExclusivelyOwnedByMail($source->rcid())) { + return $source; + } + + $copied_rcid = $this->mail_file_data->copyCollection($source->rcid()); + if ($copied_rcid === null) { + $this->attachment_copy_failed = true; + + return MailAttachments::empty(); + } + + return MailAttachments::fromIrss($copied_rcid); + } + + private function irssAttachmentsWereLost(MailAttachments $source, MailAttachments $prepared): bool + { + return $source->isIrss() && !$source->isEmpty() && !$prepared->isIrss(); + } + private function readMailObjectReferenceId(): void { $this->mail_obj_ref_id = ilMailGlobalServices::getMailObjectRefId(); @@ -394,7 +442,62 @@ public function deleteMails(array $mail_ids): void public function fetchMailData(?array $row): ?array { - return (new MailRecordMapper())->normalizeRow($row); + $row = new MailRecordMapper()->normalizeRow($row); + if ($row === null) { + return null; + } + + if (!$row['attachments'] instanceof MailAttachments || !$row['attachments']->isLegacy()) { + return $row; + } + + // Sent and received legacy mails reference a directory, only drafts, outbox mails and the stage + // reference files of the user's attachment pool + $mail_id = isset($row['mail_id']) ? (int) $row['mail_id'] : null; + if ($mail_id !== null && $this->mail_file_data->hasLegacyAttachmentDirectory($mail_id)) { + return $row; + } + + if ($mail_id !== null && !$this->isPoolReferencingFolder((int) ($row['folder_id'] ?? 0))) { + return $row; + } + + $migrated = $this->mail_file_data->migrateLegacyPoolAttachments($row['attachments']); + if ($migrated === null) { + return $row; + } + + $row['attachments'] = $migrated; + if ($mail_id !== null) { + $this->persistMailAttachmentsColumn($mail_id, $migrated); + $this->mail_file_data->replaceMailAttachmentCollection($mail_id, $migrated->rcid()); + } elseif (isset($row['user_id']) && (int) $row['user_id'] === $this->user_id) { + $this->db->update( + $this->table_mail_saved, + ['attachments' => [ilDBConstants::T_TEXT, $migrated->rcid()->serialize()]], + ['user_id' => [ilDBConstants::T_INTEGER, $this->user_id]] + ); + } + + return $row; + } + + private function isMailOwnedBy(int $mail_id, int $user_id): bool + { + $res = $this->db->queryF( + "SELECT 1 FROM $this->table_mail WHERE mail_id = %s AND user_id = %s", + [ilDBConstants::T_INTEGER, ilDBConstants::T_INTEGER], + [$mail_id, $user_id] + ); + + return $this->db->numRows($res) === 1; + } + + private function isPoolReferencingFolder(int $folder_id): bool + { + $folder = $this->mailbox->getFolderData($folder_id); + + return $folder !== null && ($folder->isDrafts() || $folder->isOutbox()); } public function getNewDraftId(int $folder_id): int @@ -410,12 +513,9 @@ public function getNewDraftId(int $folder_id): int return $next_id; } - /** - * @param list $a_attachments - */ public function updateDraft( int $a_folder_id, - array $a_attachments, + MailAttachments $a_attachments, string $a_rcp_to, string $a_rcp_cc, string $a_rcp_bcc, @@ -427,11 +527,15 @@ public function updateDraft( ?string $a_tpl_context_id = null, array $a_tpl_context_params = [] ): int { + if (!$this->isMailOwnedBy($a_draft_id, $this->user_id)) { + return $a_draft_id; + } + $this->db->update( $this->table_mail, [ 'folder_id' => ['integer', $a_folder_id], - 'attachments' => ['clob', serialize($a_attachments)], + 'attachments' => [ilDBConstants::T_CLOB, $a_attachments->toDb()], 'send_time' => ['timestamp', date('Y-m-d H:i:s')], 'rcp_to' => ['clob', $a_rcp_to], 'rcp_cc' => ['clob', $a_rcp_cc], @@ -450,6 +554,11 @@ public function updateDraft( ] ); + $this->mail_file_data->replaceMailAttachmentCollection( + $a_draft_id, + $a_attachments->stageRcidOrNull() + ); + return $a_draft_id; } @@ -472,7 +581,7 @@ public function scheduledMail( 'user_id' => [ilDBConstants::T_INTEGER, $sender_usr_id], 'folder_id' => [ilDBConstants::T_INTEGER, $folder_id], 'sender_id' => [ilDBConstants::T_INTEGER, $sender_usr_id], - 'attachments' => [ilDBConstants::T_CLOB, serialize($mail_data->getMailDeliveryData()->getAttachments())], + 'attachments' => [ilDBConstants::T_CLOB, $mail_data->getMailDeliveryData()->getAttachments()->toDb()], 'send_time' => [ilDBConstants::T_TIMESTAMP, date('Y-m-d H:i:s')], 'rcp_to' => [ilDBConstants::T_CLOB, $mail_data->getMailDeliveryData()->getTo()], 'rcp_cc' => [ilDBConstants::T_CLOB, $mail_data->getMailDeliveryData()->getCC()], @@ -486,24 +595,29 @@ public function scheduledMail( 'schedule_timezone' => [ilDBConstants::T_TEXT, $mail_data->getScheduleDatetime()->getTimezone()->getName()], ]; - if (!$mail_data->getMailDeliveryData()->getInternalMailId()) { + $outbox_id = (int) $mail_data->getMailDeliveryData()->getInternalMailId(); + if ($outbox_id === 0 || !$this->isMailOwnedBy($outbox_id, $sender_usr_id)) { $outbox_id = $this->db->nextId($this->table_mail); $mail_values['mail_id'] = [ilDBConstants::T_INTEGER, $outbox_id]; $this->db->insert($this->table_mail, $mail_values); } else { - $outbox_id = $mail_data->getMailDeliveryData()->getInternalMailId(); $this->db->update($this->table_mail, $mail_values, [ 'mail_id' => [ilDBConstants::T_INTEGER, $outbox_id], ]); } + $this->mail_file_data->replaceMailAttachmentCollection( + $outbox_id, + $mail_data->getMailDeliveryData()->getAttachments()->stageRcidOrNull() + ); + return $outbox_id; } private function sendInternalMail( int $folder_id, int $sender_usr_id, - array $attachments, + MailAttachments $attachments, string $to, string $cc, string $bcc, @@ -529,7 +643,7 @@ private function sendInternalMail( 'user_id' => ['integer', $usr_id], 'folder_id' => ['integer', $folder_id], 'sender_id' => ['integer', $sender_usr_id], - 'attachments' => ['clob', serialize($attachments)], + 'attachments' => [ilDBConstants::T_CLOB, $attachments->toDb()], 'send_time' => ['timestamp', date('Y-m-d H:i:s')], 'rcp_to' => ['clob', $to], 'rcp_cc' => ['clob', $cc], @@ -691,6 +805,7 @@ private function sendChanneledMails( string $message ): void { $usr_id_to_external_email_addresses_map = []; + $external_attachments = null; foreach ($recipients as $recipient) { if (!$recipient->isUser()) { @@ -749,10 +864,20 @@ private function sendChanneledMails( $mbox->setUsrId($recipient->getUserId()); $recipient_inbox_id = $mbox->getInboxFolder(); + $delivery_attachments = $this->getDeliveryAttachments($mail_data->getAttachments()); + if ($this->attachment_copy_failed) { + $this->logger->error('Could not copy mail attachments for delivery'); + + break; + } + if ($external_attachments === null) { + $external_attachments = $delivery_attachments; + } + $internal_mail_id = $this->sendInternalMail( $recipient_inbox_id, $this->user_id, - $mail_data->getAttachments(), + $delivery_attachments, $mail_data->getTo(), $mail_data->getCc(), '', @@ -768,26 +893,43 @@ private function sendChanneledMails( $this->getMailOptionsByUserId($this->user_id), ); - if ($mail_data->getAttachments() !== []) { - $this->mail_file_data->assignAttachmentsToDirectory($internal_mail_id, $mail_data->getInternalMailId()); - } + $this->assignMailAttachments( + $internal_mail_id, + $delivery_attachments, + $mail_data->getInternalMailId() + ); } - $this->delegateExternalEmails( - $mail_data->getSubject(), - $mail_data->getAttachments(), - $message, - $usr_id_to_external_email_addresses_map - ); + if ($usr_id_to_external_email_addresses_map === [] || $this->attachment_copy_failed) { + return; + } + + $release_external_copy = false; + if (!$external_attachments instanceof MailAttachments || $external_attachments->isEmpty()) { + $external_attachments = $this->getDeliveryAttachments($mail_data->getAttachments()); + $release_external_copy = $external_attachments->isIrss(); + } + + try { + $this->delegateExternalEmails( + $mail_data->getSubject(), + $external_attachments, + $message, + $usr_id_to_external_email_addresses_map + ); + } finally { + if ($release_external_copy) { + $this->releaseSourceAttachments($external_attachments); + } + } } /** - * @param list $attachments * @param array $usr_id_to_external_email_addresses_map */ private function delegateExternalEmails( string $subject, - array $attachments, + MailAttachments $attachments, string $message, array $usr_id_to_external_email_addresses_map ): void { @@ -921,9 +1063,6 @@ private function checkRecipients(string $recipients): array return array_merge(...$errors); } - /** - * @param list $a_attachments - */ public function persistToStage( int $a_user_id, string $a_rcp_to, @@ -931,21 +1070,25 @@ public function persistToStage( string $a_rcp_bcc, string $a_m_subject, string $a_m_message, - ?\ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification $a_attachments = null, + ?MailAttachments $a_attachments = null, bool $a_use_placeholders = false, ?string $a_tpl_context_id = null, ?array $a_tpl_ctx_params = [] ): bool { - if (!is_null($a_attachments)) { - $a_attachments = $a_attachments->serialize(); + $attachment_value = null; + if ($a_attachments !== null && $a_attachments->isIrss()) { + $attachment_value = $a_attachments->rcid()->serialize(); } + + $previous_stage_rcid = $this->lookupStageRcid(); + $this->db->replace( $this->table_mail_saved, [ 'user_id' => ['integer', $this->user_id], ], [ - 'attachments' => ['text', $a_attachments], + 'attachments' => [ilDBConstants::T_TEXT, $attachment_value], 'rcp_to' => ['clob', $a_rcp_to], 'rcp_cc' => ['clob', $a_rcp_cc], 'rcp_bcc' => ['clob', $a_rcp_bcc], @@ -957,11 +1100,34 @@ public function persistToStage( ] ); + if ($previous_stage_rcid !== null && $previous_stage_rcid !== $attachment_value) { + $this->mail_file_data->releaseCollectionIfUnreferenced( + new ResourceCollectionIdentification($previous_stage_rcid) + ); + } + $this->retrieveFromStage(); return true; } + private function lookupStageRcid(): ?string + { + $res = $this->db->queryF( + "SELECT attachments FROM $this->table_mail_saved WHERE user_id = %s", + [ilDBConstants::T_INTEGER], + [$this->user_id] + ); + $row = $this->db->fetchAssoc($res); + if (!is_array($row) || !is_string($row['attachments'] ?? null)) { + return null; + } + + $stage = MailAttachments::fromDb($row['attachments']); + + return $stage !== null && $stage->isIrss() ? $stage->rcid()->serialize() : null; + } + public function retrieveFromStage(): array { $res = $this->db->queryF( @@ -978,18 +1144,13 @@ public function retrieveFromStage(): array return $this->mail_data; } - /** - * Should be used to enqueue a 'mail'. A validation is executed before, errors are returned - * @param list $a_attachment - * @return list - */ public function enqueue( string $a_rcp_to, string $a_rcp_cc, string $a_rcp_bcc, string $a_m_subject, string $a_m_message, - array $a_attachment, + MailAttachments $a_attachment, bool $a_use_placeholders = false ): array { global $DIC; @@ -1004,34 +1165,39 @@ public function enqueue( ' | CC: ' . $a_rcp_cc . ' | BCC: ' . $a_rcp_bcc . ' | Subject: ' . $a_m_subject . - ' | Attachments: ' . print_r($a_attachment, true) + ' | Attachments: ' . ( + $a_attachment->isIrss() + ? $a_attachment->rcid()->serialize() + : print_r($a_attachment->legacyFilenames(), true) + ) ); - if ($a_attachment && !$this->mail_file_data->checkFilesExist($a_attachment)) { - return [new ilMailError('mail_attachment_file_not_exist', [implode(', ', $a_attachment)])]; - } - - $errors = $this->checkMail($a_rcp_to, $a_rcp_cc, $a_rcp_bcc, $a_m_subject); - if ($errors !== []) { - return $errors; - } - - $errors = $this->validateRecipients($a_rcp_to, $a_rcp_cc, $a_rcp_bcc); - if ($errors !== []) { - return $errors; + $normalized = $this->normalizeLegacyAttachments($a_attachment); + if ($normalized['errors'] !== []) { + return $normalized['errors']; } + $a_attachment = $normalized['attachments']; $rcp_to = $a_rcp_to; $rcp_cc = $a_rcp_cc; $rcp_bcc = $a_rcp_bcc; - $number_of_external_addresses = $this->getCountRecipients($rcp_to, $rcp_cc, $rcp_bcc); - if ( - $number_of_external_addresses > 0 && - !$this->isSystemMail() && - !$DIC->rbac()->system()->checkAccessOfUser($this->user_id, 'smtp_mail', $this->mail_obj_ref_id) + $errors = $this->checkMail($a_rcp_to, $a_rcp_cc, $a_rcp_bcc, $a_m_subject); + if ($errors === []) { + $errors = $this->validateRecipients($a_rcp_to, $a_rcp_cc, $a_rcp_bcc); + } + if ($errors === [] + && $this->getCountRecipients($rcp_to, $rcp_cc, $rcp_bcc) > 0 + && !$this->isSystemMail() + && !$DIC->rbac()->system()->checkAccessOfUser($this->user_id, 'smtp_mail', $this->mail_obj_ref_id) ) { - return [new ilMailError('mail_no_permissions_write_smtp')]; + $errors = [new ilMailError('mail_no_permissions_write_smtp')]; + } + if ($errors !== []) { + // A collection handed over by the consumer is gone with the rejected message, a stage or draft stays + $this->releaseSourceAttachments($a_attachment); + + return $errors; } if ($this->appendInstallationSignature()) { @@ -1048,9 +1214,20 @@ public function enqueue( $a_attachment, $a_use_placeholders ); + return $this->sendMail($mail_data); } + // The queued mail gets its own collection: a free collection of the mail system is taken over, + // any other (stage, draft, collection of another component) is copied and stays with its owner + $source_attachment = $a_attachment; + $a_attachment = $this->createQueueSnapshot($source_attachment); + if ($this->irssAttachmentsWereLost($source_attachment, $a_attachment)) { + $this->logger->error('Could not snapshot mail attachments before queueing'); + + return [new ilMailError('mail_error_reading_attachment')]; + } + $task_factory = $DIC->backgroundTasks()->taskFactory(); $task_manager = $DIC->backgroundTasks()->taskManager(); @@ -1064,7 +1241,7 @@ public function enqueue( $rcp_bcc, $a_m_subject, $a_m_message, - serialize($a_attachment), + $a_attachment->toBackgroundTask(), $a_use_placeholders, $this->getSaveInSentbox(), (string) $this->context_id, @@ -1093,7 +1270,6 @@ public function enqueue( /** * This method is used to finally send internal messages and external emails * To use the mail system as a consumer, please use ilMail::enqueue - * @param list $attachments * @return list * @see ilMail::enqueue() * @internal @@ -1101,61 +1277,84 @@ public function enqueue( public function sendMail( MailDeliveryData $mail_data ): array { - $internal_message_id = $this->saveInSentbox( - $mail_data->getAttachments(), - $mail_data->getTo(), - $mail_data->getCc(), - $mail_data->getBcc(), - $mail_data->getSubject(), - $mail_data->getMessage() - ); - $mail_data = $mail_data->withInternalMailId($internal_message_id); - - if ($mail_data->getAttachments() !== []) { - $this->mail_file_data->assignAttachmentsToDirectory($internal_message_id, $internal_message_id); - $this->mail_file_data->saveFiles($internal_message_id, $mail_data->getAttachments()); + $normalized = $this->normalizeLegacyAttachments($mail_data->getAttachments()); + if ($normalized['errors'] !== []) { + return $normalized['errors']; } + $mail_data = $mail_data->withAttachments($normalized['attachments']); + $this->shared_delivery_attachments = null; + $this->attachment_copy_failed = false; - $num_external_email_addresses = $this->getCountRecipients( - $mail_data->getTo(), - $mail_data->getCc(), - $mail_data->getBcc() - ); + $source_attachments = $mail_data->getAttachments(); + try { + $sentbox_attachments = $this->getDeliveryAttachments($source_attachments); + if ($this->attachment_copy_failed) { + $this->logger->error('Could not copy mail attachments for delivery'); - if ($num_external_email_addresses > 0) { - $external_mail_recipients_to = $this->getEmailRecipients($mail_data->getTo()); - $external_mail_recipients_cc = $this->getEmailRecipients($mail_data->getCc()); - $external_eail_recipients_bcc = $this->getEmailRecipients($mail_data->getBcc()); - - $this->logger->debug( - 'Parsed external email addresses from given recipients /' . - ' To: ' . $external_mail_recipients_to . - ' | CC: ' . $external_mail_recipients_cc . - ' | BCC: ' . $external_eail_recipients_bcc . - ' | Subject: ' . $mail_data->getSubject() + return [new ilMailError('mail_error_reading_attachment')]; + } + $internal_message_id = $this->saveInSentbox( + $sentbox_attachments, + $mail_data->getTo(), + $mail_data->getCc(), + $mail_data->getBcc(), + $mail_data->getSubject(), + $mail_data->getMessage() ); + $mail_data = $mail_data->withInternalMailId($internal_message_id); - $this->sendMimeMail( - $external_mail_recipients_to, - $external_mail_recipients_cc, - $external_eail_recipients_bcc, - $mail_data->getSubject(), - $mail_data->isUsePlaceholder() ? - $this->replacePlaceholders($mail_data->getMessage()) : - $mail_data->getMessage(), - $mail_data->getAttachments() + $this->assignMailAttachments( + $internal_message_id, + $sentbox_attachments, + $internal_message_id ); - } else { - $this->logger->debug('No external email addresses given in recipient string'); - } - $errors = []; - if (!$this->distributeMail($mail_data)) { - $errors['mail_send_error'] = new ilMailError('mail_send_error'); - } + $num_external_email_addresses = $this->getCountRecipients( + $mail_data->getTo(), + $mail_data->getCc(), + $mail_data->getBcc() + ); + + if ($num_external_email_addresses > 0) { + $external_mail_recipients_to = $this->getEmailRecipients($mail_data->getTo()); + $external_mail_recipients_cc = $this->getEmailRecipients($mail_data->getCc()); + $external_eail_recipients_bcc = $this->getEmailRecipients($mail_data->getBcc()); + + $this->logger->debug( + 'Parsed external email addresses from given recipients /' . + ' To: ' . $external_mail_recipients_to . + ' | CC: ' . $external_mail_recipients_cc . + ' | BCC: ' . $external_eail_recipients_bcc . + ' | Subject: ' . $mail_data->getSubject() + ); + + $this->sendMimeMail( + $external_mail_recipients_to, + $external_mail_recipients_cc, + $external_eail_recipients_bcc, + $mail_data->getSubject(), + $mail_data->isUsePlaceholder() ? + $this->replacePlaceholders($mail_data->getMessage()) : + $mail_data->getMessage(), + $sentbox_attachments + ); + } else { + $this->logger->debug('No external email addresses given in recipient string'); + } + + $errors = []; + if (!$this->distributeMail($mail_data)) { + $errors['mail_send_error'] = new ilMailError('mail_send_error'); + } + if ($this->attachment_copy_failed) { + $errors['mail_error_reading_attachment'] = new ilMailError('mail_error_reading_attachment'); + } - if (!$this->getSaveInSentbox()) { - $this->deleteMails([$internal_message_id]); + if (!$this->getSaveInSentbox()) { + $this->deleteMails([$internal_message_id]); + } + } finally { + $this->releaseSourceAttachments($source_attachments); } if ($this->isSystemMail()) { @@ -1171,6 +1370,37 @@ public function sendMail( return array_values($errors); } + private function createQueueSnapshot(MailAttachments $attachments): MailAttachments + { + if ($attachments->isEmpty() || !$attachments->isIrss()) { + return $attachments; + } + + if ($this->mail_file_data->isCollectionExclusivelyOwnedByMail($attachments->rcid())) { + return $attachments; + } + + $snapshot_rcid = $this->mail_file_data->copyCollection($attachments->rcid()); + if ($snapshot_rcid === null) { + return MailAttachments::empty(); + } + + return MailAttachments::fromIrss($snapshot_rcid); + } + + private function releaseSourceAttachments(MailAttachments $source): void + { + if ($source->isEmpty() || !$source->isIrss()) { + return; + } + + try { + $this->mail_file_data->releaseSourceCollectionAfterDelivery($source->rcid()); + } catch (Throwable $e) { + $this->logger->warning($e->getMessage()); + } + } + /** * @return list */ @@ -1202,11 +1432,8 @@ private function getSubjectSentFolderId(): int return $send_folder_id; } - /** - * @param list $attachment - */ private function saveInSentbox( - array $attachment, + MailAttachments $attachment, string $to, string $cc, string $bcc, @@ -1227,16 +1454,13 @@ private function saveInSentbox( ); } - /** - * @param list $attachments - */ private function sendMimeMail( string $to, string $cc, string $bcc, string $subject, string $message, - array $attachments + MailAttachments $attachments ): void { $mailer = new ilMimeMail(); $mailer->From($this->sender_factory->getSenderByUsrId($this->user_id)); @@ -1265,35 +1489,88 @@ function (string $message): string { $mailer->Bcc($bcc); } - foreach ($attachments as $attachment) { - $mailer->Attach( - $this->mail_file_data->getAbsoluteAttachmentPoolPathByFilename($attachment), - '', - 'inline', - $attachment - ); + if ($attachments->isIrss()) { + foreach ($this->mail_file_data->getIrssMimeAttachments($attachments->rcid()) as $attachment) { + $mailer->AttachResource( + new ResourceIdentification($attachment['rid']), + $attachment['name'] + ); + } } $mailer->Send(); } - public function saveAttachments(?ResourceCollectionIdentification $attachments): void + /** + * @return array{attachments: MailAttachments, errors: list} + */ + private function normalizeLegacyAttachments(MailAttachments $attachments): array { - if (!is_null($attachments)) { - $attachments = $attachments->serialize(); + if (!$attachments->isLegacy()) { + return ['attachments' => $attachments, 'errors' => []]; + } + + if (!$this->mail_file_data->checkFilesExist($attachments->legacyFilenames())) { + return [ + 'attachments' => $attachments, + 'errors' => [new ilMailError('mail_error_reading_attachment')], + ]; + } + + $migrated = $this->mail_file_data->migrateLegacyPoolAttachments($attachments); + if ($migrated === null) { + return [ + 'attachments' => $attachments, + 'errors' => [new ilMailError('mail_error_reading_attachment')], + ]; } + return ['attachments' => $migrated, 'errors' => []]; + } + + private function persistMailAttachmentsColumn(int $mail_id, MailAttachments $attachments): void + { $this->db->update( - $this->table_mail_saved, + 'mail', [ - 'attachments' => ['text', $attachments], + 'attachments' => [ilDBConstants::T_CLOB, $attachments->toDb()], ], [ - 'user_id' => ['integer', $this->user_id], + 'mail_id' => [ilDBConstants::T_INTEGER, $mail_id], ] ); } + private function assignMailAttachments( + int $mail_id, + MailAttachments $attachments, + int $sent_mail_id + ): void { + if ($attachments->isEmpty() || !$attachments->isIrss()) { + return; + } + + $this->mail_file_data->assignAttachmentsToCollection($mail_id, $attachments->rcid()); + } + + public function saveAttachments(?MailAttachments $attachments): void + { + $stage = $this->retrieveFromStage(); + + $this->persistToStage( + $this->user_id, + (string) ($stage['rcp_to'] ?? ''), + (string) ($stage['rcp_cc'] ?? ''), + (string) ($stage['rcp_bcc'] ?? ''), + (string) ($stage['m_subject'] ?? ''), + (string) ($stage['m_message'] ?? ''), + $attachments, + (bool) ($stage['use_placeholders'] ?? false), + $stage['tpl_ctx_id'] ?? null, + (array) ($stage['tpl_ctx_params'] ?? []) + ); + } + /** * Explode recipient string, allowed separators are ',' ';' ' ' * @return list diff --git a/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php b/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php index fcecc2d65943..0456382a4661 100644 --- a/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php +++ b/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php @@ -19,11 +19,13 @@ declare(strict_types=1); use ILIAS\Refinery\Factory as Refinery; +use ILIAS\Mail\Attachments\MailAttachments; use ILIAS\FileUpload\Handler\AbstractCtrlAwareUploadHandler; use ILIAS\FileUpload\Handler\FileInfoResult; use ILIAS\FileUpload\Handler\BasicHandlerResult; use ILIAS\FileUpload\DTO\UploadResult; use ILIAS\FileUpload\Handler\HandlerResult; +use ILIAS\FileUpload\Handler\BasicFileInfoResult; use ILIAS\Mail\Attachments\AttachmentManagement; use ILIAS\Mail\Attachments\MailAttachmentTableGUI; use ILIAS\Mail\Attachments\MailAttachmentCommands; @@ -32,8 +34,6 @@ class ilMailAttachmentGUI extends AbstractCtrlAwareUploadHandler implements ilCtrlSecurityInterface, MailAttachmentCommands { - use FileDataRCHandling; - private readonly ilGlobalTemplateInterface $tpl; private readonly ilLanguage $lng; private readonly ilObjUser $user; @@ -112,12 +112,6 @@ public function executeCommand(): void private function saveAttachments(): void { - $files = []; - - // Important: Do not check for uploaded files here, - // otherwise it is no more possible to remove files (please ignore bug reports like 10137) - - $size_of_affected_files = 0; $files_of_request = $this->http->wrapper()->query()->retrieve( 'mail_attachments_filename', $this->refinery->byTrying([ @@ -127,22 +121,23 @@ private function saveAttachments(): void ); if ($files_of_request !== [] && $files_of_request[0] === 'ALL_OBJECTS') { - $files_of_request = array_map(static fn(array $file): string => $file['name'], $this->fdm->getUserFilesData()); + $files_of_request = array_map(static fn(array $file): string => $file['rid'], $this->fdm->getUserPoolFilesData()); } - foreach ($files_of_request as $file) { - if (is_file($this->fdm->getMailPath() . '/' . basename($this->user->getId() . '_' . urldecode((string) $file)))) { - $files[] = urldecode((string) $file); - $size_of_affected_files += filesize( - $this->fdm->getMailPath() . '/' . - basename($this->user->getId() . '_' . urldecode((string) $file)) - ); - } + $resource_identifications = $this->fdm->resolvePoolIdentifiersToResources($files_of_request); + $size_of_affected_files = 0; + foreach ($resource_identifications as $rid) { + $size_of_affected_files += $this->storage->manage()->getCurrentRevision($rid)->getInformation()->getSize(); } - if ($files !== [] && - $this->fdm->getAttachmentsTotalSizeLimit() !== null && - $size_of_affected_files > $this->fdm->getAttachmentsTotalSizeLimit()) { + if ($resource_identifications === []) { + $this->tpl->setOnScreenMessage($this->tpl::MESSAGE_TYPE_INFO, $this->lng->txt('select_one'), true); + $this->showAttachmentsCommand(); + return; + } + + if ($this->fdm->getAttachmentsTotalSizeLimit() !== null + && $size_of_affected_files > $this->fdm->getAttachmentsTotalSizeLimit()) { $this->tpl->setOnScreenMessage( $this->tpl::MESSAGE_TYPE_FAILURE, $this->lng->txt('mail_max_size_attachments_total_error') . ' ' . @@ -152,10 +147,20 @@ private function saveAttachments(): void return; } - $rcid_for_files = $this->getIdforCollection($files); - $this->umail->saveAttachments($rcid_for_files); + try { + $rcid = $this->fdm->applyPoolSelectionToStage($resource_identifications); + if ($rcid === null) { + throw new RuntimeException($this->lng->txt('mail_error_reading_attachment')); + } - $this->ctrl->returnToParent($this); + $this->umail->saveAttachments(MailAttachments::fromIrss($rcid)); + } catch (Throwable $e) { + $this->tpl->setOnScreenMessage($this->tpl::MESSAGE_TYPE_FAILURE, $e->getMessage(), true); + $this->showAttachmentsCommand(); + return; + } + + $this->ctrl->redirectByClass(ilMailFormGUI::class, 'returnFromAttachments'); } private function cancelSaveAttachmentsCommand(): void @@ -175,7 +180,7 @@ private function confirmDeleteAttachments(): void ); if ($files !== [] && $files[0] === 'ALL_OBJECTS') { - $files = array_map(static fn(array $file): string => $file['name'], $this->fdm->getUserFilesData()); + $files = array_map(static fn(array $file): string => $file['rid'], $this->fdm->getUserPoolFilesData()); } if ($files === []) { @@ -191,11 +196,17 @@ private function confirmDeleteAttachments(): void $confirmation->setCancel($this->lng->txt('cancel'), self::CMD_SHOW_ATTACHMENTS); $confirmation->setHeaderText($this->lng->txt('mail_sure_delete_file')); - foreach ($files as $filename) { + foreach ($files as $rid_string) { + $title = (string) $rid_string; + $rid = $this->storage->manage()->find($title); + if ($rid !== null && $this->fdm->isInUserPool($rid)) { + $title = $this->storage->manage()->getCurrentRevision($rid)->getInformation()->getTitle(); + } + $confirmation->addItem( 'filename[]', - ilUtil::stripSlashes($filename), - ilUtil::stripSlashes(urldecode((string) $filename)) + ilUtil::stripSlashes((string) $rid_string), + ilUtil::stripSlashes($title) ); } @@ -219,35 +230,21 @@ private function deleteAttachmentsCommand(): void return; } - $decoded_files = []; - foreach ($files as $value) { - $decoded_files[] = urldecode((string) $value); - } - - $error = $this->fdm->unlinkFiles($decoded_files); - if ($error !== '') { - $this->tpl->setOnScreenMessage($this->tpl::MESSAGE_TYPE_SUCCESS, $this->lng->txt('mail_error_delete_file') . ' ' . $error, true); - } else { - $mail_data = $this->umail->retrieveFromStage(); - if (!is_null($mail_data['attachments'])) { - $files_to_legacy = $this->FilesFromIRSSToLegacy($mail_data['attachments']); - $files = $this->handleAttachments($files_to_legacy); - $rcid = null; - if (is_array($files)) { - foreach ($files as $attachment) { - $tmp = []; - if (!in_array($attachment, $decoded_files, true)) { - $tmp[] = $attachment; - } - $rcid = $this->getIdforCollection($tmp); - } - $this->umail->saveAttachments($rcid); - } + foreach ($files as $rid_string) { + $rid_string = (string) $rid_string; + if ($this->fdm->isLegacyPoolItemIdentifier($rid_string)) { + $this->fdm->unlinkFile($this->fdm->legacyPoolFilenameFromIdentifier($rid_string)); + continue; } - $this->tpl->setOnScreenMessage($this->tpl::MESSAGE_TYPE_SUCCESS, $this->lng->txt('mail_files_deleted'), true); + $rid = $this->storage->manage()->find($rid_string); + if ($rid !== null) { + $this->fdm->removeFromPool($rid); + } } + $this->tpl->setOnScreenMessage($this->tpl::MESSAGE_TYPE_SUCCESS, $this->lng->txt('mail_files_deleted'), true); + $this->ctrl->redirect($this); } @@ -288,15 +285,22 @@ private function showAttachmentsCommand(): void } $mail_data = $this->umail->retrieveFromStage(); - $files = $this->fdm->getUserFilesData(); + $stage_attachments = $mail_data['attachments'] ?? null; + $adopted_pool_rids = []; + if ($stage_attachments instanceof MailAttachments && $stage_attachments->isIrss()) { + $adopted_pool_rids = array_flip($this->fdm->getPoolResourcesInStage($stage_attachments->rcid())); + } + + $files = $this->fdm->getUserPoolFilesData(); $records = []; $checked_items = []; foreach ($files as $file) { - if (is_array($mail_data['attachments']) && in_array($file['name'], $mail_data['attachments'], true)) { - $checked_items[] = urlencode($file['name']); + if (isset($adopted_pool_rids[$file['rid']])) { + $checked_items[] = $file['rid']; } $records[] = [ + 'rid' => $file['rid'], 'filename' => $file['name'], 'filesize' => (int) $file['size'], 'filecreatedate' => (int) $file['ctime'], @@ -340,6 +344,12 @@ private function handleTableActionsCommand(): void { $query = $this->http->wrapper()->query(); if (!$query->has('mail_attachments_table_action')) { + $this->tpl->setOnScreenMessage( + $this->tpl::MESSAGE_TYPE_FAILURE, + $this->lng->txt('select_one'), + true + ); + $this->showAttachmentsCommand(); return; } @@ -347,7 +357,7 @@ private function handleTableActionsCommand(): void match ($action) { self::TABLE_ACTION_SAVE_ATTACHMENTS => $this->saveAttachments(), self::TABLE_CONFIRM_DELETE_ATTACHMENTS => $this->confirmDeleteAttachments(), - default => $this->ctrl->redirect($this), + default => $this->showAttachmentsCommand(), }; } @@ -358,14 +368,15 @@ protected function getUploadResult(): HandlerResult $result = end($array); if ($result instanceof UploadResult && $result->isOK()) { - $identifier = $this->fdm->storeUploadedFile($result); + $rid = $this->fdm->uploadToPool($result); $status = HandlerResult::STATUS_OK; + $identifier = $rid->serialize(); $message = $this->lng->txt('saved_successfully'); $this->tpl->setOnScreenMessage($this->tpl::MESSAGE_TYPE_SUCCESS, $this->lng->txt('saved_successfully'), true); } else { $status = HandlerResult::STATUS_FAILED; $identifier = ''; - $message = $result->getStatus()->getMessage(); + $message = $result instanceof UploadResult ? $result->getStatus()->getMessage() : ''; } return new BasicHandlerResult($this->getFileIdentifierParameterName(), $status, $identifier, $message); @@ -373,17 +384,43 @@ protected function getUploadResult(): HandlerResult protected function getRemoveResult(string $identifier): HandlerResult { - throw new DomainException('Not necessary for this handler'); + return new BasicHandlerResult( + $this->getFileIdentifierParameterName(), + HandlerResult::STATUS_OK, + $identifier, + 'file deleted' + ); } public function getInfoResult(string $identifier): ?FileInfoResult { - throw new DomainException('Not necessary for this handler'); + $rid = $this->storage->manage()->find($identifier); + if ($rid === null || !$this->fdm->isInUserPool($rid)) { + return new BasicFileInfoResult($this->getFileIdentifierParameterName(), 'unknown', 'unknown', 0, 'unknown'); + } + + $info = $this->storage->manage()->getCurrentRevision($rid)->getInformation(); + + return new BasicFileInfoResult( + $this->getFileIdentifierParameterName(), + $identifier, + $info->getTitle(), + $info->getSize(), + $info->getMimeType() + ); } public function getInfoForExistingFiles(array $file_ids): array { - throw new DomainException('Not necessary for this handler'); + $infos = []; + foreach ($file_ids as $file_id) { + $info = $this->getInfoResult($file_id); + if ($info !== null) { + $infos[] = $info; + } + } + + return $infos; } public function getFileIdentifierParameterName(): string diff --git a/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php b/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php index af9a116d3bab..928e31647044 100644 --- a/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php +++ b/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php @@ -18,21 +18,135 @@ declare(strict_types=1); +use ILIAS\Mail\Attachments\MailAttachments; +use ILIAS\ResourceStorage\Services as ResourceStorage; +use ILIAS\ResourceStorage\Identification\ResourceIdentification; +use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; use ILIAS\ResourceStorage\Stakeholder\AbstractResourceStakeholder; +use ILIAS\ResourceStorage\Stakeholder\ConfidentialStakeholder; -class ilMailAttachmentStakeholder extends AbstractResourceStakeholder +class ilMailAttachmentStakeholder extends AbstractResourceStakeholder implements ConfidentialStakeholder { - public function __construct() + public function getId(): string { + return 'mail_attachments'; } - public function getId(): string + public function areNewResourcesConfidential(): bool { - return 'mail_attachments'; + return true; } public function getOwnerOfNewResources(): int { - return 6; + return $this->default_owner; + } + + public function getOwnerOfResource(ResourceIdentification $identification): int + { + return $this->default_owner; + } + + public function canBeAccessedByCurrentUser(ResourceIdentification $identification): bool + { + global $DIC; + + if (!$DIC->isDependencyAvailable('user') || !$DIC->isDependencyAvailable('database')) { + return false; + } + + if (!$DIC->isDependencyAvailable('resourceStorage')) { + return false; + } + + $user_id = $DIC->user()->getId(); + // Notifications are sent by the anonymous user, a visitor must not own their attachments + if ($user_id === ANONYMOUS_USER_ID) { + return false; + } + $irss = $DIC->resourceStorage(); + + try { + if ($irss->manage()->getCurrentRevision($identification)->getOwnerId() === $user_id) { + return true; + } + } catch (Throwable) { + } + + return $this->anyCollectionContains( + $irss, + $this->collectionsOfUser($DIC->database(), $user_id), + $identification + ); + } + + /** + * @return list Collections of the user's mails, attachment pool and compose stage + */ + private function collectionsOfUser(ilDBInterface $db, int $user_id): array + { + $rcids = []; + + $res = $db->queryF( + 'SELECT DISTINCT ma.rcid FROM mail_attachment ma + INNER JOIN mail m ON m.mail_id = ma.mail_id + WHERE m.user_id = %s AND ma.rcid IS NOT NULL', + [ilDBConstants::T_INTEGER], + [$user_id] + ); + while ($row = $db->fetchAssoc($res)) { + $rcids[] = (string) $row['rcid']; + } + + $res = $db->queryF( + 'SELECT value FROM usr_pref WHERE usr_id = %s AND keyword = %s', + [ilDBConstants::T_INTEGER, ilDBConstants::T_TEXT], + [$user_id, ilFileDataMail::POOL_RCID_PREF_KEY] + ); + while ($row = $db->fetchAssoc($res)) { + $rcids[] = (string) $row['value']; + } + + $res = $db->queryF( + 'SELECT attachments FROM mail_saved WHERE user_id = %s', + [ilDBConstants::T_INTEGER], + [$user_id] + ); + while ($row = $db->fetchAssoc($res)) { + $stage = MailAttachments::fromDb(is_string($row['attachments'] ?? null) ? $row['attachments'] : null); + if ($stage !== null && $stage->isIrss()) { + $rcids[] = $stage->rcid()->serialize(); + } + } + + return array_values(array_unique(array_filter( + $rcids, + static fn(string $rcid): bool => $rcid !== '' && $rcid !== '-' + ))); + } + + /** + * @param list $rcids + */ + private function anyCollectionContains( + ResourceStorage $irss, + array $rcids, + ResourceIdentification $identification + ): bool { + if ($rcids === []) { + return false; + } + + $irss->preloadCollections($rcids); + foreach ($rcids as $rcid) { + try { + if ($irss->collection()->get(new ResourceCollectionIdentification($rcid))->isIn($identification)) { + return true; + } + } catch (Throwable) { + } + } + + return false; } } diff --git a/components/ILIAS/Mail/classes/class.ilMailFolderGUI.php b/components/ILIAS/Mail/classes/class.ilMailFolderGUI.php index 868783b1a8bb..d7f9e1569eac 100755 --- a/components/ILIAS/Mail/classes/class.ilMailFolderGUI.php +++ b/components/ILIAS/Mail/classes/class.ilMailFolderGUI.php @@ -31,6 +31,8 @@ use ILIAS\Mail\Message\MailMessageHtmlRenderer; use ILIAS\Filesystem\Stream\Streams; use ILIAS\User\Profile\PublicProfileGUI; +use ILIAS\Mail\Attachments\MailAttachments; +use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; /** * @ilCtrl_Calls ilMailFolderGUI: ILIAS\User\Profile\PublicProfileGUI @@ -943,12 +945,20 @@ protected function showMail(): void $form->addItem($message); - if ($mail_data['attachments']) { + $mail_attachments = $mail_data['attachments'] ?? null; + if ($mail_attachments instanceof MailAttachments && !$mail_attachments->isEmpty()) { $att = new ilCustomInputGUI($this->lng->txt('attachments') . ':'); + $mail_file_data = new ilFileDataMail($this->user->getId()); $radiog = new ilRadioGroupInputGUI('', 'filename'); - foreach ($mail_data['attachments'] as $file) { - $radiog->addOption(new ilRadioOption($file, md5($file))); + if ($mail_attachments->isIrss()) { + foreach ($mail_file_data->getAttachmentListing($mail_attachments->rcid()) as $file) { + $radiog->addOption(new ilRadioOption($file['name'], $file['md5'])); + } + } else { + foreach ($mail_attachments->legacyFilenames() as $file) { + $radiog->addOption(new ilRadioOption($file, md5($file))); + } } $att->setHtml($radiog->render()); @@ -1177,15 +1187,19 @@ protected function deliverFile(): void try { if ($mail_id > 0 && $filename !== '') { $mail_data = $this->umail->getMail($mail_id); + $mail_file_data = new ilFileDataMail($this->user->getId()); if ($mail_data === null || - !in_array($filename, array_map('md5', (array) ($mail_data['attachments'] ?? [])), true)) { + !in_array($filename, $this->getAttachmentHashes($mail_data, $mail_file_data), true)) { throw new ilMailException('mail_error_reading_attachment'); } - $mail_file_data = new ilFileDataMail($this->user->getId()); try { $file = $mail_file_data->getAttachmentPathAndFilenameByMd5Hash($filename, (int) $mail_id); - ilFileDelivery::deliverFileLegacy($file['path'], $file['filename']); + if (isset($file['rcid']) && $file['rcid'] instanceof ResourceCollectionIdentification) { + $mail_file_data->deliverFile($file['rcid'], $file['md5']); + } else { + ilFileDelivery::deliverFileLegacy($file['path'], $file['filename']); + } } catch (OutOfBoundsException $e) { throw new ilMailException('mail_error_reading_attachment', $e->getCode(), $e); } @@ -1204,18 +1218,37 @@ protected function deliverAttachments(): void try { $mail_id = $this->getMailIdsFromRequest()[0] ?? 0; $mail_data = $this->umail->getMail($mail_id); - if ($mail_data === null || [] === (array) $mail_data['attachments']) { + if ($mail_data === null + || !($mail_data['attachments'] instanceof MailAttachments) + || $mail_data['attachments']->isEmpty()) { throw new ilMailException('mail_error_reading_attachment'); } + $attachments = $mail_data['attachments']; $type = $this->http->wrapper()->query()->retrieve( 'type', $this->refinery->byTrying([$this->refinery->kindlyTo()->string(), $this->refinery->always('')]) ); $mail_file_data = new ilFileDataMail($this->user->getId()); - if (count($mail_data['attachments']) === 1) { - $attachment = current($mail_data['attachments']); + + if ($attachments->isIrss()) { + $listing = $mail_file_data->getAttachmentListing($attachments->rcid()); + if (count($listing) === 1) { + $file = reset($listing); + $mail_file_data->deliverFile($attachments->rcid(), $file['md5']); + } else { + // Drafts and scheduled mails are served from their collection as well + $mail_file_data->deliverCollectionAsZip( + $attachments->rcid(), + (string) ($mail_data['m_subject'] ?? '') + ); + } + return; + } + + if ($attachments->isLegacy() && count($attachments->legacyFilenames()) === 1) { + $attachment = current($attachments->legacyFilenames()); try { if ($type === 'draft') { @@ -1224,25 +1257,30 @@ protected function deliverAttachments(): void } $path_to_file = $mail_file_data->getAbsoluteAttachmentPoolPathByFilename($attachment); $filename = $attachment; + ilFileDelivery::deliverFileLegacy($path_to_file, $filename); } else { $file = $mail_file_data->getAttachmentPathAndFilenameByMd5Hash( md5((string) $attachment), $mail_id ); - $path_to_file = $file['path']; - $filename = $file['filename']; + if (isset($file['rcid']) && $file['rcid'] instanceof ResourceCollectionIdentification) { + $mail_file_data->deliverFile($file['rcid'], $file['md5']); + } else { + ilFileDelivery::deliverFileLegacy($file['path'], $file['filename']); + } } - ilFileDelivery::deliverFileLegacy($path_to_file, $filename); } catch (OutOfBoundsException $e) { throw new ilMailException('mail_error_reading_attachment', $e->getCode(), $e); } - } else { + } elseif ($attachments->isLegacy()) { $mail_file_data->deliverAttachmentsAsZip( $mail_data['m_subject'], $mail_id, - $mail_data['attachments'], + $attachments->legacyFilenames(), $type === 'draft' ); + } else { + throw new ilMailException('mail_error_reading_attachment'); } } catch (Exception $e) { $this->tpl->setOnScreenMessage('failure', $this->lng->txt($e->getMessage()), true); @@ -1250,6 +1288,27 @@ protected function deliverAttachments(): void } } + /** + * @param array $mail_data + * @return list + */ + private function getAttachmentHashes(array $mail_data, ilFileDataMail $mail_file_data): array + { + $attachments = $mail_data['attachments'] ?? null; + if (!$attachments instanceof MailAttachments || $attachments->isEmpty()) { + return []; + } + + if ($attachments->isIrss()) { + return array_values(array_map( + static fn(array $file): string => (string) $file['md5'], + $mail_file_data->getAttachmentListing($attachments->rcid()) + )); + } + + return array_map(md5(...), $attachments->legacyFilenames()); + } + private function encodeRecipientsForHtml(string $recipients): string { return $this->encodeForHtml($this->umail->formatNamesForOutput($recipients)); diff --git a/components/ILIAS/Mail/classes/class.ilMailFormGUI.php b/components/ILIAS/Mail/classes/class.ilMailFormGUI.php index d8ae254bbc2d..5b4100156c77 100755 --- a/components/ILIAS/Mail/classes/class.ilMailFormGUI.php +++ b/components/ILIAS/Mail/classes/class.ilMailFormGUI.php @@ -34,6 +34,7 @@ use ILIAS\Mail\RecipientSearch\UserSearchEndpointConfigurator; use ILIAS\Data\Clock\ClockFactory; use ILIAS\Data\Factory as DataFactory; +use ILIAS\Mail\Attachments\MailAttachments; use ILIAS\Mail\Folder\MailScheduleData; use ILIAS\UI\URLBuilder; use ILIAS\Data\URI; @@ -70,7 +71,7 @@ class ilMailFormGUI private readonly ilFileDataMail $mfile; private readonly GlobalHttpState $http; private readonly Refinery $refinery; - private ?array $request_attachments = null; + private ?MailAttachments $request_attachments = null; protected ilMailTemplateService $template_service; private readonly ilMailBodyPurifier $purifier; private string $mail_form_type = ''; @@ -212,30 +213,15 @@ public function executeCommand(): void } } - /** - * @param list $files - * @return list - */ - protected function decodeAttachmentFiles(array $files): array - { - $decoded_files = []; - foreach ($files as $value) { - if (is_file($this->mfile->getMailPath() . '/' . $this->user->getId() . '_' . urldecode($value))) { - $decoded_files[] = urldecode($value); - } - } - - return $decoded_files; - } public function saveMessageToOutbox(array $form_values, Form $form): void { - $files = []; - if (count($form_values['attachments']) > 0) { - $files = $this->tryHandleAttachments($form_values['attachments'], $form); - if ($files === null) { - return; - } + try { + $attachments = $this->attachmentsFromFormUpload($form_values['attachments']); + } catch (ilMailAttachmentsTotalSizeLimitExceededException $e) { + $this->tpl->setOnScreenMessage(ilGlobalTemplateInterface::MESSAGE_TYPE_FAILURE, $e->getMessage()); + $this->showForm($form); + return; } $rcp_to = ''; @@ -257,6 +243,7 @@ public function saveMessageToOutbox(array $form_values, Form $form): void $rcp_bcc, ); if ($errors) { + $this->umail->saveAttachments($attachments); $this->showSubmissionErrors($errors); $this->showForm(); $this->http->close(); @@ -280,7 +267,7 @@ public function saveMessageToOutbox(array $form_values, Form $form): void $rcp_bcc, ilUtil::securePlainString($form_values['m_subject'] ?? $this->lng->txt('mail_no_subject')), $sanitized_message, - $files, + $attachments, $form_values['use_placeholders'], $outbox_id ?? null ), @@ -296,6 +283,9 @@ public function saveMessageToOutbox(array $form_values, Form $form): void $this->umail->deleteMails([$draft_id]); } + // The scheduled mail now owns its attachments, a new mail must not inherit them + $this->resetStage(); + $this->ctrl->setParameterByClass(ilMailFolderGUI::class, 'mobj_id', $outbox_folder_id); $this->tpl->setOnScreenMessage('info', $this->lng->txt('mail_scheduled'), true); @@ -327,12 +317,12 @@ public function sendMessage(): void return; } - $files = []; - if (count($value['attachments']) > 0) { - $files = $this->tryHandleAttachments($value['attachments'], $form); - if ($files === null) { - return; - } + try { + $attachments = $this->attachmentsFromFormUpload($value['attachments']); + } catch (ilMailAttachmentsTotalSizeLimitExceededException $e) { + $this->tpl->setOnScreenMessage(ilGlobalTemplateInterface::MESSAGE_TYPE_FAILURE, $e->getMessage()); + $this->showForm($form); + return; } $mailer = $this->umail @@ -362,11 +352,12 @@ public function sendMessage(): void $rcp_bcc, ilUtil::securePlainString($value['m_subject']), (new ilMailBody($value['m_message'], $this->purifier))->getContent(), - $files, + $attachments, $value['use_placeholders'] )) { $mailer->autoresponder()->disableAutoresponder(); + $this->umail->saveAttachments($attachments); $this->showSubmissionErrors($errors); $this->showForm($form); @@ -426,12 +417,12 @@ public function saveDraft(): void if ($value['m_subject'] === '') { $value['m_subject'] = $this->lng->txt('mail_no_subject'); } - $files = []; - if (count($value['attachments']) > 0) { - $files = $this->tryHandleAttachments($value['attachments'], $form); - if ($files === null) { - return; - } + try { + $attachments = $this->attachmentsFromFormUpload($value['attachments']); + } catch (ilMailAttachmentsTotalSizeLimitExceededException $e) { + $this->tpl->setOnScreenMessage(ilGlobalTemplateInterface::MESSAGE_TYPE_FAILURE, $e->getMessage()); + $this->showForm($form); + return; } $draft_folder_id = $this->mbox->getDraftsFolder(); @@ -441,7 +432,8 @@ public function saveDraft(): void $rcp_bcc = !empty($value['rcp_bcc']) ? implode(',', $value['rcp_bcc']) : ''; if ($errors = $this->umail->validateRecipients($rcp_to, $rcp_cc, $rcp_bcc)) { - $this->request_attachments = $files; + $this->umail->saveAttachments($attachments); + $this->request_attachments = $attachments; $this->showSubmissionErrors($errors); $this->showForm($form); return; @@ -456,7 +448,7 @@ public function saveDraft(): void $this->umail->updateDraft( $draft_folder_id, - $files, + $attachments, $rcp_to, $rcp_cc, $rcp_bcc, @@ -475,6 +467,8 @@ public function saveDraft(): void $this->umail->deleteMails([$outbox_id]); } + $this->resetStage(); + $this->ctrl->setParameterByClass(ilMailFolderGUI::class, 'mobj_id', $draft_folder_id); $this->tpl->setOnScreenMessage('info', $this->lng->txt('mail_saved'), true); @@ -487,6 +481,11 @@ public function saveDraft(): void $this->showForm(); } + private function resetStage(): void + { + $this->umail->persistToStage($this->user->getId(), '', '', '', '', '', null); + } + public function searchUsers(bool $save = true): void { $this->tpl->setTitle($this->lng->txt('mail_new')); @@ -714,11 +713,26 @@ public function showForm(?Form $form = null): void break; case self::MAIL_FORM_TYPE_DRAFT: + $mail_data = $this->getOwnMailOrRedirect($mail_id); ilSession::set('draft', $mail_id); - $mail_data = $this->umail->getMail($mail_id); - if (!is_null($mail_data['attachments']) || !empty($mail_data['attachments'])) { - $mail_data['attachments'] = $this->filesFromLegacyToIRSS($mail_data); + if ($mail_data['attachments'] instanceof MailAttachments && !$mail_data['attachments']->isEmpty()) { + $stage_attachments = $this->stageAttachmentsFromMailAttachments($mail_data['attachments']); + $this->umail->persistToStage( + $this->user->getId(), + (string) ($mail_data['rcp_to'] ?? ''), + (string) ($mail_data['rcp_cc'] ?? ''), + (string) ($mail_data['rcp_bcc'] ?? ''), + (string) ($mail_data['m_subject'] ?? ''), + (string) ($mail_data['m_message'] ?? ''), + $stage_attachments, + (bool) ($mail_data['use_placeholders'] ?? false), + $mail_data['tpl_ctx_id'] ?? null, + (array) ($mail_data['tpl_ctx_params'] ?? []) + ); + $mail_data['attachments'] = $this->formRidsFromMailAttachments($stage_attachments); + } else { + $mail_data['attachments'] = []; } ilMailFormCall::setContextId($mail_data['tpl_ctx_id']); @@ -726,8 +740,28 @@ public function showForm(?Form $form = null): void break; case self::MAIL_FORM_TYPE_OUTBOX: + $mail_data = $this->getOwnMailOrRedirect($mail_id); ilSession::set('outbox', $mail_id); - $mail_data = $this->umail->getMail($mail_id); + + if ($mail_data['attachments'] instanceof MailAttachments && !$mail_data['attachments']->isEmpty()) { + $stage_attachments = $this->stageAttachmentsFromMailAttachments($mail_data['attachments']); + $this->umail->persistToStage( + $this->user->getId(), + (string) ($mail_data['rcp_to'] ?? ''), + (string) ($mail_data['rcp_cc'] ?? ''), + (string) ($mail_data['rcp_bcc'] ?? ''), + (string) ($mail_data['m_subject'] ?? ''), + (string) ($mail_data['m_message'] ?? ''), + $stage_attachments, + (bool) ($mail_data['use_placeholders'] ?? false), + $mail_data['tpl_ctx_id'] ?? null, + (array) ($mail_data['tpl_ctx_params'] ?? []) + ); + $mail_data['attachments'] = $this->formRidsFromMailAttachments($stage_attachments); + } else { + $mail_data['attachments'] = []; + } + ilMailFormCall::setContextId($mail_data['tpl_ctx_id']); ilMailFormCall::setContextParameters($mail_data['tpl_ctx_params']); break; @@ -737,15 +771,26 @@ public function showForm(?Form $form = null): void $mail_data['rcp_to'] = $mail_data['rcp_cc'] = $mail_data['rcp_bcc'] = ''; $mail_data['m_subject'] = $this->umail->formatForwardSubject($mail_data['m_subject'] ?? ''); $mail_data['m_message'] = $this->umail->prependSignature($mail_data['m_message'] ?? ''); - if (is_array($mail_data['attachments']) && count($mail_data['attachments']) && $error = $this->mfile->adoptAttachments( - $mail_data['attachments'], - $mail_id - )) { - $this->tpl->setOnScreenMessage('info', $error); - } - - if (!is_null($mail_data['attachments']) || ($mail_data['attachments'] != '')) { - $mail_data['attachments'] = $this->filesFromLegacyToIRSS($mail_data); + if ($mail_data['attachments'] instanceof MailAttachments && !$mail_data['attachments']->isEmpty()) { + $stage_attachments = $mail_data['attachments']->isLegacy() + ? $this->fdm->createStageFromLegacyMailAttachments( + (int) $mail_id, + $mail_data['attachments']->legacyFilenames() + ) + : $this->stageAttachmentsFromMailAttachments($mail_data['attachments']); + $this->umail->persistToStage( + $this->user->getId(), + '', + '', + '', + $mail_data['m_subject'] ?? '', + $mail_data['m_message'] ?? '', + $stage_attachments, + false + ); + $mail_data['attachments'] = $this->formRidsFromMailAttachments($stage_attachments); + } else { + $mail_data['attachments'] = []; } break; @@ -847,8 +892,8 @@ public function showForm(?Form $form = null): void } } - if ($this->request_attachments) { - $mail_data['attachments'] = $this->request_attachments; + if ($this->request_attachments instanceof MailAttachments && $this->request_attachments->isIrss()) { + $mail_data['attachments'] = $this->FilesFromIRSSToLegacy($this->request_attachments->rcid()); } break; } @@ -881,6 +926,24 @@ public function showForm(?Form $form = null): void $this->tpl->printToStdout(); } + /** + * @return array + */ + private function getOwnMailOrRedirect(int $mail_id): array + { + $mail_data = $this->umail->getMail($mail_id); + if ($mail_data === null) { + $this->tpl->setOnScreenMessage( + ilGlobalTemplateInterface::MESSAGE_TYPE_FAILURE, + $this->lng->txt('permission_denied'), + true + ); + $this->ctrl->redirectByClass(ilMailGUI::class); + } + + return $mail_data; + } + public function lookupRecipientAsync(): void { $search = trim((string) $this->getBodyParam( @@ -943,16 +1006,12 @@ private function saveMailBeforeSearch(?array $input_results = null): void $result = $input_results; } - $resource_collection_id = null; + $stage_attachments = null; if (!empty($result['attachments']->getValue())) { - try { - $files = $this->handleAttachments($result['attachments']->getValue()); - } catch (ilMailAttachmentsTotalSizeLimitExceededException $e) { - $this->tpl->setOnScreenMessage(ilGlobalTemplateInterface::MESSAGE_TYPE_FAILURE, $e->getMessage()); - $this->showForm(); + $stage_attachments = $this->tryHandleAttachments($result['attachments']->getValue()); + if ($stage_attachments === null) { return; } - $resource_collection_id = $this->getIdforCollection($files); } $rcp_to = implode(',', $result['rcp_to']->getValue() ?? []); @@ -966,7 +1025,7 @@ private function saveMailBeforeSearch(?array $input_results = null): void $rcp_bcc, ilUtil::securePlainString($result['m_subject']->getValue()), ilUtil::securePlainString($result['m_message']->getValue()), - $resource_collection_id, + $stage_attachments, (bool) $result['use_placeholders']->getValue(), ilMailFormCall::getContextId(), ilMailFormCall::getContextParameters() @@ -982,13 +1041,12 @@ public function searchMailingListsTo(): void } /** - * @param array $attachments - * @return list|null + * @param list $attachments */ - protected function tryHandleAttachments(array $attachments, ?Form $form = null): ?array + protected function tryHandleAttachments(array $attachments, ?Form $form = null): ?MailAttachments { try { - return $this->handleAttachments($attachments); + return $this->attachmentsFromFormUpload($attachments); } catch (ilMailAttachmentsTotalSizeLimitExceededException $e) { $this->tpl->setOnScreenMessage(ilGlobalTemplateInterface::MESSAGE_TYPE_FAILURE, $e->getMessage()); $this->showForm($form); @@ -1027,6 +1085,7 @@ private function getUserSearchConfigurator(): \ILIAS\User\Search\EndpointConfigu protected function buildFormElements(?array $mail_data): array { + $mail_data ??= []; $ff = $this->ui_factory->input()->field(); $rcp_to = $this->user_search->getInput( @@ -1059,7 +1118,6 @@ protected function buildFormElements(?array $mail_data): array } } - $has_files = !empty($mail_data['attachments']); $attachments = $ff->file( $this->upload_handler, $this->lng->txt('attachments') @@ -1070,11 +1128,16 @@ protected function buildFormElements(?array $mail_data): array $attachments = $attachments->withMaxFileSize((int) $attachment_total_size_limit); } - if (isset($mail_data['attachments']) && $has_files) { - if ($mail_data['attachments'] instanceof \ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification) { - $mail_data['attachments'] = $this->FilesFromIRSSToLegacy($mail_data['attachments']); - } - $attachments = $attachments->withValue($mail_data['attachments'] ?? []); + $mail_attachments = $mail_data['attachments'] ?? null; + if ($mail_attachments instanceof MailAttachments && !$mail_attachments->isEmpty()) { + // Legacy pool attachments are converted to IRSS once when the mail is read (ilMail::fetchMailData), + // creating collections while rendering would leave an orphaned copy behind on every request + $mail_data['attachments'] = $mail_attachments->isIrss() + ? $this->FilesFromIRSSToLegacy($mail_attachments->rcid()) + : []; + $attachments = $attachments->withValue($mail_data['attachments']); + } elseif (is_array($mail_attachments) && $mail_attachments !== []) { + $attachments = $attachments->withValue($mail_attachments); } $template_chb = null; diff --git a/components/ILIAS/Mail/classes/class.ilMailFormUploadHandlerGUI.php b/components/ILIAS/Mail/classes/class.ilMailFormUploadHandlerGUI.php index b7101848cc24..03d6d93272fb 100644 --- a/components/ILIAS/Mail/classes/class.ilMailFormUploadHandlerGUI.php +++ b/components/ILIAS/Mail/classes/class.ilMailFormUploadHandlerGUI.php @@ -19,6 +19,7 @@ declare(strict_types=1); use ILIAS\FileUpload\DTO\UploadResult; +use ILIAS\Mail\Attachments\MailAttachments; use ILIAS\FileUpload\Handler\AbstractCtrlAwareUploadHandler; use ILIAS\FileUpload\Handler\BasicFileInfoResult; use ILIAS\FileUpload\Handler\BasicHandlerResult; @@ -33,11 +34,14 @@ class ilMailFormUploadHandlerGUI extends AbstractCtrlAwareUploadHandler private readonly ILIAS\ResourceStorage\Services $storage; private readonly ilMailAttachmentStakeholder $stakeholder; private readonly ilLanguage $lng; + private readonly int $user_id; + private ?ilFileDataMail $mail_files = null; public function __construct() { global $DIC; parent::__construct(); + $this->user_id = $DIC->user()->getId(); $this->storage = $DIC->resourceStorage(); $this->stakeholder = new ilMailAttachmentStakeholder(); $this->lng = $DIC->language(); @@ -53,6 +57,9 @@ protected function getUploadResult(): HandlerResultInterface $result = end($array); if ($result instanceof UploadResult && $result->isOK()) { $i = $this->storage->manage()->upload($result, $this->stakeholder); + new ilFormatMail($this->user_id)->saveAttachments( + MailAttachments::fromIrss($this->mailFiles()->addResourceToStage($i)) + ); $status = HandlerResultInterface::STATUS_OK; $identifier = $i->serialize(); $message = $this->lng->txt('upload_ok'); @@ -67,9 +74,8 @@ protected function getUploadResult(): HandlerResultInterface protected function getRemoveResult(string $identifier): HandlerResultInterface { - $id = $this->storage->manage()->find($identifier); - if ($id !== null) { - $this->storage->manage()->remove($id, $this->stakeholder); + $id = $this->acceptedComposeResource($identifier); + if ($id !== null && $this->mailFiles()->removeFromStage($id)) { return new BasicHandlerResult($this->getFileIdentifierParameterName(), HandlerResultInterface::STATUS_OK, $identifier, 'file deleted'); } @@ -78,7 +84,7 @@ protected function getRemoveResult(string $identifier): HandlerResultInterface public function getInfoResult(string $identifier): FileInfoResult { - $id = $this->storage->manage()->find($identifier); + $id = $this->acceptedComposeResource($identifier); if ($id === null) { return new BasicFileInfoResult($this->getFileIdentifierParameterName(), 'unknown', 'unknown', 0, 'unknown'); } @@ -91,7 +97,7 @@ public function getInfoForExistingFiles(array $file_ids): array { $infos = []; foreach ($file_ids as $file_id) { - $id = $this->storage->manage()->find($file_id); + $id = $this->acceptedComposeResource($file_id); if ($id === null) { continue; } @@ -105,7 +111,7 @@ public function getInfoForExistingFiles(array $file_ids): array public function getInlineConsumer(string $identifier): InlineConsumer { - return $this->storage->consume()->inline(new ResourceIdentification($identifier)); + return $this->storage->consume()->inline($this->requireComposeResource($identifier)); } public function removeFileForIdentifier(string $identifier): HandlerResultInterface @@ -115,6 +121,31 @@ public function removeFileForIdentifier(string $identifier): HandlerResultInterf public function getStreamConsumer(string $identifier): FileStreamConsumer { - return $this->storage->consume()->stream(new ResourceIdentification($identifier)); + return $this->storage->consume()->stream($this->requireComposeResource($identifier)); + } + + private function mailFiles(): ilFileDataMail + { + return $this->mail_files ??= new ilFileDataMail($this->user_id); + } + + private function acceptedComposeResource(string $identifier): ?ResourceIdentification + { + $id = $this->storage->manage()->find($identifier); + if ($id === null || !$this->mailFiles()->canUseResourceInCompose($id)) { + return null; + } + + return $id; + } + + private function requireComposeResource(string $identifier): ResourceIdentification + { + $id = $this->acceptedComposeResource($identifier); + if ($id === null) { + throw new RuntimeException('Mail attachment is not available.'); + } + + return $id; } } diff --git a/components/ILIAS/Mail/classes/class.ilMailNotification.php b/components/ILIAS/Mail/classes/class.ilMailNotification.php index 0b79f5d3313d..e562d9b44ed4 100755 --- a/components/ILIAS/Mail/classes/class.ilMailNotification.php +++ b/components/ILIAS/Mail/classes/class.ilMailNotification.php @@ -18,6 +18,8 @@ declare(strict_types=1); +use ILIAS\Mail\Attachments\MailAttachments; + abstract class ilMailNotification { final public const int SUBJECT_TITLE_LENGTH = 60; @@ -27,7 +29,7 @@ abstract class ilMailNotification protected ?ilMail $mail = null; protected string $subject = ''; protected string $body = ''; - protected array $attachments = []; + protected MailAttachments $attachments; protected ilLanguage $language; protected array $lang_modules = []; protected array $recipients = []; @@ -41,6 +43,7 @@ abstract class ilMailNotification public function __construct(protected bool $is_in_wsp = false) { global $DIC; + $this->attachments = MailAttachments::empty(); $this->setSender(ANONYMOUS_USER_ID); $this->language = ilLanguageFactory::_getLanguage($DIC->language()->getDefaultLanguage()); @@ -109,12 +112,12 @@ public function getRecipients(): array return $this->recipients; } - public function setAttachments(array $a_att): void + public function setAttachments(MailAttachments $attachments): void { - $this->attachments = $a_att; + $this->attachments = $attachments; } - public function getAttachments(): array + public function getAttachments(): MailAttachments { return $this->attachments; } @@ -251,6 +254,11 @@ protected function initMail(): ilMail return $this->mail = new ilMail($this->getSender()); } + protected function initFileDataMail(): ilFileDataMail + { + return new ilFileDataMail($this->getSender()); + } + protected function getMail(): ilMail { return is_object($this->mail) ? $this->mail : $this->initMail(); diff --git a/components/ILIAS/Mail/classes/class.ilMailbox.php b/components/ILIAS/Mail/classes/class.ilMailbox.php index 86122a93e9ca..1354ecc40894 100755 --- a/components/ILIAS/Mail/classes/class.ilMailbox.php +++ b/components/ILIAS/Mail/classes/class.ilMailbox.php @@ -380,6 +380,9 @@ public function getUsrId(): int public function delete(): void { + $fdm = new ilFileDataMail($this->usr_id); + $fdm->onUserDelete(); + $this->db->manipulateF( 'DELETE FROM mail_obj_data WHERE user_id = %s', ['integer'], @@ -410,11 +413,6 @@ public function delete(): void [$this->usr_id, $this->usr_id] ); - // Delete the user's files from filesystem: - // This has to be done before deleting the database entries in table 'mail' - $fdm = new ilFileDataMail($this->usr_id); - $fdm->onUserDelete(); - // Delete mails of deleted user $this->db->manipulateF( 'DELETE FROM mail WHERE user_id = %s', diff --git a/components/ILIAS/Mail/classes/class.ilMimeMail.php b/components/ILIAS/Mail/classes/class.ilMimeMail.php index 2096848ffc18..ff8386c72084 100755 --- a/components/ILIAS/Mail/classes/class.ilMimeMail.php +++ b/components/ILIAS/Mail/classes/class.ilMimeMail.php @@ -21,6 +21,8 @@ use ILIAS\Mail\Mime\Presentation\MailBodyComposer; use ILIAS\Mail\Mime\Presentation\MailBodySource; use ILIAS\Mail\Mime\Presentation\Asset\InlineImages; +use ILIAS\Mail\Mime\MailMimeAttachment; +use ILIAS\ResourceStorage\Identification\ResourceIdentification; class ilMimeMail { @@ -42,14 +44,8 @@ class ilMimeMail /** @var string[] */ protected array $abcc = []; protected InlineImages $images; - /** @var string[] */ - protected array $aattach = []; - /** @var string[] */ - protected array $actype = []; - /** @var string[] */ - protected array $adispo = []; - /** @var string[] */ - protected array $adisplay = []; + /** @var list */ + protected array $mime_attachments = []; private ?MailBodyComposer $body_composer; /** @var Closure(string): string|null */ private ?Closure $to_html_transformation = null; @@ -204,35 +200,53 @@ public function Attach( string $disposition = 'inline', ?string $display_name = null ): void { - if ($file_type === '') { - $file_type = 'application/octet-stream'; - } + $this->mime_attachments[] = MailMimeAttachment::fromPath( + $filename, + $file_type, + $disposition, + $display_name + ); + } - $this->aattach[] = $filename; - $this->actype[] = $file_type; - $this->adispo[] = $disposition; - $this->adisplay[] = $display_name; + public function AttachResource( + ResourceIdentification $resource_identification, + string $display_name, + string $file_type = '', + string $disposition = 'inline' + ): void { + $this->mime_attachments[] = MailMimeAttachment::fromResource( + $resource_identification, + $display_name, + $file_type, + $disposition + ); + } + + /** + * @return list + */ + public function getMimeAttachments(): array + { + return $this->mime_attachments; } /** * @return array{path: string, name: string}[] + * @deprecated Use getMimeAttachments() for path and IRSS attachments. */ public function getAttachments(): array { $attachments = []; - $i = 0; - foreach ($this->aattach as $attachment) { - $name = ''; - if (isset($this->adisplay[$i]) && is_string($this->adisplay[$i]) && $this->adisplay[$i] !== '') { - $name = $this->adisplay[$i]; + foreach ($this->mime_attachments as $attachment) { + if ($attachment->isResource() || $attachment->getPath() === null) { + continue; } $attachments[] = [ - 'path' => $attachment, - 'name' => $name + 'path' => $attachment->getPath(), + 'name' => $attachment->getDisplayName(), ]; - ++$i; } return $attachments; diff --git a/components/ILIAS/Mail/classes/class.ilObjMailGUI.php b/components/ILIAS/Mail/classes/class.ilObjMailGUI.php index ae711061898a..e38f01d63d27 100755 --- a/components/ILIAS/Mail/classes/class.ilObjMailGUI.php +++ b/components/ILIAS/Mail/classes/class.ilObjMailGUI.php @@ -19,6 +19,7 @@ declare(strict_types=1); use ILIAS\Mail\Autoresponder\AutoresponderService; +use ILIAS\Mail\Attachments\MailAttachments; use ILIAS\Mail\Signature\MailUserSignature; use ILIAS\Mail\Signature\Signature; use ILIAS\Mail\Signature\MailInstallationSignature; @@ -388,7 +389,7 @@ protected function sendTestMail(bool $is_manual_mail = false): void '', $this->lng->txt('mail_email_' . $lng_variable_prefix . '_subject'), $this->lng->txt('mail_email_' . $lng_variable_prefix . '_body'), - [] + MailAttachments::empty() ); $this->tpl->setOnScreenMessage('success', $this->lng->txt('mail_external_test_sent')); diff --git a/components/ILIAS/Mail/src/Attachments/MailAttachments.php b/components/ILIAS/Mail/src/Attachments/MailAttachments.php new file mode 100644 index 000000000000..23551cc273ed --- /dev/null +++ b/components/ILIAS/Mail/src/Attachments/MailAttachments.php @@ -0,0 +1,173 @@ + */ + private readonly array $legacy_filenames, + ) { + } + + public static function empty(): self + { + return new self(null, []); + } + + public static function fromIrss(ResourceCollectionIdentification $rcid): self + { + return new self($rcid, []); + } + + /** + * Legacy rows come from unserialize() and may contain non-strings. + * Those entries are dropped so one bad value does not abort the migration. + * + * @param array $filenames + */ + public static function fromLegacyFilenames(array $filenames): self + { + $names = []; + foreach ($filenames as $filename) { + if (is_string($filename)) { + $names[] = $filename; + } + } + + if ($names === []) { + return self::empty(); + } + + return new self(null, $names); + } + + public static function fromDb(?string $raw): ?self + { + if ($raw === null || $raw === '') { + return null; + } + + if (str_starts_with($raw, 'a:')) { + $unserialized = @unserialize($raw, ['allowed_classes' => false]); + if (!is_array($unserialized)) { + return null; + } + + if (isset($unserialized[self::SERIALIZED_RCID_KEY])) { + return self::fromIrss( + new ResourceCollectionIdentification((string) $unserialized[self::SERIALIZED_RCID_KEY]) + ); + } + + return self::fromLegacyFilenames($unserialized); + } + + // Very old rows may contain other serialized values (e.g. "N;"), which are no collection + if (preg_match(self::RCID_PATTERN, $raw) !== 1) { + return null; + } + + return self::fromIrss(new ResourceCollectionIdentification($raw)); + } + + public static function fromBackgroundTask(string $serialized): self + { + $parsed = @unserialize($serialized, ['allowed_classes' => false]); + if (!is_array($parsed)) { + return self::empty(); + } + + if (isset($parsed[self::SERIALIZED_RCID_KEY])) { + return self::fromIrss( + new ResourceCollectionIdentification((string) $parsed[self::SERIALIZED_RCID_KEY]) + ); + } + + return self::fromLegacyFilenames($parsed); + } + + public function isEmpty(): bool + { + return $this->rcid === null && $this->legacy_filenames === []; + } + + public function isIrss(): bool + { + return $this->rcid !== null; + } + + public function isLegacy(): bool + { + return $this->rcid === null && $this->legacy_filenames !== []; + } + + public function rcid(): ResourceCollectionIdentification + { + if ($this->rcid === null) { + throw new InvalidArgumentException('Mail attachments are not stored in IRSS.'); + } + + return $this->rcid; + } + + /** + * @return list + */ + public function legacyFilenames(): array + { + if ($this->isIrss()) { + throw new InvalidArgumentException('Mail attachments are not legacy filenames.'); + } + + return $this->legacy_filenames; + } + + public function toDb(): string + { + if ($this->isIrss()) { + return $this->rcid->serialize(); + } + + return serialize($this->legacy_filenames); + } + + public function toBackgroundTask(): string + { + if ($this->isIrss()) { + return serialize([self::SERIALIZED_RCID_KEY => $this->rcid->serialize()]); + } + + return serialize($this->legacy_filenames); + } + + public function stageRcidOrNull(): ?ResourceCollectionIdentification + { + return $this->rcid; + } +} diff --git a/components/ILIAS/Mail/src/Mime/MailMimeAttachment.php b/components/ILIAS/Mail/src/Mime/MailMimeAttachment.php new file mode 100644 index 000000000000..768d8841f49e --- /dev/null +++ b/components/ILIAS/Mail/src/Mime/MailMimeAttachment.php @@ -0,0 +1,95 @@ +resource_identification !== null; + } + + public function getPath(): ?string + { + return $this->path; + } + + public function getResourceIdentification(): ?ResourceIdentification + { + return $this->resource_identification; + } + + public function getMimeType(): string + { + return $this->mime_type; + } + + public function getDisposition(): string + { + return $this->disposition; + } + + public function getDisplayName(): string + { + return $this->display_name; + } +} diff --git a/components/ILIAS/Mail/src/Setup/Database/V12/MailDBUpdateSteps12.php b/components/ILIAS/Mail/src/Setup/Database/V12/MailDBUpdateSteps12.php new file mode 100644 index 000000000000..19d4e66e2918 --- /dev/null +++ b/components/ILIAS/Mail/src/Setup/Database/V12/MailDBUpdateSteps12.php @@ -0,0 +1,75 @@ +db = $db; + } + + public function step_1(): void + { + if (!$this->db->tableColumnExists('mail_attachment', 'rcid')) { + $this->db->addTableColumn( + 'mail_attachment', + 'rcid', + [ + 'type' => ilDBConstants::T_TEXT, + 'length' => 64, + 'notnull' => false, + 'default' => null, + ] + ); + } + } + + /** + * Attachment collections are reference counted via `mail_attachment.rcid` + */ + public function step_2(): void + { + if (!$this->db->indexExistsByFields('mail_attachment', ['rcid'])) { + $this->db->addIndex('mail_attachment', ['rcid'], 'rci'); + } + } + + /** + * Mails without attachments stored an empty serialized list, which the attachment migration + * would otherwise have to visit row by row + */ + public function step_3(): void + { + $this->db->manipulateF( + 'UPDATE mail SET attachments = NULL WHERE attachments = %s', + [ilDBConstants::T_TEXT], + [serialize([])] + ); + } +} diff --git a/components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php b/components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php new file mode 100644 index 000000000000..8da9955b55c9 --- /dev/null +++ b/components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php @@ -0,0 +1,607 @@ + */ + private array $statistics = []; + + public function getLabel(): string + { + return 'Migrate Mail Attachments to IRSS'; + } + + public function getDefaultAmountOfStepsPerRun(): int + { + return 10; + } + + public function getPreconditions(Environment $environment): array + { + return array_merge( + ilResourceStorageMigrationHelper::getPreconditions(), + [new ilDatabaseUpdateStepsExecutedObjective(new MailDBUpdateSteps12())] + ); + } + + public function prepare(Environment $environment): void + { + $this->io = $this->resolveAdminInteraction($environment); + $this->helper = new ilResourceStorageMigrationHelper( + new ilMailAttachmentStakeholder(), + $environment + ); + } + + public function step(Environment $environment): void + { + $this->io ??= $this->resolveAdminInteraction($environment); + $this->step_number++; + $this->statistics = []; + $started = microtime(true); + + try { + $this->migrateSentAttachmentDirectories(); + $this->migrateSerializedMailAttachments(); + } catch (Throwable $e) { + $this->error('Migration aborted', ['step' => $this->step_number], $e); + + throw $e; + } + + if ($this->statistics !== []) { + $this->info('Step finished', array_merge( + ['step' => $this->step_number, 'seconds' => round(microtime(true) - $started, 2)], + $this->statistics + )); + } + } + + // ----------------------------------------------------------------------------------------------------------- + // Directories of sent and received mails + // ----------------------------------------------------------------------------------------------------------- + + /** + * Sent and received mails share one directory per sending process. All mails referencing + * a directory get the same collection, the `mail_attachment` rows are used for reference counting. + */ + private function migrateSentAttachmentDirectories(): void + { + $mail_path = rtrim($this->helper->getClientDataDir(), '/') . '/mail'; + + foreach ($this->nextUnmigratedPaths() as $relative_path) { + $absolute_path = $mail_path . '/' . $relative_path; + if (trim($relative_path, '/. ') === '' || str_contains($relative_path, '..')) { + $this->skipPath($relative_path, 'Invalid directory name'); + + continue; + } + + if (!is_dir($absolute_path)) { + $this->skipPath($relative_path, 'Directory does not exist'); + + continue; + } + + $filenames = $this->listDirectoryFiles($absolute_path); + if ($filenames === []) { + $this->skipPath($relative_path, 'No file of the directory could be stored'); + + continue; + } + + $owner_id = $this->resolveOwnerIdForPath($relative_path); + $rcid = $this->copyDirectoryToCollection($absolute_path, $filenames, $owner_id); + if ($rcid === null) { + $this->warning('Directory was not migrated because not every file could be stored', [ + 'path' => $relative_path, + 'files' => count($filenames), + ]); + + continue; + } + + $this->markCollectionResourcesConfidential($rcid); + $this->assignRcidToPath($relative_path, $rcid); + $this->updateMailAttachmentFields($relative_path, $rcid); + $this->deleteMigratedDirectory($absolute_path); + $this->count('directories_migrated'); + } + } + + /** + * Files stay on disk until the collection is stored and the mail rows point at it. + * A rerun can therefore still read them when this step stops in between. + * + * @param list $filenames + */ + private function copyDirectoryToCollection( + string $absolute_path, + array $filenames, + int $owner_id + ): ?ResourceCollectionIdentification { + $collection = $this->helper->getCollectionBuilder()->new($owner_id); + $copies = []; + foreach ($filenames as $file_name) { + $resource_id = $this->helper->movePathToStorage( + $absolute_path . '/' . $file_name, + $owner_id, + null, + static fn(string $name): string => md5($name), + true + ); + if (!$resource_id instanceof ResourceIdentification) { + $this->discardResources($copies); + + return null; + } + $copies[] = $resource_id; + $collection->add($resource_id); + } + + if ($collection->count() !== count($filenames) + || !$this->helper->getCollectionBuilder()->store($collection)) { + $this->discardResources($copies); + + return null; + } + + return $collection->getIdentification(); + } + + /** + * Copies of a directory that could not be migrated completely are removed, the next run copies it again + * + * @param list $resource_ids + */ + private function discardResources(array $resource_ids): void + { + foreach ($resource_ids as $resource_id) { + $this->helper->getManager()->remove($resource_id, $this->helper->getStakeholder()); + } + } + + /** + * @return list + */ + private function listDirectoryFiles(string $absolute_path): array + { + $filenames = []; + foreach (new \DirectoryIterator($absolute_path) as $file_info) { + if (!$file_info->isFile()) { + continue; + } + $filenames[] = $file_info->getFilename(); + } + sort($filenames); + + return $filenames; + } + + private function deleteMigratedDirectory(string $absolute_path): void + { + foreach ($this->listDirectoryFiles($absolute_path) as $file_name) { + unlink($absolute_path . '/' . $file_name); + } + rmdir($absolute_path); + } + + /** + * @return list Paths of the most recently sent mails first + */ + private function nextUnmigratedPaths(): array + { + $db = $this->helper->getDatabase(); + $db->setLimit(self::PATHS_PER_STEP); + $res = $db->query( + 'SELECT ma.path FROM mail_attachment ma + LEFT JOIN mail m ON m.mail_id = ma.mail_id + WHERE (ma.rcid IS NULL OR ma.rcid = "") + AND ma.path IS NOT NULL AND ma.path != "" + GROUP BY ma.path + ORDER BY MAX(m.send_time) DESC' + ); + + $paths = []; + while ($row = $db->fetchAssoc($res)) { + $paths[] = (string) $row['path']; + } + + return $paths; + } + + private function skipPath(string $relative_path, string $reason): void + { + $mail_ids = $this->mailIdsOfPath($relative_path); + $this->markPathAsSkipped($relative_path); + $this->count('directories_skipped'); + + $this->warning('Directory skipped: ' . $reason, [ + 'path' => $relative_path, + 'mails' => count($mail_ids), + 'mail_ids' => array_slice($mail_ids, 0, 20), + ]); + } + + /** + * @return list + */ + private function mailIdsOfPath(string $relative_path): array + { + $db = $this->helper->getDatabase(); + $res = $db->queryF( + 'SELECT mail_id FROM mail_attachment WHERE path = %s', + [ilDBConstants::T_TEXT], + [$relative_path] + ); + + $mail_ids = []; + while ($row = $db->fetchAssoc($res)) { + $mail_ids[] = (int) $row['mail_id']; + } + + return $mail_ids; + } + + // ----------------------------------------------------------------------------------------------------------- + // Drafts and scheduled mails referencing the attachment pool + // ----------------------------------------------------------------------------------------------------------- + + /** + * Drafts and scheduled mails reference files of the user's attachment pool. Mails referencing a directory + * are handled by migrateSentAttachmentDirectories() and must never be treated as pool references. + */ + private function migrateSerializedMailAttachments(): void + { + $db = $this->helper->getDatabase(); + $db->setLimit(self::MAILS_PER_STEP); + $res = $db->query( + 'SELECT m.mail_id, m.user_id, m.attachments, fo.m_type FROM mail m + LEFT JOIN mail_attachment ma ON ma.mail_id = m.mail_id + LEFT JOIN mail_obj_data fo ON fo.obj_id = m.folder_id + WHERE ma.mail_id IS NULL + AND m.attachments LIKE ' . $db->quote('a:%', ilDBConstants::T_TEXT) . ' + ORDER BY m.send_time DESC, m.mail_id DESC' + ); + + $mail_path = rtrim($this->helper->getClientDataDir(), '/') . '/mail'; + while ($row = $db->fetchAssoc($res)) { + $this->migrateSerializedMail( + (int) $row['mail_id'], + (int) $row['user_id'], + (string) $row['attachments'], + $mail_path, + in_array( + $row['m_type'] ?? null, + [MailFolderType::DRAFTS->value, MailFolderType::OUTBOX->value], + true + ) + ); + } + } + + private function migrateSerializedMail( + int $mail_id, + int $user_id, + string $raw_attachments, + string $mail_path, + bool $references_pool + ): void { + $attachments = MailAttachments::fromDb($raw_attachments); + if ($attachments === null || !$attachments->isLegacy()) { + if ($attachments === null) { + $this->warning('Attachments of mail could not be read and have been removed', [ + 'mail_id' => $mail_id, + 'value' => substr($raw_attachments, 0, 200), + ]); + } + $this->clearMailAttachmentsColumn($mail_id); + + return; + } + + // Sent and received mails reference a directory. Without one, files of the owner's pool must not be attached. + if (!$references_pool) { + $this->warning('Mail outside drafts and outbox has no attachment directory, its attachments have been removed', [ + 'mail_id' => $mail_id, + 'files' => $attachments->legacyFilenames(), + ]); + $this->clearMailAttachmentsColumn($mail_id); + + return; + } + + $rcid = $this->migratePoolFilenamesToCollection($attachments->legacyFilenames(), $user_id, $mail_path, $mail_id); + if ($rcid === null) { + $this->warning('None of the pool files referenced by the mail exists, its attachments have been removed', [ + 'mail_id' => $mail_id, + 'files' => $attachments->legacyFilenames(), + ]); + $this->clearMailAttachmentsColumn($mail_id); + + return; + } + + $db = $this->helper->getDatabase(); + $db->manipulateF( + 'INSERT INTO mail_attachment (mail_id, path, rcid) VALUES (%s, %s, %s)', + [ilDBConstants::T_INTEGER, ilDBConstants::T_TEXT, ilDBConstants::T_TEXT], + [$mail_id, '', $rcid->serialize()] + ); + $db->update( + 'mail', + ['attachments' => [ilDBConstants::T_CLOB, $rcid->serialize()]], + ['mail_id' => [ilDBConstants::T_INTEGER, $mail_id]] + ); + $this->count('drafts_migrated'); + } + + /** + * Pool files are copied, not moved: The same pool file may be referenced by several drafts and stays + * available in the user's pool, where it is migrated when it is used the next time. + * + * @param list $filenames + */ + private function migratePoolFilenamesToCollection( + array $filenames, + int $user_id, + string $mail_path, + int $mail_id + ): ?ResourceCollectionIdentification { + $collection = $this->helper->getCollectionBuilder()->new($user_id); + + foreach ($filenames as $filename) { + $absolute_path = $mail_path . '/' . $user_id . '_' . $filename; + if ($filename === '' || str_contains($filename, '/') || str_contains($filename, '\\') || !is_file($absolute_path)) { + $this->warning('Pool file referenced by the mail does not exist', [ + 'mail_id' => $mail_id, + 'file' => $filename, + ]); + + continue; + } + + // Pool files are stored as "_" on disk, the users only know the name + $resource_id = $this->helper->movePathToStorage( + $absolute_path, + $user_id, + static fn(): string => $filename, + static fn(): string => md5($filename), + true + ); + + if ($resource_id instanceof ResourceIdentification) { + $this->markConfidential($resource_id); + $collection->add($resource_id); + } + } + + if ($collection->count() === 0) { + return null; + } + + if (!$this->helper->getCollectionBuilder()->store($collection)) { + $this->discardResources(array_values($collection->getResourceIdentifications())); + + return null; + } + + return $collection->getIdentification(); + } + + private function markConfidential(ResourceIdentification $identification): void + { + $this->helper->getManager()->setConfidential($identification, true); + } + + private function markCollectionResourcesConfidential(ResourceCollectionIdentification $rcid): void + { + foreach ($this->helper->getCollectionBuilder()->get($rcid)->getResourceIdentifications() as $identification) { + $this->markConfidential($identification); + } + } + + private function clearMailAttachmentsColumn(int $mail_id): void + { + $this->helper->getDatabase()->update( + 'mail', + [ + 'attachments' => [ilDBConstants::T_CLOB, ''], + ], + [ + 'mail_id' => [ilDBConstants::T_INTEGER, $mail_id], + ] + ); + } + + public function getRemainingAmountOfSteps(): int + { + $db = $this->helper->getDatabase(); + + $path_count = (int) $db->fetchAssoc( + $db->query( + 'SELECT COUNT(DISTINCT path) cnt FROM mail_attachment + WHERE (rcid IS NULL OR rcid = "") + AND path IS NOT NULL AND path != ""' + ) + )['cnt']; + + $mail_count = (int) $db->fetchAssoc( + $db->query( + 'SELECT COUNT(m.mail_id) cnt FROM mail m + LEFT JOIN mail_attachment ma ON ma.mail_id = m.mail_id + WHERE ma.mail_id IS NULL + AND m.attachments LIKE ' . $db->quote('a:%', ilDBConstants::T_TEXT) + ) + )['cnt']; + + $remaining = max( + (int) ceil($path_count / self::PATHS_PER_STEP), + (int) ceil($mail_count / self::MAILS_PER_STEP) + ); + + $this->info('Remaining', [ + 'directories' => $path_count, + 'drafts' => $mail_count, + 'steps' => $remaining, + ]); + + return $remaining; + } + + private function resolveOwnerIdForPath(string $relative_path): int + { + $db = $this->helper->getDatabase(); + $db->setLimit(1, 0); + $res = $db->queryF( + 'SELECT m.sender_id FROM mail_attachment ma + INNER JOIN mail m ON m.mail_id = ma.mail_id + WHERE ma.path = %s + ORDER BY m.send_time ASC', + [ilDBConstants::T_TEXT], + [$relative_path] + ); + + $row = $db->fetchAssoc($res); + if (is_array($row) && (int) $row['sender_id'] > 0) { + return (int) $row['sender_id']; + } + + return defined('SYSTEM_USER_ID') ? (int) SYSTEM_USER_ID : 6; + } + + private function assignRcidToPath(string $relative_path, ResourceCollectionIdentification $rcid): void + { + $this->helper->getDatabase()->manipulateF( + 'UPDATE mail_attachment SET rcid = %s WHERE path = %s', + [ilDBConstants::T_TEXT, ilDBConstants::T_TEXT], + [$rcid->serialize(), $relative_path] + ); + } + + private function updateMailAttachmentFields(string $relative_path, ResourceCollectionIdentification $rcid): void + { + $mail_ids = $this->mailIdsOfPath($relative_path); + if ($mail_ids === []) { + return; + } + + $db = $this->helper->getDatabase(); + $db->manipulateF( + 'UPDATE mail SET attachments = %s WHERE ' . $db->in('mail_id', $mail_ids, false, ilDBConstants::T_INTEGER), + [ilDBConstants::T_TEXT], + [$rcid->serialize()] + ); + } + + private function markPathAsSkipped(string $relative_path): void + { + $this->helper->getDatabase()->manipulateF( + 'UPDATE mail_attachment SET rcid = %s WHERE path = %s', + [ilDBConstants::T_TEXT, ilDBConstants::T_TEXT], + [self::SKIPPED_MARKER, $relative_path] + ); + } + + private function resolveAdminInteraction(Environment $environment): ?AdminInteraction + { + $io = $environment->getResource(Environment::RESOURCE_ADMIN_INTERACTION); + + return $io instanceof AdminInteraction ? $io : null; + } + + private function count(string $key, int $amount = 1): void + { + $this->statistics[$key] = ($this->statistics[$key] ?? 0) + $amount; + } + + /** + * @param array $context + */ + private function info(string $message, array $context = []): void + { + $this->report('INFO', $message, $context); + } + + /** + * @param array $context + */ + private function warning(string $message, array $context = []): void + { + $this->report('WARNING', $message, $context); + } + + /** + * @param array $context + */ + private function error(string $message, array $context = [], ?Throwable $exception = null): void + { + if ($exception !== null) { + $context['exception'] = $exception::class . ': ' . $exception->getMessage() . + ' (' . $exception->getFile() . ':' . $exception->getLine() . ')'; + } + + $this->report('ERROR', $message, $context); + } + + /** + * @param array $context + */ + private function report(string $level, string $message, array $context): void + { + $this->io?->inform(sprintf( + '[%s] Mail attachment migration %s: %s%s', + date('Y-m-d H:i:s'), + $level, + $message, + $context !== [] + ? ' ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR) + : '' + )); + } +} diff --git a/components/ILIAS/Mail/tests/FileDataRCHandlingAttachmentsSizeTest.php b/components/ILIAS/Mail/tests/FileDataRCHandlingAttachmentsSizeTest.php index bedf2d75a418..0b5eac833b6f 100644 --- a/components/ILIAS/Mail/tests/FileDataRCHandlingAttachmentsSizeTest.php +++ b/components/ILIAS/Mail/tests/FileDataRCHandlingAttachmentsSizeTest.php @@ -18,10 +18,10 @@ declare(strict_types=1); -use ILIAS\Filesystem\Stream\FileStream; +use ILIAS\ResourceStorage\Services; use ILIAS\FileUpload\Handler\FileInfoResult; use PHPUnit\Framework\Attributes\DataProvider; -use ILIAS\ResourceStorage\Consumer\FileStreamConsumer; +use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; class FileDataRCHandlingAttachmentsSizeTest extends ilMailBaseTestCase { @@ -29,11 +29,11 @@ class FileDataRCHandlingAttachmentsSizeTest extends ilMailBaseTestCase * @param list $files */ #[DataProvider('exceededLimitProvider')] - public function testHandleAttachmentsRejectsWhenTotalSizeExceedsLimit( + public function testFormUploadRejectsWhenTotalSizeExceedsLimit( ?float $limit, array $files ): void { - $subject = $this->createSubject($limit, $files, expect_store: false); + $subject = $this->createSubject($limit, $files, expect_stage: false); $this->expectException(ilMailAttachmentsTotalSizeLimitExceededException::class); $subject->handle(array_column($files, 'id')); @@ -43,15 +43,16 @@ public function testHandleAttachmentsRejectsWhenTotalSizeExceedsLimit( * @param list $files */ #[DataProvider('acceptedLimitProvider')] - public function testHandleAttachmentsStoresWhenTotalSizeIsWithinLimit( + public function testFormUploadKeepsTheStageWhenTotalSizeIsWithinLimit( ?float $limit, array $files ): void { - $subject = $this->createSubject($limit, $files, expect_store: true); + $expected_rcid = new ResourceCollectionIdentification('stage-rcid'); + $subject = $this->createSubject($limit, $files, expect_stage: true, expected_rcid: $expected_rcid); $result = $subject->handle(array_column($files, 'id')); - $this->assertSame(array_column($files, 'id'), $result); + $this->assertSame($expected_rcid, $result->rcid()); } /** @@ -83,11 +84,15 @@ public static function acceptedLimitProvider(): array /** * @param list $files */ - private function createSubject(?float $limit, array $files, bool $expect_store): FileDataRCHandlingAttachmentsSizeTestSubject - { + private function createSubject( + ?float $limit, + array $files, + bool $expect_stage, + ?ResourceCollectionIdentification $expected_rcid = null + ): FileDataRCHandlingAttachmentsSizeTestSubject { $infos = []; foreach ($files as $file) { - $info = $this->createMock(FileInfoResult::class); + $info = $this->createStub(FileInfoResult::class); $info->method('getFileIdentifier')->willReturn($file['id']); $info->method('getSize')->willReturn($file['size']); $info->method('getName')->willReturn($file['id']); @@ -96,41 +101,41 @@ private function createSubject(?float $limit, array $files, bool $expect_store): $fdm = $this->createMock(ilFileDataMail::class); $fdm->method('getAttachmentsTotalSizeLimit')->willReturn($limit); - if ($expect_store) { - $fdm->expects($this->exactly(count($files)))->method('storeAsAttachment')->willReturnArgument(0); + if ($expect_stage) { + $fdm->expects($this->once()) + ->method('restrictStageTo') + ->with(array_column($files, 'id')) + ->willReturn($expected_rcid); } else { - $fdm->expects($this->never())->method('storeAsAttachment'); + $fdm->expects($this->never())->method('restrictStageTo'); } - $upload_handler = $this->createMock(ilMailFormUploadHandlerGUI::class); + $storage = $this->createStub(Services::class); + + $upload_handler = $this->createStub(ilMailFormUploadHandlerGUI::class); $upload_handler->method('getInfoResult')->willReturnCallback( static fn(string $id): FileInfoResult => $infos[$id] ); - if ($expect_store) { - $consumer = $this->createMock(FileStreamConsumer::class); - $consumer->method('getStream')->willReturn($this->createMock(FileStream::class)); - $upload_handler->method('getStreamConsumer')->willReturn($consumer); - $upload_handler->method('removeFileForIdentifier'); - } - $lng = $this->createMock(ilLanguage::class); + $lng = $this->createStub(ilLanguage::class); $lng->method('txt')->willReturnArgument(0); $this->setGlobalVariable('lng', $lng); - return new FileDataRCHandlingAttachmentsSizeTestSubject($fdm, $upload_handler, $lng); + return new FileDataRCHandlingAttachmentsSizeTestSubject($fdm, $upload_handler, $lng, $storage); } } class FileDataRCHandlingAttachmentsSizeTestSubject { use FileDataRCHandling { - handleAttachments as public handle; + attachmentsFromFormUpload as public handle; } public function __construct( public ilFileDataMail $fdm, public ilMailFormUploadHandlerGUI $upload_handler, - public ilLanguage $lng + public ilLanguage $lng, + public Services $storage ) { } } diff --git a/components/ILIAS/Mail/tests/FileDataRCHandlingFormUploadTest.php b/components/ILIAS/Mail/tests/FileDataRCHandlingFormUploadTest.php new file mode 100644 index 000000000000..ed971de0e8bd --- /dev/null +++ b/components/ILIAS/Mail/tests/FileDataRCHandlingFormUploadTest.php @@ -0,0 +1,91 @@ +createMock(ilFileDataMail::class); + $fdm->expects($this->never())->method('restrictStageTo'); + + $this->assertTrue($this->createSubject($fdm)->fromFormUpload([])->isEmpty()); + } + + public function testStageReducedToNothingMeansNoAttachments(): void + { + $fdm = $this->createMock(ilFileDataMail::class); + $fdm->method('getAttachmentsTotalSizeLimit')->willReturn(null); + $fdm->expects($this->once())->method('restrictStageTo')->with(['gone-rid'])->willReturn(null); + + $this->assertTrue($this->createSubject($fdm)->fromFormUpload(['gone-rid'])->isEmpty()); + } + + public function testFormUploadKeepsTheRestrictedStage(): void + { + $fdm = $this->createMock(ilFileDataMail::class); + $fdm->method('getAttachmentsTotalSizeLimit')->willReturn(null); + $fdm->expects($this->once()) + ->method('restrictStageTo') + ->with(['rid-a']) + ->willReturn(new ResourceCollectionIdentification('stage-rcid')); + + $result = $this->createSubject($fdm)->fromFormUpload(['rid-a']); + + $this->assertSame('stage-rcid', $result->rcid()->serialize()); + } + + private function createSubject(ilFileDataMail $fdm): FileDataRCHandlingFormUploadTestSubject + { + $info = $this->createStub(FileInfoResult::class); + $info->method('getFileIdentifier')->willReturn('unknown'); + $upload_handler = $this->createStub(ilMailFormUploadHandlerGUI::class); + $upload_handler->method('getInfoResult')->willReturn($info); + + return new FileDataRCHandlingFormUploadTestSubject( + $fdm, + $upload_handler, + $this->createStub(ilLanguage::class), + $this->createStub(Services::class) + ); + } +} + +class FileDataRCHandlingFormUploadTestSubject +{ + use FileDataRCHandling { + attachmentsFromFormUpload as public fromFormUpload; + } + + public function __construct( + public ilFileDataMail $fdm, + public ilMailFormUploadHandlerGUI $upload_handler, + public ilLanguage $lng, + public Services $storage + ) { + } +} diff --git a/components/ILIAS/Mail/tests/MailAttachmentsTest.php b/components/ILIAS/Mail/tests/MailAttachmentsTest.php new file mode 100644 index 000000000000..d7c0cf5b9202 --- /dev/null +++ b/components/ILIAS/Mail/tests/MailAttachmentsTest.php @@ -0,0 +1,120 @@ +assertInstanceOf(MailAttachments::class, $parsed); + $this->assertTrue($parsed->isIrss()); + $this->assertSame(self::RCID, $parsed->rcid()->serialize()); + } + + public function testValueContainingSerializationMarkerLaterIsNoCollection(): void + { + $this->assertNull(MailAttachments::fromDb('rcid-with-a:-inside')); + } + + public function testOtherSerializedValuesAreNoCollection(): void + { + $this->assertNull(MailAttachments::fromDb('N;')); + $this->assertNull(MailAttachments::fromDb(serialize('file.pdf'))); + } + + public function testParseLegacyArray(): void + { + $parsed = MailAttachments::fromDb(serialize(['file.pdf', 'image.png'])); + + $this->assertTrue($parsed->isLegacy()); + $this->assertSame(['file.pdf', 'image.png'], $parsed->legacyFilenames()); + } + + public function testSerializeRcidForBackgroundTask(): void + { + $attachments = MailAttachments::fromIrss(new ResourceCollectionIdentification('rcid-456')); + $parsed = MailAttachments::fromBackgroundTask($attachments->toBackgroundTask()); + + $this->assertTrue($parsed->isIrss()); + $this->assertSame('rcid-456', $parsed->rcid()->serialize()); + } + + public function testParseEmptyLegacyArray(): void + { + $parsed = MailAttachments::fromDb(serialize([])); + + $this->assertNotNull($parsed); + $this->assertTrue($parsed->isEmpty()); + $this->assertFalse($parsed->isLegacy()); + } + + public function testParseEmptyStringReturnsNull(): void + { + $this->assertNull(MailAttachments::fromDb('')); + $this->assertNull(MailAttachments::fromDb(null)); + } + + public function testParseSerializedRcidWrapper(): void + { + $raw = serialize([MailAttachments::SERIALIZED_RCID_KEY => 'rcid-wrapper']); + $parsed = MailAttachments::fromDb($raw); + + $this->assertNotNull($parsed); + $this->assertTrue($parsed->isIrss()); + $this->assertSame('rcid-wrapper', $parsed->rcid()->serialize()); + } + + public function testLegacyFilenamesThrowForIrss(): void + { + $attachments = MailAttachments::fromIrss(new ResourceCollectionIdentification('rcid-123')); + $this->expectException(InvalidArgumentException::class); + $attachments->legacyFilenames(); + } + + public function testToDbLegacySerialization(): void + { + $attachments = MailAttachments::fromLegacyFilenames(['a.pdf', 'b.png']); + $this->assertSame(serialize(['a.pdf', 'b.png']), $attachments->toDb()); + } + + public function testLegacyFilenamesDropNonStrings(): void + { + $parsed = MailAttachments::fromDb(serialize(['file.pdf', 1, ['nested'], null, 'image.png'])); + + $this->assertNotNull($parsed); + $this->assertTrue($parsed->isLegacy()); + $this->assertSame(['file.pdf', 'image.png'], $parsed->legacyFilenames()); + } + + public function testLegacyFilenamesWithOnlyNonStringsAreEmpty(): void + { + $parsed = MailAttachments::fromDb(serialize([1, ['nested']])); + + $this->assertNotNull($parsed); + $this->assertTrue($parsed->isEmpty()); + } +} diff --git a/components/ILIAS/Mail/tests/MailDeletionHandlerAttachmentPathTest.php b/components/ILIAS/Mail/tests/MailDeletionHandlerAttachmentPathTest.php new file mode 100644 index 000000000000..a07f9c271fe9 --- /dev/null +++ b/components/ILIAS/Mail/tests/MailDeletionHandlerAttachmentPathTest.php @@ -0,0 +1,74 @@ +createStub(ilDBInterface::class); + $db->method('prepare')->willReturn($this->createStub(ilDBStatement::class)); + $db->method('in')->willReturn('mail_id IN (1,2)'); + $db->method('query')->willReturnCallback( + function (string $sql) use (&$captured_sql): ilDBStatement { + $captured_sql = $sql; + + return $this->createStub(ilDBStatement::class); + } + ); + $db->method('execute')->willReturn($this->createStub(ilDBStatement::class)); + $db->method('fetchAssoc')->willReturnOnConsecutiveCalls( + ['path' => '', 'cnt_mail_ids' => 2], + ['cnt' => 2], + ['path' => '/', 'cnt_mail_ids' => 1], + ['cnt' => 1], + ['path' => '6_1_2', 'cnt_mail_ids' => 1], + ['cnt' => 1], + null + ); + + $collector = $this->createStub(ExpiredOrOrphanedMailsCollector::class); + $collector->method('mailIdsToDelete')->willReturn([1, 2]); + + $handler = new MailDeletionHandler( + $this->createStub(ilMailCronOrphanedMails::class), + $collector, + $db, + $this->createStub(ilSetting::class), + $this->createStub(ilLogger::class), + static function (string $directory): void { + } + ); + + $paths = new ReflectionClass($handler) + ->getMethod('determineDeletableAttachmentPaths') + ->invoke($handler); + + $this->assertSame(['6_1_2'], $paths); + $this->assertStringContainsString('path IS NOT NULL AND path != ""', $captured_sql); + } +} diff --git a/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php b/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php new file mode 100644 index 000000000000..4778db883721 --- /dev/null +++ b/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php @@ -0,0 +1,496 @@ + */ + private array $messages = []; + private string $tmp_dir; + + protected function setUp(): void + { + parent::setUp(); + + if (!defined('SYSTEM_USER_ID')) { + define('SYSTEM_USER_ID', 6); + } + + $this->tmp_dir = sys_get_temp_dir() . '/mail-migration-unit-' . uniqid('', true); + mkdir($this->tmp_dir . '/mail', 0775, true); + + $this->migration = new MigrateMailAttachmentsToIRSS(); + $this->db = $this->createDatabaseDouble(); + $this->helper = $this->createHelperDouble(false); + + $io = $this->createStub(AdminInteraction::class); + $io->method('inform')->willReturnCallback(function (string $message): void { + $this->messages[] = $message; + }); + + $this->injectCollaborators($this->helper, $io); + } + + private function createDatabaseDouble(bool $mock = false): ilDBInterface + { + $db = $mock + ? $this->createMock(ilDBInterface::class) + : $this->createStub(ilDBInterface::class); + $db->method('quote')->willReturnCallback(static fn(mixed $value): string => "'" . $value . "'"); + $db->method('supportsTransactions')->willReturn(true); + + return $db; + } + + private function useDatabaseMock(): ilDBInterface&MockObject + { + $db = $this->createDatabaseDouble(true); + $this->db = $db; + assert($db instanceof MockObject); + + return $db; + } + + private function createHelperDouble(bool $mock): ilResourceStorageMigrationHelper + { + $methods = [ + 'getDatabase', + 'getClientDataDir', + 'moveFilesOfPathToCollection', + 'movePathToStorage', + 'getCollectionBuilder', + 'getManager', + 'getStakeholder', + ]; + if ($mock) { + $helper = $this->getMockBuilder(ilResourceStorageMigrationHelper::class) + ->disableOriginalConstructor() + ->onlyMethods($methods) + ->getMock(); + } else { + $helper = $this->getStubBuilder(ilResourceStorageMigrationHelper::class) + ->disableOriginalConstructor() + ->onlyMethods($methods) + ->getStub(); + } + $helper->method('getDatabase')->willReturnCallback(fn(): ilDBInterface => $this->db); + $helper->method('getClientDataDir')->willReturn($this->tmp_dir); + + return $helper; + } + + private function useHelperMock(): ilResourceStorageMigrationHelper&MockObject + { + $helper = $this->createHelperDouble(true); + $this->helper = $helper; + new ReflectionClass($this->migration)->getProperty('helper')->setValue($this->migration, $helper); + assert($helper instanceof MockObject); + + return $helper; + } + + private function injectCollaborators( + ilResourceStorageMigrationHelper $helper, + AdminInteraction $io + ): void { + $reflection = new ReflectionClass($this->migration); + $reflection->getProperty('helper')->setValue($this->migration, $helper); + $reflection->getProperty('io')->setValue($this->migration, $io); + } + + protected function tearDown(): void + { + exec('chmod -R u+rwx ' . escapeshellarg($this->tmp_dir) . ' && rm -rf ' . escapeshellarg($this->tmp_dir)); + parent::tearDown(); + } + + // --------------------------------------------------------------------------------------------------------- + // Order: most recently sent mails first + // --------------------------------------------------------------------------------------------------------- + + // --------------------------------------------------------------------------------------------------------- + // Directories + // --------------------------------------------------------------------------------------------------------- + + // --------------------------------------------------------------------------------------------------------- + // Drafts + // --------------------------------------------------------------------------------------------------------- + + public function testPathsOfTheMostRecentlySentMailsAreMigratedFirst(): void + { + $captured_sql = ''; + $db = $this->useDatabaseMock(); + $db->expects($this->once())->method('setLimit')->with(25); + $db->method('query')->willReturnCallback( + function (string $sql) use (&$captured_sql): ilDBStatement { + $captured_sql = $sql; + + return $this->createStub(ilDBStatement::class); + } + ); + $db->method('fetchAssoc')->willReturnOnConsecutiveCalls(['path' => 'newest'], ['path' => 'older'], null); + + $paths = $this->invokePrivate('nextUnmigratedPaths', []); + + $this->assertSame(['newest', 'older'], $paths); + $this->assertStringContainsString('ORDER BY MAX(m.send_time) DESC', $captured_sql); + $this->assertStringNotContainsString('LIMIT', $captured_sql); + } + + /** + * Regression: mails with a not yet migrated directory must never be treated as pool references + */ + public function testDraftsOfTheMostRecentlySentMailsAreMigratedFirstAndNeverMailsWithDirectory(): void + { + $captured_sql = ''; + $db = $this->useDatabaseMock(); + $db->expects($this->once())->method('setLimit')->with(50); + $db->method('query')->willReturnCallback( + function (string $sql) use (&$captured_sql): ilDBStatement { + $captured_sql = $sql; + + return $this->createStub(ilDBStatement::class); + } + ); + $db->method('fetchAssoc')->willReturn(null); + + $this->invokePrivate('migrateSerializedMailAttachments', []); + + $this->assertStringContainsString('LEFT JOIN mail_attachment ma ON ma.mail_id = m.mail_id', $captured_sql); + $this->assertStringContainsString('WHERE ma.mail_id IS NULL', $captured_sql); + $this->assertStringContainsString('LEFT JOIN mail_obj_data fo ON fo.obj_id = m.folder_id', $captured_sql); + $this->assertStringContainsString('ORDER BY m.send_time DESC, m.mail_id DESC', $captured_sql); + } + + public function testDirectoryIsMigratedWithTheIrssHelper(): void + { + $dir = $this->createDirectory('6_100', ['a.pdf' => '1234']); + $helper = $this->useHelperMock(); + $this->db->method('query')->willReturn($this->createStub(ilDBStatement::class)); + $this->db->method('queryF')->willReturn($this->createStub(ilDBStatement::class)); + $this->db->method('in')->willReturn('mail_id IN (1,2)'); + $this->db->method('fetchAssoc')->willReturnOnConsecutiveCalls( + // paths to migrate + ['path' => '6_100'], + null, + // owner, mails of the directory + ['sender_id' => 6], + ['mail_id' => 1], + ['mail_id' => 2], + null + ); + $rcid = new ResourceCollectionIdentification('dir-rcid'); + $resource_id = new ResourceIdentification('dir-rid'); + $collection = $this->createMock(ResourceCollection::class); + $collection->expects($this->once())->method('add')->with($resource_id); + $collection->method('count')->willReturn(1); + $collection->method('getIdentification')->willReturn($rcid); + $collection->method('getResourceIdentifications')->willReturn([$resource_id]); + $collection_builder = $this->createMock(CollectionBuilder::class); + $collection_builder->expects($this->once())->method('new')->with(6)->willReturn($collection); + $collection_builder->expects($this->once())->method('store')->with($collection)->willReturn(true); + $collection_builder->expects($this->once())->method('get')->with($rcid)->willReturn($collection); + $helper->method('getCollectionBuilder')->willReturn($collection_builder); + $this->expectConfidential($resource_id); + $helper->expects($this->once()) + ->method('movePathToStorage') + ->willReturnCallback(function ( + string $path, + int $owner, + ?Closure $file_name, + Closure $revision_title, + bool $copy + ) use ($dir, $resource_id): ResourceIdentification { + $this->assertSame($dir . '/a.pdf', $path); + $this->assertSame(6, $owner); + $this->assertNull($file_name); + $this->assertSame(md5('a.pdf'), $revision_title('a.pdf')); + $this->assertTrue($copy); + + return $resource_id; + }); + $statements = []; + $this->db->method('manipulateF')->willReturnCallback( + function (string $sql, array $types, array $values) use (&$statements): int { + $statements[] = [$sql, $values]; + return 1; + } + ); + + $this->invokePrivate('migrateSentAttachmentDirectories', []); + + $this->assertSame(['UPDATE mail_attachment SET rcid = %s WHERE path = %s', ['dir-rcid', '6_100']], $statements[0]); + $this->assertSame(['UPDATE mail SET attachments = %s WHERE mail_id IN (1,2)', ['dir-rcid']], $statements[1]); + $this->assertDirectoryDoesNotExist($dir); + } + + public function testIncompleteDirectoryCopyIsNotMarkedMigratedAndKeepsTheFiles(): void + { + $dir = $this->createDirectory('6_100', ['a.pdf' => '1234', 'b.pdf' => '5678']); + $helper = $this->useHelperMock(); + $db = $this->useDatabaseMock(); + $db->method('query')->willReturn($this->createStub(ilDBStatement::class)); + $db->method('queryF')->willReturn($this->createStub(ilDBStatement::class)); + $db->method('fetchAssoc')->willReturnOnConsecutiveCalls( + ['path' => '6_100'], + null, + ['sender_id' => 6] + ); + $db->expects($this->never())->method('manipulateF'); + $collection = $this->createStub(ResourceCollection::class); + $collection_builder = $this->createMock(CollectionBuilder::class); + $collection_builder->method('new')->willReturn($collection); + $collection_builder->expects($this->never())->method('store'); + $helper->method('getCollectionBuilder')->willReturn($collection_builder); + $copied = 0; + $helper->expects($this->exactly(2)) + ->method('movePathToStorage') + ->willReturnCallback(function () use (&$copied): ?ResourceIdentification { + $copied++; + + return $copied === 1 ? new ResourceIdentification('dir-rid') : null; + }); + $stakeholder = new ilMailAttachmentStakeholder(6); + $helper->method('getStakeholder')->willReturn($stakeholder); + $manager = $this->createMock(Manager::class); + $manager->expects($this->once())->method('remove')->with( + $this->callback(static fn(ResourceIdentification $rid): bool => $rid->serialize() === 'dir-rid'), + $stakeholder + ); + $helper->method('getManager')->willReturn($manager); + + $this->invokePrivate('migrateSentAttachmentDirectories', []); + + $this->assertFileExists($dir . '/a.pdf'); + $this->assertFileExists($dir . '/b.pdf'); + $this->assertReported('WARNING', 'not every file could be stored'); + } + + public function testMissingDirectoryIsSkippedAndReported(): void + { + $db = $this->useDatabaseMock(); + $helper = $this->useHelperMock(); + $db->method('query')->willReturn($this->createStub(ilDBStatement::class)); + $db->method('queryF')->willReturn($this->createStub(ilDBStatement::class)); + $db->method('fetchAssoc')->willReturnOnConsecutiveCalls( + ['path' => '6_104'], + null, + ['mail_id' => 1], + null + ); + $helper->expects($this->never())->method('movePathToStorage'); + $db->expects($this->once())->method('manipulateF')->with( + 'UPDATE mail_attachment SET rcid = %s WHERE path = %s', + [ilDBConstants::T_TEXT, ilDBConstants::T_TEXT], + ['-', '6_104'] + ); + + $this->invokePrivate('migrateSentAttachmentDirectories', []); + + $this->assertReported('WARNING', 'does not exist'); + } + + public function testPoolFilesAreCopiedWithTheirNameWithoutTheUserPrefix(): void + { + file_put_contents($this->tmp_dir . '/mail/6_Übung 1.pdf', '%PDF'); + $helper = $this->useHelperMock(); + $collection = $this->createMock(ResourceCollection::class); + $collection->expects($this->once())->method('add'); + $collection->method('count')->willReturn(1); + $collection->method('getIdentification')->willReturn(new ResourceCollectionIdentification('draft-rcid')); + $collection_builder = $this->createStub(CollectionBuilder::class); + $collection_builder->method('new')->willReturn($collection); + $collection_builder->method('store')->willReturn(true); + $helper->method('getCollectionBuilder')->willReturn($collection_builder); + $helper->expects($this->once()) + ->method('movePathToStorage') + ->willReturnCallback(function ( + string $path, + int $owner, + Closure $file_name, + Closure $revision_title, + bool $copy + ): ResourceIdentification { + $this->assertSame('Übung 1.pdf', $file_name()); + $this->assertSame(md5('Übung 1.pdf'), $revision_title()); + $this->assertTrue($copy, 'Pool files are copied, they stay in the pool'); + + return new ResourceIdentification('rid'); + }); + $this->expectConfidential(new ResourceIdentification('rid')); + + $rcid = $this->invokePrivate( + 'migratePoolFilenamesToCollection', + [['Übung 1.pdf', 'gone.pdf'], 6, $this->tmp_dir . '/mail', 42] + ); + + $this->assertSame('draft-rcid', $rcid->serialize()); + $this->assertFileExists($this->tmp_dir . '/mail/6_Übung 1.pdf'); + $this->assertReported('WARNING', 'gone.pdf'); + } + + public function testDraftGetsItsCollectionAndAMailAttachmentRow(): void + { + file_put_contents($this->tmp_dir . '/mail/6_exists.pdf', '%PDF'); + $db = $this->useDatabaseMock(); + $collection = $this->createStub(ResourceCollection::class); + $collection->method('count')->willReturn(1); + $collection->method('getIdentification')->willReturn(new ResourceCollectionIdentification('draft-rcid')); + $collection_builder = $this->createStub(CollectionBuilder::class); + $collection_builder->method('new')->willReturn($collection); + $collection_builder->method('store')->willReturn(true); + $this->helper->method('getCollectionBuilder')->willReturn($collection_builder); + $this->helper->method('movePathToStorage')->willReturn(new ResourceIdentification('rid')); + $this->expectConfidential(new ResourceIdentification('rid')); + $db->expects($this->once())->method('manipulateF')->with( + 'INSERT INTO mail_attachment (mail_id, path, rcid) VALUES (%s, %s, %s)', + $this->anything(), + [42, '', 'draft-rcid'] + ); + $db->expects($this->once())->method('update')->with( + 'mail', + ['attachments' => [ilDBConstants::T_CLOB, 'draft-rcid']], + ['mail_id' => [ilDBConstants::T_INTEGER, 42]] + ); + + $this->invokePrivate('migrateSerializedMail', [42, 6, serialize(['exists.pdf']), $this->tmp_dir . '/mail', true]); + } + + public function testMailOutsideDraftsAndOutboxNeverTakesFilesOfThePool(): void + { + file_put_contents($this->tmp_dir . '/mail/6_exists.pdf', '%PDF'); + $helper = $this->useHelperMock(); + $helper->expects($this->never())->method('movePathToStorage'); + $db = $this->useDatabaseMock(); + $db->expects($this->never())->method('manipulateF'); + $db->expects($this->once())->method('update')->with( + 'mail', + ['attachments' => [ilDBConstants::T_CLOB, '']], + ['mail_id' => [ilDBConstants::T_INTEGER, 44]] + ); + + $this->invokePrivate('migrateSerializedMail', [44, 6, serialize(['exists.pdf']), $this->tmp_dir . '/mail', false]); + + $this->assertFileExists($this->tmp_dir . '/mail/6_exists.pdf'); + $this->assertReported('WARNING', 'outside drafts and outbox'); + } + + public function testBrokenDraftValueIsClearedAndReported(): void + { + $db = $this->useDatabaseMock(); + $db->expects($this->once())->method('update')->with( + 'mail', + ['attachments' => [ilDBConstants::T_CLOB, '']], + ['mail_id' => [ilDBConstants::T_INTEGER, 43]] + ); + + $this->invokePrivate('migrateSerializedMail', [43, 6, 'a:1:{broken', $this->tmp_dir . '/mail', true]); + + $this->assertReported('WARNING', 'could not be read'); + } + + // --------------------------------------------------------------------------------------------------------- + // Abort, reporting + // --------------------------------------------------------------------------------------------------------- + + public function testAbortIsReportedWithItsCauseAndRethrown(): void + { + $this->db->method('query')->willThrowException(new RuntimeException('MySQL server has gone away')); + + try { + $this->migration->step($this->createStub(Environment::class)); + $this->fail('The exception must be rethrown'); + } catch (RuntimeException $e) { + $this->assertSame('MySQL server has gone away', $e->getMessage()); + } + + $this->assertReported('ERROR', 'MySQL server has gone away'); + } + + public function testMailAttachmentsAreConfidential(): void + { + $this->assertTrue(new ilMailAttachmentStakeholder(6)->areNewResourcesConfidential()); + } + + public function testResolveOwnerIdForPathUsesSenderIdAndFallsBackToSystemUser(): void + { + $this->db->method('queryF')->willReturn($this->createStub(ilDBStatement::class)); + $this->db->method('fetchAssoc')->willReturnOnConsecutiveCalls(['sender_id' => 42], ['sender_id' => 0]); + + $this->assertSame(42, $this->invokePrivate('resolveOwnerIdForPath', ['6_1'])); + $this->assertSame(SYSTEM_USER_ID, $this->invokePrivate('resolveOwnerIdForPath', ['6_2'])); + } + + // --------------------------------------------------------------------------------------------------------- + + /** + * @param array $files + */ + private function createDirectory(string $relative_path, array $files): string + { + $dir = $this->tmp_dir . '/mail/' . $relative_path; + mkdir($dir, 0775, true); + foreach ($files as $name => $content) { + file_put_contents($dir . '/' . $name, $content); + } + + return $dir; + } + + private function expectConfidential(ResourceIdentification $identification): void + { + $manager = $this->createMock(Manager::class); + $manager->expects($this->once())->method('setConfidential')->with($identification, true); + $this->helper->method('getManager')->willReturn($manager); + } + + private function assertReported(string $level, string $needle): void + { + foreach ($this->messages as $message) { + if (str_contains($message, $level) && str_contains($message, $needle)) { + $this->assertMatchesRegularExpression('/^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]/', $message); + + return; + } + } + + $this->fail(sprintf('No %s containing "%s" reported, got: %s', $level, $needle, implode("\n", $this->messages))); + } + + /** + * @param list $arguments + */ + private function invokePrivate(string $method, array $arguments): mixed + { + return new ReflectionClass($this->migration)->getMethod($method)->invoke($this->migration, ...$arguments); + } +} diff --git a/components/ILIAS/Mail/tests/ilFileDataMailAttachmentOwnerTest.php b/components/ILIAS/Mail/tests/ilFileDataMailAttachmentOwnerTest.php new file mode 100644 index 000000000000..fc3b4e622f1c --- /dev/null +++ b/components/ILIAS/Mail/tests/ilFileDataMailAttachmentOwnerTest.php @@ -0,0 +1,53 @@ +createStub(ilDBInterface::class); + $db->method('queryF')->willReturnCallback( + function (string $sql, array $types, array $values) use (&$queries): ilDBStatement { + $queries[] = [$sql, $values]; + + return $this->createStub(ilDBStatement::class); + } + ); + // The mail belongs to another user: neither the collection nor a directory is returned + $db->method('fetchAssoc')->willReturn(null); + $db->method('numRows')->willReturn(0); + + $sut = new ReflectionClass(ilFileDataMail::class)->newInstanceWithoutConstructor(); + $sut->user_id = 4711; + new ReflectionClass(ilFileDataMail::class)->getProperty('db')->setValue($sut, $db); + + try { + $sut->getAttachmentPathAndFilenameByMd5Hash(md5('report.pdf'), 42); + $this->fail('An attachment of a foreign mail must not be found'); + } catch (OutOfBoundsException) { + } + + foreach ($queries as [$sql, $values]) { + $this->assertStringContainsString('mail.user_id = %s', $sql); + $this->assertSame([42, 4711], $values); + } + } +} diff --git a/components/ILIAS/Mail/tests/ilFileDataMailCollectionReferenceTest.php b/components/ILIAS/Mail/tests/ilFileDataMailCollectionReferenceTest.php new file mode 100644 index 000000000000..9bbe5ffb777e --- /dev/null +++ b/components/ILIAS/Mail/tests/ilFileDataMailCollectionReferenceTest.php @@ -0,0 +1,68 @@ +assertTrue($this->isReferenced('pool-rcid', 'pool-rcid')); + } + + public function testOtherCollectionIsNotReferencedByThePool(): void + { + $this->assertFalse($this->isReferenced('pool-rcid', 'stage-rcid')); + } + + public function testUserWithoutPoolDoesNotReferenceTheCollection(): void + { + $this->assertFalse($this->isReferenced(null, 'stage-rcid')); + } + + private function isReferenced(?string $pool_rcid, string $rcid): bool + { + $queries = []; + $db = $this->createStub(ilDBInterface::class); + $db->method('queryF')->willReturnCallback( + function (string $sql, array $types, array $values) use (&$queries): ilDBStatement { + $queries[] = [$sql, $values]; + + return $this->createStub(ilDBStatement::class); + } + ); + // neither a mail nor the compose stage references the collection + $db->method('numRows')->willReturn(0); + $db->method('fetchAssoc')->willReturn($pool_rcid === null ? null : ['value' => $pool_rcid]); + + $sut = new ReflectionClass(ilFileDataMail::class)->newInstanceWithoutConstructor(); + $sut->user_id = 4711; + new ReflectionClass(ilFileDataMail::class)->getProperty('db')->setValue($sut, $db); + + $result = $sut->isCollectionReferenced(new ResourceCollectionIdentification($rcid)); + + // The pool is looked up by primary key (usr_id, keyword), never searched by value + [$pool_sql, $pool_values] = end($queries); + $this->assertStringContainsString('WHERE usr_id = %s AND keyword = %s', $pool_sql); + $this->assertSame([4711, 'mail_attachment_pool_rcid'], $pool_values); + + return $result; + } +} diff --git a/components/ILIAS/Mail/tests/ilFileDataMailFileNameTest.php b/components/ILIAS/Mail/tests/ilFileDataMailFileNameTest.php new file mode 100644 index 000000000000..32b485dc4b49 --- /dev/null +++ b/components/ILIAS/Mail/tests/ilFileDataMailFileNameTest.php @@ -0,0 +1,230 @@ +setTitle('abcdef'); + + $revision = $this->createMock(Revision::class); + $revision->expects($this->once())->method('getInformation')->willReturn($information); + $revision->expects($this->once())->method('setInformation')->with($information); + + $manage = $this->createMock(Manager::class); + $manage->expects($this->once())->method('getCurrentRevision')->with($rid)->willReturn($revision); + $manage->expects($this->once())->method('updateRevision')->with($revision); + + $irss = $this->createStub(IRSS::class); + $irss->method('manage')->willReturn($manage); + + $reflection = new ReflectionClass(ilFileDataMail::class); + $sut = $reflection->newInstanceWithoutConstructor(); + $reflection->getProperty('irss')->setValue($sut, $irss); + + $reflection->getMethod('applyResourceFileName')->invoke($sut, $rid, 'appointment_20261008.ics'); + + $this->assertSame('appointment_20261008.ics', $information->getTitle()); + $this->assertSame('ics', $information->getSuffix()); + } + + public function testApplyResourceFileNameIgnoresEmptyName(): void + { + $manage = $this->createMock(Manager::class); + $manage->expects($this->never())->method('getCurrentRevision'); + + $irss = $this->createStub(IRSS::class); + $irss->method('manage')->willReturn($manage); + + $reflection = new ReflectionClass(ilFileDataMail::class); + $sut = $reflection->newInstanceWithoutConstructor(); + $reflection->getProperty('irss')->setValue($sut, $irss); + + $reflection->getMethod('applyResourceFileName')->invoke($sut, new ResourceIdentification('ics-rid'), ''); + } + + public function testAttachmentListingKeepsEqualDisplayTitles(): void + { + $info_a = new FileInformation(); + $info_a->setTitle('report.pdf'); + $info_b = new FileInformation(); + $info_b->setTitle('report.pdf'); + + $revision_a = $this->createStub(Revision::class); + $revision_a->method('getInformation')->willReturn($info_a); + $revision_a->method('getTitle')->willReturn(md5('report.pdf')); + $revision_b = $this->createStub(Revision::class); + $revision_b->method('getInformation')->willReturn($info_b); + $revision_b->method('getTitle')->willReturn(md5('report.pdf')); + + $manage = $this->createStub(Manager::class); + $manage->method('find')->willReturnCallback( + static fn(string $rid): ResourceIdentification => new ResourceIdentification($rid) + ); + $manage->method('getCurrentRevision')->willReturnCallback( + static fn(ResourceIdentification $identification): Revision => $identification->serialize() === 'rid-a' + ? $revision_a + : $revision_b + ); + + $irss = $this->createStub(IRSS::class); + $irss->method('manage')->willReturn($manage); + $irss->method('collection')->willReturn($this->collectionsContaining(['rid-a', 'rid-b'])); + + $reflection = new ReflectionClass(ilFileDataMail::class); + $sut = $reflection->newInstanceWithoutConstructor(); + $reflection->getProperty('irss')->setValue($sut, $irss); + + $listing = $sut->getAttachmentListing(new ResourceCollectionIdentification('rcid')); + + $this->assertSame(['rid-a', 'rid-b'], array_keys($listing)); + $this->assertSame([md5('rid-a'), md5('rid-b')], array_column($listing, 'md5')); + $this->assertSame(['report.pdf', 'report.pdf'], array_column($listing, 'name')); + } + + public function testEqualFileNamesResolveToTheirOwnResource(): void + { + $sut = $this->createSutWithEqualTitles(['rid-a', 'rid-b'], md5('report.pdf')); + + $this->assertSame( + 'rid-b', + $sut->getResourceIdByHash(new ResourceCollectionIdentification('rcid'), md5('rid-b'))?->serialize() + ); + } + + public function testLegacyNameHashStillResolvesTheFirstMatchingResource(): void + { + $sut = $this->createSutWithEqualTitles(['rid-a', 'rid-b'], md5('report.pdf')); + + $this->assertSame( + 'rid-a', + $sut->getResourceIdByHash(new ResourceCollectionIdentification('rcid'), md5('report.pdf'))?->serialize() + ); + } + + /** + * @param list $rids + */ + private function createSutWithEqualTitles(array $rids, string $revision_title): ilFileDataMail + { + $revision = $this->createStub(Revision::class); + $revision->method('getTitle')->willReturn($revision_title); + + $manage = $this->createStub(Manager::class); + $manage->method('find')->willReturnCallback( + static fn(string $rid): ResourceIdentification => new ResourceIdentification($rid) + ); + $manage->method('getCurrentRevision')->willReturn($revision); + + $irss = $this->createStub(IRSS::class); + $irss->method('manage')->willReturn($manage); + $irss->method('collection')->willReturn($this->collectionsContaining($rids)); + + $reflection = new ReflectionClass(ilFileDataMail::class); + $sut = $reflection->newInstanceWithoutConstructor(); + $reflection->getProperty('irss')->setValue($sut, $irss); + + return $sut; + } + + /** + * @param list $rids + */ + private function collectionsContaining(array $rids): Collections + { + $collection = new ResourceCollection( + new ResourceCollectionIdentification('rcid'), + ResourceCollection::NO_SPECIFIC_OWNER, + '' + ); + foreach ($rids as $rid) { + $collection->add(new ResourceIdentification($rid)); + } + + $collections = $this->createStub(Collections::class); + $collections->method('exists')->willReturn(true); + $collections->method('get')->willReturn($collection); + + return $collections; + } + + public function testAdoptedPoolFileKeepsTheNameWithoutTheUserPrefix(): void + { + $dir = sys_get_temp_dir() . '/mail-pool-name-' . uniqid('', true); + mkdir($dir); + file_put_contents($dir . '/6_report.pdf', '%PDF'); + + $rid = new ResourceIdentification('pool-rid'); + $info = new FileInformation(); + $revision = $this->createMock(Revision::class); + $revision->method('getInformation')->willReturn($info); + $revision->expects($this->once())->method('setInformation')->with($info); + + $manage = $this->createMock(Manager::class); + $manage->expects($this->once())->method('stream')->willReturnCallback( + function (mixed $stream, mixed $stakeholder, ?string $title) use ($rid): ResourceIdentification { + $this->assertSame(md5('report.pdf'), $title); + + return $rid; + } + ); + $manage->expects($this->once())->method('getCurrentRevision')->with($rid)->willReturn($revision); + $manage->expects($this->once())->method('updateRevision')->with($revision); + + $collection = $this->createMock(ResourceCollection::class); + $collection->method('getResourceIdentifications')->willReturn([]); + $collection->expects($this->once())->method('add')->with($rid); + $collection->method('getIdentification')->willReturn(new ResourceCollectionIdentification('pool-rcid')); + $collections = $this->createMock(Collections::class); + $collections->method('id')->willReturn(new ResourceCollectionIdentification('pool-rcid')); + $collections->method('get')->willReturn($collection); + $collections->expects($this->once())->method('store')->willReturn(true); + + $irss = $this->createStub(IRSS::class); + $irss->method('manage')->willReturn($manage); + $irss->method('collection')->willReturn($collections); + + $reflection = new ReflectionClass(ilFileDataMail::class); + $sut = $reflection->newInstanceWithoutConstructor(); + $sut->user_id = 6; + $sut->mail_path = $dir; + $reflection->getProperty('irss')->setValue($sut, $irss); + $reflection->getProperty('stakeholder')->setValue($sut, new ilMailAttachmentStakeholder(6)); + + $stored = $sut->adoptPoolFilenamesToCollection(null, ['report.pdf']); + + $this->assertNotNull($stored); + $this->assertSame('report.pdf', $info->getTitle()); + $this->assertFileExists($dir . '/6_report.pdf'); + unlink($dir . '/6_report.pdf'); + rmdir($dir); + } +} diff --git a/components/ILIAS/Mail/tests/ilFileDataMailPoolTest.php b/components/ILIAS/Mail/tests/ilFileDataMailPoolTest.php new file mode 100644 index 000000000000..c17b89a7c5b4 --- /dev/null +++ b/components/ILIAS/Mail/tests/ilFileDataMailPoolTest.php @@ -0,0 +1,174 @@ +newFileDataMail(); + + $this->assertTrue( + $file_data_mail->isLegacyPoolItemIdentifier('legacy:chef-man-cap-svgrepo-com.svg') + ); + $this->assertFalse( + $file_data_mail->isLegacyPoolItemIdentifier('657497dc-5079-4f95-b19d-aecdaf81ff1a') + ); + } + + public function testLegacyPoolFilenameFromIdentifier(): void + { + $file_data_mail = $this->newFileDataMail(); + + $this->assertSame( + 'pepper-svgrepo-com.svg', + $file_data_mail->legacyPoolFilenameFromIdentifier('legacy:pepper-svgrepo-com.svg') + ); + } + + public function testResolvePoolIdentifiersMigratesLegacyEntries(): void + { + $legacy_rid = new ResourceIdentification('legacy-rid'); + $irss_rid = new ResourceIdentification('irss-rid'); + + $file_data_mail = $this->getMockBuilder(ilFileDataMail::class) + ->disableOriginalConstructor() + ->onlyMethods(['migrateLegacyPoolFilenameToResource', 'isInUserPool']) + ->getMock(); + $file_data_mail + ->expects($this->once()) + ->method('migrateLegacyPoolFilenameToResource') + ->with('old.svg') + ->willReturn($legacy_rid); + $file_data_mail + ->expects($this->once()) + ->method('isInUserPool') + ->with($irss_rid) + ->willReturn(true); + + $irss = $this->createIrssStub([ + 'irss-rid' => $irss_rid, + ]); + $this->injectIrss($irss, $file_data_mail); + + $resolved = $file_data_mail->resolvePoolIdentifiersToResources([ + 'legacy:old.svg', + 'irss-rid', + 'unknown-rid', + ]); + + $this->assertCount(2, $resolved); + $this->assertSame('legacy-rid', $resolved[0]->serialize()); + $this->assertSame('irss-rid', $resolved[1]->serialize()); + } + + public function testResolvePoolIdentifiersRejectsResourcesOutsideThePool(): void + { + $foreign_rid = new ResourceIdentification('foreign-rid'); + + $file_data_mail = $this->getMockBuilder(ilFileDataMail::class) + ->disableOriginalConstructor() + ->onlyMethods(['migrateLegacyPoolFilenameToResource', 'isInUserPool']) + ->getMock(); + $file_data_mail + ->expects($this->never()) + ->method('migrateLegacyPoolFilenameToResource'); + $file_data_mail + ->expects($this->once()) + ->method('isInUserPool') + ->with($foreign_rid) + ->willReturn(false); + + $this->injectIrss($this->createIrssStub(['foreign-rid' => $foreign_rid]), $file_data_mail); + + $this->assertSame([], $file_data_mail->resolvePoolIdentifiersToResources(['foreign-rid'])); + } + + public function testMigrateLegacyPoolAttachmentsReturnsIrssWhenAlreadyMigrated(): void + { + $irss = MailAttachments::fromIrss( + new ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification('rcid-1') + ); + + $partial = $this->getMockBuilder(ilFileDataMail::class) + ->disableOriginalConstructor() + ->onlyMethods(['checkFilesExist', 'createCollectionFromPoolFilenames']) + ->getMock(); + + $partial->expects($this->never())->method('checkFilesExist'); + $partial->expects($this->never())->method('createCollectionFromPoolFilenames'); + + $result = $partial->migrateLegacyPoolAttachments($irss); + + $this->assertSame($irss, $result); + } + + public function testMigrateLegacyPoolAttachmentsReturnsNullWhenFilesMissing(): void + { + $legacy = MailAttachments::fromLegacyFilenames(['missing.svg']); + + $partial = $this->getMockBuilder(ilFileDataMail::class) + ->disableOriginalConstructor() + ->onlyMethods(['checkFilesExist', 'createCollectionFromPoolFilenames']) + ->getMock(); + + $partial->expects($this->once()) + ->method('checkFilesExist') + ->with(['missing.svg']) + ->willReturn(false); + $partial->expects($this->never())->method('createCollectionFromPoolFilenames'); + + $this->assertNull($partial->migrateLegacyPoolAttachments($legacy)); + } + + /** + * @param array $known_rids + */ + private function createIrssStub(array $known_rids): IRSS + { + $manage = $this->createStub(Manager::class); + $manage->method('find')->willReturnCallback( + static function (string $rid) use ($known_rids): ?ResourceIdentification { + return $known_rids[$rid] ?? null; + } + ); + + $irss = $this->createStub(IRSS::class); + $irss->method('manage')->willReturn($manage); + + return $irss; + } + + private function newFileDataMail(): ilFileDataMail + { + return new ReflectionClass(ilFileDataMail::class)->newInstanceWithoutConstructor(); + } + + private function injectIrss(IRSS $irss, ilFileDataMail $file_data_mail): void + { + $reflection = new ReflectionClass(ilFileDataMail::class); + $property = $reflection->getProperty('irss'); + $property->setValue($file_data_mail, $irss); + } +} diff --git a/components/ILIAS/Mail/tests/ilFileDataMailSourceReleaseTest.php b/components/ILIAS/Mail/tests/ilFileDataMailSourceReleaseTest.php new file mode 100644 index 000000000000..d7fbd063a9f9 --- /dev/null +++ b/components/ILIAS/Mail/tests/ilFileDataMailSourceReleaseTest.php @@ -0,0 +1,149 @@ +createSut(referenced: true, mock_manager: true); + $sut->expects($this->never())->method('removeCollection'); + $this->managerMock()->expects($this->never())->method('remove'); + + $sut->releaseSourceCollectionAfterDelivery($rcid); + } + + public function testKeepsUnknownCollection(): void + { + $rcid = new ResourceCollectionIdentification('unknown-rcid'); + $sut = $this->createSut(collection_exists: false); + $sut->expects($this->never())->method('removeCollection'); + + $sut->releaseSourceCollectionAfterDelivery($rcid); + } + + public function testKeepsForeignStakeholderCollection(): void + { + $rcid = new ResourceCollectionIdentification('exercise-rcid'); + $sut = $this->createSut(stakeholders: [$this->foreignStakeholder()], rids: ['exercise-rid'], mock_manager: true); + $sut->expects($this->never())->method('removeCollection'); + $this->managerMock()->expects($this->never())->method('remove'); + + $sut->releaseSourceCollectionAfterDelivery($rcid); + } + + public function testRemovesCollectionTogetherWithItsResources(): void + { + $rcid = new ResourceCollectionIdentification('calendar-rcid'); + $sut = $this->createSut(stakeholders: [new ilMailAttachmentStakeholder()], rids: ['ics-rid']); + $sut->expects($this->once())->method('removeCollection')->with($rcid, true); + + $sut->releaseSourceCollectionAfterDelivery($rcid); + } + + public function testIgnoresSkippedMarker(): void + { + $sut = $this->createSut(); + $sut->expects($this->never())->method('isCollectionReferenced'); + $sut->expects($this->never())->method('removeCollection'); + + $sut->releaseCollectionIfUnreferenced(new ResourceCollectionIdentification('-')); + } + + /** + * @param list $stakeholders + * @param list $rids + */ + private function createSut( + bool $collection_exists = true, + array $stakeholders = [], + bool $referenced = false, + array $rids = [], + bool $mock_manager = false + ): ilFileDataMail&MockObject { + $this->manage = $mock_manager + ? $this->createMock(Manager::class) + : $this->createStub(Manager::class); + $this->db = $this->createStub(ilDBInterface::class); + $this->db->method('queryF')->willReturn($this->createStub(ilDBStatement::class)); + $this->db->method('fetchAssoc')->willReturn(null); + + $resource = $this->createStub(StorableResource::class); + $resource->method('getStakeholders')->willReturn($stakeholders); + $this->manage->method('getResource')->willReturn($resource); + $this->manage->method('find')->willReturnCallback( + static fn(string $rid): ResourceIdentification => new ResourceIdentification($rid) + ); + + $collections = $this->createStub(Collections::class); + $collections->method('exists')->willReturn($collection_exists); + + $irss = $this->createStub(IRSS::class); + $irss->method('collection')->willReturn($collections); + $irss->method('manage')->willReturn($this->manage); + + $sut = $this->getMockBuilder(ilFileDataMail::class) + ->disableOriginalConstructor() + ->onlyMethods([ + 'isCollectionReferenced', + 'getRidsFromCollection', + 'removeCollection', + ]) + ->getMock(); + $sut->method('isCollectionReferenced')->willReturn($referenced); + $sut->method('getRidsFromCollection')->willReturn($rids); + + $reflection = new ReflectionClass(ilFileDataMail::class); + $reflection->getProperty('irss')->setValue($sut, $irss); + $reflection->getProperty('db')->setValue($sut, $this->db); + $reflection->getProperty('stakeholder')->setValue($sut, new ilMailAttachmentStakeholder()); + + return $sut; + } + + private function managerMock(): Manager&MockObject + { + if (!$this->manage instanceof MockObject) { + throw new LogicException('This test configures no expectations on the manager.'); + } + + return $this->manage; + } + + private function foreignStakeholder(): ResourceStakeholder + { + $stakeholder = $this->createStub(ResourceStakeholder::class); + $stakeholder->method('getId')->willReturn('exc_instruction_files'); + + return $stakeholder; + } +} diff --git a/components/ILIAS/Mail/tests/ilFileDataMailStageTest.php b/components/ILIAS/Mail/tests/ilFileDataMailStageTest.php new file mode 100644 index 000000000000..e0f194f11adf --- /dev/null +++ b/components/ILIAS/Mail/tests/ilFileDataMailStageTest.php @@ -0,0 +1,136 @@ +createSut(has_stage: true); + $this->collections->expects($this->once())->method('store')->with($this->stage); + $this->manage->expects($this->once())->method('remove')->with( + $this->callback(static fn(ResourceIdentification $rid): bool => $rid->serialize() === 'rid-b') + ); + + $rcid = $sut->restrictStageTo(['rid-a']); + + $this->assertSame('33333333-3333-4333-8333-333333333333', $rcid?->serialize()); + $this->assertSame(['rid-a'], $this->stageRids()); + } + + public function testRestrictingTheStageToAllFilesKeepsIt(): void + { + $sut = $this->createSut(has_stage: true); + $this->collections->expects($this->never())->method('store'); + $this->manage->expects($this->never())->method('remove'); + + $this->assertSame('33333333-3333-4333-8333-333333333333', $sut->restrictStageTo(['rid-a', 'rid-b'])?->serialize()); + } + + public function testWithoutStageNothingIsRestricted(): void + { + $sut = $this->createSut(has_stage: false); + $this->collections->expects($this->never())->method('store'); + $this->manage->expects($this->never())->method('remove'); + + $this->assertNull($sut->restrictStageTo(['rid-a'])); + } + + public function testOnlyFilesOfTheStageCanBeUsedInTheComposeForm(): void + { + $sut = $this->createSut(has_stage: true); + $this->collections->expects($this->never())->method('store'); + $this->manage->expects($this->never())->method('remove'); + + $this->assertTrue($sut->canUseResourceInCompose(new ResourceIdentification('rid-a'))); + $this->assertFalse($sut->canUseResourceInCompose(new ResourceIdentification('foreign-rid'))); + } + + public function testRemovingAFileOutsideTheStageDeletesNothing(): void + { + $sut = $this->createSut(has_stage: true); + $this->collections->expects($this->never())->method('store'); + $this->manage->expects($this->never())->method('remove'); + + $this->assertFalse($sut->removeFromStage(new ResourceIdentification('foreign-rid'))); + } + + public function testRemovingAStageFileDeletesIt(): void + { + $sut = $this->createSut(has_stage: true); + $this->collections->expects($this->once())->method('store')->with($this->stage); + $this->manage->expects($this->once())->method('remove'); + + $this->assertTrue($sut->removeFromStage(new ResourceIdentification('rid-b'))); + $this->assertSame(['rid-a'], $this->stageRids()); + } + + private function createSut(bool $has_stage): ilFileDataMail + { + $this->stage = new ResourceCollection(new ResourceCollectionIdentification('33333333-3333-4333-8333-333333333333'), 6, ''); + $this->stage->add(new ResourceIdentification('rid-a')); + $this->stage->add(new ResourceIdentification('rid-b')); + + $this->collections = $this->createMock(Collections::class); + $this->collections->method('exists')->willReturn(true); + $this->collections->method('get')->willReturn($this->stage); + + $this->manage = $this->createMock(Manager::class); + + $irss = $this->createStub(IRSS::class); + $irss->method('collection')->willReturn($this->collections); + $irss->method('manage')->willReturn($this->manage); + + $db = $this->createStub(ilDBInterface::class); + $db->method('queryF')->willReturn($this->createStub(ilDBStatement::class)); + $db->method('fetchAssoc')->willReturn($has_stage ? ['attachments' => '33333333-3333-4333-8333-333333333333'] : null); + + $reflection = new ReflectionClass(ilFileDataMail::class); + $sut = $reflection->newInstanceWithoutConstructor(); + $sut->user_id = 6; + $reflection->getProperty('irss')->setValue($sut, $irss); + $reflection->getProperty('db')->setValue($sut, $db); + $reflection->getProperty('stakeholder')->setValue($sut, new ilMailAttachmentStakeholder(6)); + + return $sut; + } + + /** + * @return list + */ + private function stageRids(): array + { + return array_values(array_map( + static fn(ResourceIdentification $rid): string => $rid->serialize(), + $this->stage->getResourceIdentifications() + )); + } +} diff --git a/components/ILIAS/Mail/tests/ilMailCopyOnSendTest.php b/components/ILIAS/Mail/tests/ilMailCopyOnSendTest.php new file mode 100644 index 000000000000..5848ce266d4e --- /dev/null +++ b/components/ILIAS/Mail/tests/ilMailCopyOnSendTest.php @@ -0,0 +1,236 @@ +createMailInstance(); + $this->mail_file_data->expects($this->never())->method('copyCollection'); + + $legacy = MailAttachments::fromLegacyFilenames(['file.pdf']); + $result = $this->invokeDeliveryAttachments($legacy, $mail); + + $this->assertSame($legacy, $result); + } + + public function testDeliveryAttachmentsReturnsEmptyUnchanged(): void + { + $mail = $this->createMailInstance(); + $this->mail_file_data->expects($this->never())->method('copyCollection'); + + $empty = MailAttachments::empty(); + $result = $this->invokeDeliveryAttachments($empty, $mail); + + $this->assertTrue($result->isEmpty()); + } + + public function testDeliveryAttachmentsCopiesIrssCollectionPerCallWhenSharingIsDisabled(): void + { + $mail = $this->createMailInstance(); + $source = new ResourceCollectionIdentification('source-rcid'); + $copy_a = new ResourceCollectionIdentification('copy-a'); + $copy_b = new ResourceCollectionIdentification('copy-b'); + + $this->mail_file_data + ->expects($this->exactly(2)) + ->method('copyCollection') + ->with($source) + ->willReturnOnConsecutiveCalls($copy_a, $copy_b); + + $mail->setShareAttachments(false); + + $first = $this->invokeDeliveryAttachments(MailAttachments::fromIrss($source), $mail); + $second = $this->invokeDeliveryAttachments(MailAttachments::fromIrss($source), $mail); + + $this->assertSame('copy-a', $first->rcid()->serialize()); + $this->assertSame('copy-b', $second->rcid()->serialize()); + } + + public function testDeliveryAttachmentsReturnsEmptyWhenCopyFails(): void + { + $mail = $this->createMailInstance(); + $source = new ResourceCollectionIdentification('source-rcid'); + $this->mail_file_data + ->expects($this->once()) + ->method('copyCollection') + ->willReturn(null); + + $result = $this->invokeDeliveryAttachments(MailAttachments::fromIrss($source), $mail); + + $this->assertTrue($result->isEmpty()); + } + + public function testSharedDeliveryAttachmentsReuseSingleCopy(): void + { + $mail = $this->createMailInstance(); + $source = new ResourceCollectionIdentification('source-rcid'); + $shared = new ResourceCollectionIdentification('shared-copy'); + + $this->mail_file_data + ->expects($this->once()) + ->method('copyCollection') + ->with($source) + ->willReturn($shared); + + $mail->setShareAttachments(true); + + $first = $this->invokeDeliveryAttachments(MailAttachments::fromIrss($source), $mail); + $second = $this->invokeDeliveryAttachments(MailAttachments::fromIrss($source), $mail); + + $this->assertSame('shared-copy', $first->rcid()->serialize()); + $this->assertSame($first, $second); + } + + public function testDeliveryAttachmentsAreSharedByDefault(): void + { + $mail = $this->createMailInstance(); + $source = new ResourceCollectionIdentification('source-rcid'); + + $this->mail_file_data + ->expects($this->once()) + ->method('copyCollection') + ->willReturn(new ResourceCollectionIdentification('shared-copy')); + + $first = $this->invokeDeliveryAttachments(MailAttachments::fromIrss($source), $mail); + $second = $this->invokeDeliveryAttachments(MailAttachments::fromIrss($source), $mail); + + $this->assertSame($first, $second); + } + + public function testExclusivelyOwnedSourceIsTakenOverWithoutCopy(): void + { + $mail = $this->createMailInstance(exclusively_owned: true); + $source = MailAttachments::fromIrss(new ResourceCollectionIdentification('queued-snapshot')); + + $this->mail_file_data->expects($this->never())->method('copyCollection'); + + $result = $this->invokeDeliveryAttachments($source, $mail); + + $this->assertSame('queued-snapshot', $result->rcid()->serialize()); + } + + public function testQueueSnapshotTakesOverAFreeCollectionOfTheMailSystem(): void + { + $mail = $this->createMailInstance(exclusively_owned: true); + $this->mail_file_data->expects($this->never())->method('copyCollection'); + $source = MailAttachments::fromIrss(new ResourceCollectionIdentification('calendar-ics')); + + $this->assertSame($source, $this->invokeQueueSnapshot($mail, $source)); + } + + public function testQueueSnapshotCopiesStageDraftsAndForeignCollections(): void + { + $mail = $this->createMailInstance(); + $source = new ResourceCollectionIdentification('stage-rcid'); + $this->mail_file_data + ->expects($this->once()) + ->method('copyCollection') + ->with($source) + ->willReturn(new ResourceCollectionIdentification('snapshot-rcid')); + + $snapshot = $this->invokeQueueSnapshot($mail, MailAttachments::fromIrss($source)); + + $this->assertSame('snapshot-rcid', $snapshot->rcid()->serialize()); + } + + public function testReleaseSourceAttachmentsIgnoresEmptyAndLegacy(): void + { + $mail = $this->createMailInstance(); + $this->mail_file_data->expects($this->never())->method('releaseSourceCollectionAfterDelivery'); + + $this->invokeReleaseSourceAttachments($mail, MailAttachments::empty()); + $this->invokeReleaseSourceAttachments($mail, MailAttachments::fromLegacyFilenames(['a.ics'])); + } + + public function testReleaseSourceAttachmentsDelegatesIrssCollection(): void + { + $mail = $this->createMailInstance(); + $rcid = new ResourceCollectionIdentification('source-rcid'); + $this->mail_file_data + ->expects($this->once()) + ->method('releaseSourceCollectionAfterDelivery') + ->with($rcid); + + $this->invokeReleaseSourceAttachments($mail, MailAttachments::fromIrss($rcid)); + } + + public function testReleaseSourceAttachmentsLogsFailuresWithoutThrowing(): void + { + $mail = $this->createMailInstance(); + $logger = $this->createMock(ilLogger::class); + $logger->expects($this->once())->method('warning')->with('release failed'); + new ReflectionClass(ilMail::class)->getProperty('logger')->setValue($mail, $logger); + + $this->mail_file_data + ->expects($this->once()) + ->method('releaseSourceCollectionAfterDelivery') + ->willThrowException(new RuntimeException('release failed')); + + $this->invokeReleaseSourceAttachments( + $mail, + MailAttachments::fromIrss(new ResourceCollectionIdentification('source-rcid')) + ); + } + + private function createMailInstance(bool $exclusively_owned = false): ilMail + { + $this->mail_file_data = $this->getMockBuilder(ilFileDataMail::class) + ->disableOriginalConstructor() + ->onlyMethods([ + 'copyCollection', + 'releaseSourceCollectionAfterDelivery', + 'isCollectionExclusivelyOwnedByMail', + ]) + ->getMock(); + $this->mail_file_data->method('isCollectionExclusivelyOwnedByMail')->willReturn($exclusively_owned); + + $mail = new ReflectionClass(ilMail::class)->newInstanceWithoutConstructor(); + + $reflection = new ReflectionClass(ilMail::class); + $reflection->getProperty('mail_file_data')->setValue($mail, $this->mail_file_data); + + return $mail; + } + + private function invokeReleaseSourceAttachments(ilMail $mail, MailAttachments $source): void + { + new ReflectionClass($mail)->getMethod('releaseSourceAttachments')->invoke($mail, $source); + } + + private function invokeQueueSnapshot(ilMail $mail, MailAttachments $source): MailAttachments + { + return new ReflectionClass($mail)->getMethod('createQueueSnapshot')->invoke($mail, $source); + } + + private function invokeDeliveryAttachments(MailAttachments $source, ilMail $mail): MailAttachments + { + $reflection = new ReflectionClass($mail); + + return $reflection->getMethod('getDeliveryAttachments')->invoke($mail, $source); + } +} diff --git a/components/ILIAS/Mail/tests/ilMailFolderGUIAttachmentHashesTest.php b/components/ILIAS/Mail/tests/ilMailFolderGUIAttachmentHashesTest.php new file mode 100644 index 000000000000..71754faac798 --- /dev/null +++ b/components/ILIAS/Mail/tests/ilMailFolderGUIAttachmentHashesTest.php @@ -0,0 +1,72 @@ +createStub(ilFileDataMail::class); + $mail_file_data->method('getAttachmentListing')->willReturn([ + 'a.pdf' => ['md5' => md5('a.pdf'), 'name' => 'a.pdf', 'size' => 1, 'ctime' => ''], + 'b.pdf' => ['md5' => md5('b.pdf'), 'name' => 'b.pdf', 'size' => 1, 'ctime' => ''], + ]); + + $hashes = $this->invoke( + ['attachments' => MailAttachments::fromIrss(new ResourceCollectionIdentification('rcid'))], + $mail_file_data + ); + + $this->assertSame([md5('a.pdf'), md5('b.pdf')], $hashes); + } + + public function testLegacyAttachmentHashesAreTheHashedFilenames(): void + { + $hashes = $this->invoke( + ['attachments' => MailAttachments::fromLegacyFilenames(['a.pdf'])], + $this->createStub(ilFileDataMail::class) + ); + + $this->assertSame([md5('a.pdf')], $hashes); + } + + public function testMailsWithoutAttachmentsHaveNoHashes(): void + { + $this->assertSame([], $this->invoke(['attachments' => null], $this->createStub(ilFileDataMail::class))); + } + + /** + * @param array $mail_data + * @return list + */ + private function invoke(array $mail_data, ilFileDataMail $mail_file_data): array + { + $reflection = new ReflectionClass(ilMailFolderGUI::class); + $gui = $reflection->newInstanceWithoutConstructor(); + + return $reflection->getMethod('getAttachmentHashes')->invoke($gui, $mail_data, $mail_file_data); + } +} diff --git a/components/ILIAS/Mail/tests/ilMailMimeTest.php b/components/ILIAS/Mail/tests/ilMailMimeTest.php index 7b72c166ce00..630ad0f2e99a 100755 --- a/components/ILIAS/Mail/tests/ilMailMimeTest.php +++ b/components/ILIAS/Mail/tests/ilMailMimeTest.php @@ -101,7 +101,9 @@ public function testTransportFactoryWillReturnNullTransportIfExternalEmailDelive 'raise', ])->getMock(); - $factory = new ilMailMimeTransportFactory($settings, $event_handler); + $irss = $this->createStub(ILIAS\ResourceStorage\Services::class); + + $factory = new ilMailMimeTransportFactory($settings, $event_handler, $irss); $this->assertInstanceOf(ilMailMimeTransportNull::class, $factory->getTransport()); } @@ -118,7 +120,9 @@ public function testTransportFactoryWillReturnSmtpTransportIfEnabled(): void 'raise', ])->getMock(); - $factory = new ilMailMimeTransportFactory($settings, $event_handler); + $irss = $this->createStub(ILIAS\ResourceStorage\Services::class); + + $factory = new ilMailMimeTransportFactory($settings, $event_handler, $irss); $this->assertInstanceOf(ilMailMimeTransportSmtp::class, $factory->getTransport()); } @@ -147,7 +151,9 @@ public function testTransportFactoryWillReturnSendmailTransportIfSmtpTransportIs 'raise', ])->getMock(); - $factory = new ilMailMimeTransportFactory($settings, $event_handler); + $irss = $this->createStub(ILIAS\ResourceStorage\Services::class); + + $factory = new ilMailMimeTransportFactory($settings, $event_handler, $irss); $this->assertInstanceOf(ilMailMimeTransportSendmail::class, $factory->getTransport()); } diff --git a/components/ILIAS/Mail/tests/ilMailTaskProcessorTest.php b/components/ILIAS/Mail/tests/ilMailTaskProcessorTest.php index 32a1b31d6efb..f397da5d97f9 100755 --- a/components/ILIAS/Mail/tests/ilMailTaskProcessorTest.php +++ b/components/ILIAS/Mail/tests/ilMailTaskProcessorTest.php @@ -21,6 +21,7 @@ use ILIAS\BackgroundTasks\Implementation\TaskManager\BasicTaskManager; use ILIAS\BackgroundTasks\Task\TaskFactory; use ILIAS\DI\Container; +use ILIAS\Mail\Attachments\MailAttachments; class ilMailTaskProcessorTest extends ilMailBaseTestCase { @@ -58,7 +59,7 @@ public function testMailValueObjectCannotBeCreatedWithUnsupportedSubjectLength() '', 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce mollis posuere tincidunt. Phasellus et euismod ligula. Suspendisse dignissim eget dui nec imperdiet. Donec in pretium tellus. Maecenas lacinia eleifend erat ut euismod. Aenean eu malesuada est.', 'Dear Steve, great!', - [] + MailAttachments::empty() ); } @@ -108,7 +109,7 @@ public function testOneTask(): void '', 'That is awesome!', 'Dear Steve, great!', - [] + MailAttachments::empty() ); $value_objects = [ @@ -175,7 +176,7 @@ public function testRunTwoTasks(): void '', 'That is awesome!', 'Dear Steve, great!', - [] + MailAttachments::empty() ); $value_objects[] = new ilMailValueObject( @@ -185,7 +186,7 @@ public function testRunTwoTasks(): void '', 'Greate', 'Steve, Steve, Steve. Wait that is not Steve', - [] + MailAttachments::empty() ); $usr_id = 100; @@ -248,7 +249,7 @@ public function testRunThreeTasksInDifferentBuckets(): void '', 'That is awesome!', 'Dear Steve, great!', - [] + MailAttachments::empty() ); $value_objects[] = new ilMailValueObject( @@ -258,7 +259,7 @@ public function testRunThreeTasksInDifferentBuckets(): void '', 'Greate', 'Steve, Steve, Steve. Wait that is not Steve', - [] + MailAttachments::empty() ); $value_objects[] = new ilMailValueObject( @@ -268,7 +269,7 @@ public function testRunThreeTasksInDifferentBuckets(): void '', 'That is awesome!', 'Hey Alan! Alan! Alan!', - [] + MailAttachments::empty() ); $usr_id = 100; @@ -334,7 +335,7 @@ public function testRunHasWrongTypeAndWillResultInException(): void '', 'That is awesome!', 'Dear Steve, great!', - [] + MailAttachments::empty() ); $value_objects[] = new ilMailValueObject( @@ -344,7 +345,7 @@ public function testRunHasWrongTypeAndWillResultInException(): void '', 'Greate', 'Steve, Steve, Steve. Wait that is not Steve', - [] + MailAttachments::empty() ); $value_objects[] = 'This should fail'; diff --git a/components/ILIAS/Mail/tests/ilMailTest.php b/components/ILIAS/Mail/tests/ilMailTest.php index 80507a8b0ce1..bbdafcdbc2dc 100755 --- a/components/ILIAS/Mail/tests/ilMailTest.php +++ b/components/ILIAS/Mail/tests/ilMailTest.php @@ -21,6 +21,7 @@ use ILIAS\Refinery\Factory; use PHPUnit\Framework\MockObject\MockObject; use ILIAS\Mail\Autoresponder\AutoresponderService; +use ILIAS\Mail\Attachments\MailAttachments; use ILIAS\LegalDocuments\Conductor; use ILIAS\Refinery\Transformation; use ILIAS\Data\Result\Ok; @@ -28,6 +29,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use ILIAS\Mail\TemplateEngine\TemplateEngineFactoryInterface; use ILIAS\Mail\TemplateEngine\Mustache\MustacheTemplateEngineFactory; +use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; class ilMailTest extends ilMailBaseTestCase { @@ -36,6 +38,7 @@ class ilMailTest extends ilMailBaseTestCase private MockObject&ilLogger $mock_log; private MockObject&ilMailRfc822AddressParserFactory $mock_parser_factory; private MockObject&ilLanguage $mock_language; + private MockObject&ilFileDataMail $mock_mail_file_data; public function testExternalMailDeliveryWorksAsExpected(): void { @@ -264,7 +267,7 @@ public function render(string $template, object|array $context): string implode(',', array_slice(array_keys($active_users_login_to_id_map), 5, 2)), 'Subject', 'Message', - [], + MailAttachments::empty(), false ); $mail_service->sendMail($mail_data); @@ -459,9 +462,11 @@ public function testUpdateDraft(): void $params = []; $draft_id = 78; + $this->mock_database->method('queryF')->willReturn($this->createStub(ilDBStatement::class)); + $this->mock_database->method('numRows')->willReturn(1); $this->mock_database->expects($this->once())->method('update')->with('mail', [ 'folder_id' => ['integer', $folder_id], - 'attachments' => ['clob', serialize([])], + 'attachments' => [ilDBConstants::T_CLOB, serialize([])], 'send_time' => ['timestamp', date('Y-m-d H:i:s')], 'rcp_to' => ['clob', $to], 'rcp_cc' => ['clob', $cc], @@ -482,7 +487,7 @@ public function testUpdateDraft(): void $draft_id, $instance->updateDraft( $folder_id, - [], + MailAttachments::empty(), $to, $cc, $bcc, @@ -497,6 +502,20 @@ public function testUpdateDraft(): void ); } + public function testUpdateDraftLeavesMailsOfOtherUsersUntouched(): void + { + $instance = $this->create(); + $this->mock_database->method('queryF')->willReturn($this->createStub(ilDBStatement::class)); + $this->mock_database->method('numRows')->willReturn(0); + $this->mock_database->expects($this->never())->method('update'); + $this->mock_mail_file_data->expects($this->never())->method('replaceMailAttachmentCollection'); + + $this->assertSame( + 4711, + $instance->updateDraft(7890, MailAttachments::empty(), 'abc', '', '', 'subject', 'message', 4711) + ); + } + public function testPersistingToStage(): void { $usr_id = 897; @@ -527,8 +546,9 @@ public function testPersistingToStage(): void ]); $mock_statement = $this->getMockBuilder(ilDBStatement::class)->disableOriginalConstructor()->getMock(); - $this->mock_database->expects($this->once())->method('queryF')->willReturnCallback($this->queryCallback($mock_statement, ['integer'], [$usr_id])); - $this->mock_database->expects($this->once())->method('fetchAssoc')->with($mock_statement)->willReturn([ + // Previous stage lookup (to release its collection) and re-reading the stage + $this->mock_database->expects($this->exactly(2))->method('queryF')->willReturnCallback($this->queryCallback($mock_statement, ['integer'], [$usr_id])); + $this->mock_database->expects($this->exactly(2))->method('fetchAssoc')->with($mock_statement)->willReturn([ 'rcp_to' => 'phpunit' ]); @@ -627,20 +647,87 @@ public function testGetIliasMailerName(): void $this->assertSame($expected, ilMail::_getIliasMailerName()); } + public function testPersistingToStageReleasesReplacedStageCollection(): void + { + $usr_id = 897; + $instance = $this->create(789, $usr_id); + $mock_statement = $this->createStub(ilDBStatement::class); + $this->mock_database->method('queryF')->willReturn($mock_statement); + $this->mock_database->method('fetchAssoc')->willReturnOnConsecutiveCalls( + ['attachments' => '11111111-1111-4111-8111-111111111111'], + ['attachments' => '22222222-2222-4222-8222-222222222222', 'rcp_to' => ''] + ); + + $this->mock_mail_file_data + ->expects($this->once()) + ->method('releaseCollectionIfUnreferenced') + ->with($this->callback( + static fn(ResourceCollectionIdentification $rcid): bool => $rcid->serialize() === '11111111-1111-4111-8111-111111111111' + )); + + $instance->persistToStage( + $usr_id, + '', + '', + '', + '', + '', + MailAttachments::fromIrss(new ResourceCollectionIdentification('22222222-2222-4222-8222-222222222222')) + ); + } + + public function testPersistingSameStageCollectionKeepsIt(): void + { + $usr_id = 897; + $instance = $this->create(789, $usr_id); + $mock_statement = $this->createStub(ilDBStatement::class); + $this->mock_database->method('queryF')->willReturn($mock_statement); + $this->mock_database->method('fetchAssoc')->willReturn(['attachments' => '33333333-3333-4333-8333-333333333333', 'rcp_to' => '']); + + $this->mock_mail_file_data->expects($this->never())->method('releaseCollectionIfUnreferenced'); + + $instance->persistToStage( + $usr_id, + '', + '', + '', + '', + '', + MailAttachments::fromIrss(new ResourceCollectionIdentification('33333333-3333-4333-8333-333333333333')) + ); + } + public function testSaveAttachments(): void { $usr_id = 89; - $attachments = new \ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification('657497dc-5079-4f95-b19d-aecdaf81ff1a'); + $attachments = MailAttachments::fromIrss( + new ResourceCollectionIdentification('657497dc-5079-4f95-b19d-aecdaf81ff1a') + ); $instance = $this->create(789, $usr_id); + $mock_statement = $this->createStub(ilDBStatement::class); - $this->mock_database->expects($this->once())->method('update')->with( + $this->mock_database->expects($this->exactly(3))->method('queryF')->willReturnCallback( + $this->queryCallback($mock_statement, ['integer'], [$usr_id]) + ); + $this->mock_database->expects($this->exactly(3))->method('fetchAssoc')->with($mock_statement)->willReturn([ + 'attachments' => null, + 'rcp_to' => '', + 'rcp_cc' => '', + 'rcp_bcc' => '', + 'm_subject' => '', + 'm_message' => '', + 'use_placeholders' => 0, + 'tpl_ctx_id' => null, + 'tpl_ctx_params' => '[]', + ]); + $this->mock_database->expects($this->once())->method('replace')->with( 'mail_saved', - [ - 'attachments' => ['text', $attachments->serialize()], - ], [ 'user_id' => ['integer', $usr_id], - ] + ], + $this->callback(static function (array $fields) use ($attachments): bool { + return ($fields['attachments'][1] ?? null) === $attachments->rcid()->serialize(); + }) ); $instance->saveAttachments($attachments); @@ -680,7 +767,7 @@ private function create(int $ref_id = 234, int $usr_id = 123): ilMail ($this->mock_log = $this->getMockBuilder(ilLogger::class)->disableOriginalConstructor()->getMock()), ($this->mock_database = $this->createMock(ilDBInterface::class)), ($this->mock_language = $this->getMockBuilder(ilLanguage::class)->disableOriginalConstructor()->getMock()), - $this->getMockBuilder(ilFileDataMail::class)->disableOriginalConstructor()->getMock(), + ($this->mock_mail_file_data = $this->getMockBuilder(ilFileDataMail::class)->disableOriginalConstructor()->getMock()), $this->getMockBuilder(ilMailOptions::class)->disableOriginalConstructor()->getMock(), $this->getMockBuilder(ilMailbox::class)->disableOriginalConstructor()->getMock(), $this->getMockBuilder(ilMailMimeSenderFactory::class)->disableOriginalConstructor()->getMock(), diff --git a/components/ILIAS/Membership/classes/Cron/class.ilMembershipCronMinMembers.php b/components/ILIAS/Membership/classes/Cron/class.ilMembershipCronMinMembers.php index b35b70407284..f49fe7db0e84 100755 --- a/components/ILIAS/Membership/classes/Cron/class.ilMembershipCronMinMembers.php +++ b/components/ILIAS/Membership/classes/Cron/class.ilMembershipCronMinMembers.php @@ -21,6 +21,7 @@ use ILIAS\Cron\Job\Schedule\JobScheduleType; use ILIAS\Cron\Job\JobResult; use ILIAS\Cron\CronJob; +use ILIAS\Mail\Attachments\MailAttachments; /** * Cron for course/group minimum members @@ -162,7 +163,7 @@ protected function sendMessage(int $a_reci_id, array $a_items): void '', $lng->txt("mem_cron_min_members_subject"), $ntf->composeAndGetMessage($a_reci_id, null, "read", true), - [] + MailAttachments::empty() ); } } diff --git a/components/ILIAS/Membership/classes/Cron/class.ilMembershipCronNotifications.php b/components/ILIAS/Membership/classes/Cron/class.ilMembershipCronNotifications.php index c0fea84531eb..501c8432f7d0 100755 --- a/components/ILIAS/Membership/classes/Cron/class.ilMembershipCronNotifications.php +++ b/components/ILIAS/Membership/classes/Cron/class.ilMembershipCronNotifications.php @@ -21,6 +21,7 @@ use ILIAS\Cron\Job\Schedule\JobScheduleType; use ILIAS\Cron\Job\JobResult; use ILIAS\Cron\CronJob; +use ILIAS\Mail\Attachments\MailAttachments; /** * Course/group notifications @@ -475,7 +476,7 @@ protected function sendMail(int $a_user_id, array $a_objects, string $a_last_run (string) null, $subject, $mail_content, - [] + MailAttachments::empty() ); } diff --git a/components/ILIAS/Notes/Notifications/class.NotificationsManager.php b/components/ILIAS/Notes/Notifications/class.NotificationsManager.php index 24bcf199d246..c8b39191efe6 100755 --- a/components/ILIAS/Notes/Notifications/class.NotificationsManager.php +++ b/components/ILIAS/Notes/Notifications/class.NotificationsManager.php @@ -20,6 +20,8 @@ namespace ILIAS\Notes; +use ILIAS\Mail\Attachments\MailAttachments; + /** * @author Alexander Killing */ @@ -183,7 +185,7 @@ public function sendNotifications( "", $subject, $message, - array() + MailAttachments::empty() ); } } diff --git a/components/ILIAS/Notifications/classes/ilNotificationMailHandler.php b/components/ILIAS/Notifications/classes/ilNotificationMailHandler.php index 4d04e1a17238..628febdfe86b 100755 --- a/components/ILIAS/Notifications/classes/ilNotificationMailHandler.php +++ b/components/ILIAS/Notifications/classes/ilNotificationMailHandler.php @@ -22,6 +22,7 @@ use ILIAS\Notifications\Model\ilNotificationObject; use ilMail; +use ILIAS\Mail\Attachments\MailAttachments; /** * @author Jan Posselt @@ -39,7 +40,7 @@ public function notify(ilNotificationObject $notification): void '', $notification->title, $notification->longDescription, - [] + MailAttachments::empty() ); } } diff --git a/components/ILIAS/Skill/classes/class.ilSkillNotifications.php b/components/ILIAS/Skill/classes/class.ilSkillNotifications.php index 37faa43c7cf2..91c864a20d32 100755 --- a/components/ILIAS/Skill/classes/class.ilSkillNotifications.php +++ b/components/ILIAS/Skill/classes/class.ilSkillNotifications.php @@ -22,6 +22,7 @@ use ILIAS\Cron\Job\Schedule\JobScheduleType; use ILIAS\Cron\Job\JobResult; use ILIAS\Cron\CronJob; +use ILIAS\Mail\Attachments\MailAttachments; /** * Course/group skill notification @@ -233,7 +234,7 @@ protected function sendMail(int $a_user_id, array $a_achievements, string $a_las "", $subject, $ntf->composeAndGetMessage($a_user_id, null, "read", true), - [] + MailAttachments::empty() ); } } diff --git a/components/ILIAS/StudyProgramme/classes/Mail/class.ilPRGMail.php b/components/ILIAS/StudyProgramme/classes/Mail/class.ilPRGMail.php index a2b6161e518b..51593a217a47 100755 --- a/components/ILIAS/StudyProgramme/classes/Mail/class.ilPRGMail.php +++ b/components/ILIAS/StudyProgramme/classes/Mail/class.ilPRGMail.php @@ -19,6 +19,7 @@ declare(strict_types=1); use ILIAS\User\Settings\Settings as UserSettings; +use ILIAS\Mail\Attachments\MailAttachments; /** * Send mails to users (usually triggered by cron) @@ -96,7 +97,7 @@ protected function sendMail( $mail = new ilMail(ANONYMOUS_USER_ID); try { - $mail->enqueue($login, '', '', $subject, $body, []); + $mail->enqueue($login, '', '', $subject, $body, MailAttachments::empty()); return true; } catch (Exception $e) { $this->log->info($e->getMessage()); diff --git a/components/ILIAS/Survey/Participants/class.ilSurveyParticipantsGUI.php b/components/ILIAS/Survey/Participants/class.ilSurveyParticipantsGUI.php index 3947862aed55..a0e27ac8b638 100755 --- a/components/ILIAS/Survey/Participants/class.ilSurveyParticipantsGUI.php +++ b/components/ILIAS/Survey/Participants/class.ilSurveyParticipantsGUI.php @@ -20,6 +20,7 @@ use ILIAS\Survey\CSVReader; use ILIAS\Survey\Participants; +use ILIAS\Mail\Attachments\MailAttachments; /** * Class ilSurveyParticipantsGUI @@ -1748,7 +1749,7 @@ public function mailRatersActionObject(): void "", // bcc $subj, // subject $mytxt, // message - array() // attachments + MailAttachments::empty() // attachments ); $this->object->set360RaterSent( diff --git a/components/ILIAS/Survey/Participants/class.ilSurveyRaterGUI.php b/components/ILIAS/Survey/Participants/class.ilSurveyRaterGUI.php index 608833e4f878..1b146f3a509c 100755 --- a/components/ILIAS/Survey/Participants/class.ilSurveyRaterGUI.php +++ b/components/ILIAS/Survey/Participants/class.ilSurveyRaterGUI.php @@ -18,6 +18,8 @@ declare(strict_types=1); +use ILIAS\Mail\Attachments\MailAttachments; + /** * @author Alexander Killing * @ilCtrl_Calls ilSurveyRaterGUI: ilRepositorySearchGUI @@ -385,7 +387,7 @@ public function mailRatersAction(): void "", // bcc $subj, // subject $mytxt, // message - array() // attachments + MailAttachments::empty() // attachments ); $this->survey->set360RaterSent( diff --git a/components/ILIAS/Survey/classes/class.ilObjSurvey.php b/components/ILIAS/Survey/classes/class.ilObjSurvey.php index 1137452b9773..776bd9674328 100755 --- a/components/ILIAS/Survey/classes/class.ilObjSurvey.php +++ b/components/ILIAS/Survey/classes/class.ilObjSurvey.php @@ -21,6 +21,7 @@ use ILIAS\Survey\Participants; use ILIAS\Survey\Mode; use ILIAS\Survey\InternalDomainService; +use ILIAS\Mail\Attachments\MailAttachments; /** * @author Helmut Schottmüller @@ -3848,7 +3849,7 @@ public function sendCodes( "", // bcc $subject, // subject $messagetext, // message - array() // attachments + MailAttachments::empty() // attachments ); } } @@ -4352,7 +4353,7 @@ public function sendAppraiseeNotification( "", $subject, $ntf->composeAndGetMessage($a_user_id, null, "read", true), - [] + MailAttachments::empty() ); } @@ -4381,7 +4382,7 @@ public function sendAppraiseeCloseNotification( "", $subject, $ntf->composeAndGetMessage($a_user_id, null, "read", true), - [] + MailAttachments::empty() ); } @@ -4412,7 +4413,7 @@ public function sendRaterNotification( "", $subject, $ntf->composeAndGetMessage($a_user_id, null, "read", true), - [] + MailAttachments::empty() ); } @@ -5283,7 +5284,7 @@ public function send360ReminderToUser( "", $subject, $ntf->composeAndGetMessage($a_user_id, null, "read", true), - [] + MailAttachments::empty() ); } @@ -5347,7 +5348,7 @@ protected function sendTutorNotification(): void "", $subject, $message, - array() + MailAttachments::empty() ); } } @@ -5505,7 +5506,7 @@ protected function sentReminder( "", $subject, $message, - array() + MailAttachments::empty() ); } } diff --git a/components/ILIAS/Test/classes/class.ilTestMailNotification.php b/components/ILIAS/Test/classes/class.ilTestMailNotification.php index 2a7b1b5cd174..2c088538e564 100755 --- a/components/ILIAS/Test/classes/class.ilTestMailNotification.php +++ b/components/ILIAS/Test/classes/class.ilTestMailNotification.php @@ -18,6 +18,8 @@ declare(strict_types=1); +use ILIAS\Mail\Attachments\MailAttachments; + /** * Class ilTestMailNotification * @author Nadia Ahmad @@ -100,7 +102,15 @@ public function sendAdvancedNotification($owner_id, $title, $usr_data, $file_nam $this->appendBody($this->language->txt('tst_notification_explanation_admin')); $this->appendBody("\n"); - $this->setAttachments($file_names); + $attachments = MailAttachments::empty(); + if ($file_names !== []) { + $file_data_mail = $this->initFileDataMail(); + $rcid = $file_data_mail->createCollectionFromPoolFilenames($file_names); + if ($rcid !== null) { + $attachments = MailAttachments::fromIrss($rcid); + } + } + $this->setAttachments($attachments); $this->getMail()->appendInstallationSignature(true); $this->sendMail([$owner_id]); diff --git a/components/ILIAS/User/classes/Cron/class.ilUserCronCheckAccounts.php b/components/ILIAS/User/classes/Cron/class.ilUserCronCheckAccounts.php index b6df670a88af..0eef1312af44 100755 --- a/components/ILIAS/User/classes/Cron/class.ilUserCronCheckAccounts.php +++ b/components/ILIAS/User/classes/Cron/class.ilUserCronCheckAccounts.php @@ -21,6 +21,7 @@ use ILIAS\Cron\Job\Schedule\JobScheduleType; use ILIAS\Cron\Job\JobResult; use ILIAS\Cron\CronJob; +use ILIAS\Mail\Attachments\MailAttachments; /** * This cron send notifications about expiring user accounts @@ -133,7 +134,7 @@ public function run(): JobResult '', $lng->txt('account_expires_subject'), $body, - [] + MailAttachments::empty() ); // set status 'mail sent' diff --git a/components/ILIAS/User/src/Settings/NewAccountMail/Mail.php b/components/ILIAS/User/src/Settings/NewAccountMail/Mail.php index 7d5ab877d531..22199e7012b3 100644 --- a/components/ILIAS/User/src/Settings/NewAccountMail/Mail.php +++ b/components/ILIAS/User/src/Settings/NewAccountMail/Mail.php @@ -31,4 +31,5 @@ public function getSalutationNoneSpecific(): string; public function getSalutationMale(): string; public function getSalutationFemale(): string; public function getAttachment(ResourceStorage $irss): ?MailAttachment; + public function getAttachmentRid(): ?string; } diff --git a/components/ILIAS/WebServices/ECS/classes/class.ilECSAppEventListener.php b/components/ILIAS/WebServices/ECS/classes/class.ilECSAppEventListener.php index 0215acd1f65b..73f808e14281 100755 --- a/components/ILIAS/WebServices/ECS/classes/class.ilECSAppEventListener.php +++ b/components/ILIAS/WebServices/ECS/classes/class.ilECSAppEventListener.php @@ -18,6 +18,8 @@ declare(strict_types=1); +use ILIAS\Mail\Attachments\MailAttachments; + /** * ECS Event Handler * @author Stefan Meyer @@ -220,7 +222,7 @@ private function sendNotification(ilECSSetting $server, ilObjUser $user_obj): vo $body .= $user_obj->getProfileAsString($lang) . "\n\n"; $body .= ilMail::_getAutoGeneratedMessageString($lang); - $mail->enqueue($server->getUserRecipientsAsString(), "", "", $subject, $body, array()); + $mail->enqueue($server->getUserRecipientsAsString(), "", "", $subject, $body, MailAttachments::empty()); // Store sub_id = 1 in ecs import which means mail is send $import->setSubId("1"); diff --git a/components/ILIAS/WebServices/ECS/classes/class.ilECSObjectSettings.php b/components/ILIAS/WebServices/ECS/classes/class.ilECSObjectSettings.php index 76a255dbe2a6..a400cb22fe4b 100755 --- a/components/ILIAS/WebServices/ECS/classes/class.ilECSObjectSettings.php +++ b/components/ILIAS/WebServices/ECS/classes/class.ilECSObjectSettings.php @@ -22,6 +22,7 @@ use ILIAS\UI\Component\Input\Field\Section; use ILIAS\Refinery\Factory as Refinery; use ILIAS\Refinery\Transformation; +use ILIAS\Mail\Attachments\MailAttachments; /** * Handles object exports to ECS @@ -658,7 +659,7 @@ protected function sendNewContentNotification(ilECSSetting $a_server, $a_econten '', $lang->txt('ecs_new_approval_subject'), $message, - array() + MailAttachments::empty(), ); return true; diff --git a/components/ILIAS/WebServices/ECS/classes/class.ilRemoteObjectBase.php b/components/ILIAS/WebServices/ECS/classes/class.ilRemoteObjectBase.php index a9f62f51eac4..45da124352de 100755 --- a/components/ILIAS/WebServices/ECS/classes/class.ilRemoteObjectBase.php +++ b/components/ILIAS/WebServices/ECS/classes/class.ilRemoteObjectBase.php @@ -18,6 +18,8 @@ declare(strict_types=1); +use ILIAS\Mail\Attachments\MailAttachments; + /** * Remote object app base class * @@ -629,7 +631,7 @@ protected function sendNewContentNotification($a_server_id): void '', $lang->txt('ecs_new_econtent_subject'), $message, - array() + MailAttachments::empty() ); } diff --git a/components/ILIAS/Wiki/Notification/NotificationGUI.php b/components/ILIAS/Wiki/Notification/NotificationGUI.php index 9c62c11eae19..e369313d3959 100755 --- a/components/ILIAS/Wiki/Notification/NotificationGUI.php +++ b/components/ILIAS/Wiki/Notification/NotificationGUI.php @@ -22,6 +22,7 @@ use ILIAS\Wiki\InternalGUIService; use ILIAS\Wiki\InternalDomainService; +use ILIAS\Mail\Attachments\MailAttachments; /** * This class is only in GUI layer, since it @@ -225,7 +226,7 @@ public function send( '', $subject, $message, - [], + MailAttachments::empty(), false, false ); diff --git a/components/ILIAS/Wiki/classes/class.ilWikiUtil.php b/components/ILIAS/Wiki/classes/class.ilWikiUtil.php index 40b7ebfba23c..a3bf79e76f91 100755 --- a/components/ILIAS/Wiki/classes/class.ilWikiUtil.php +++ b/components/ILIAS/Wiki/classes/class.ilWikiUtil.php @@ -16,6 +16,8 @@ * *********************************************************************/ +use ILIAS\Mail\Attachments\MailAttachments; + /** * Wiki link / page title handling: * @@ -638,7 +640,7 @@ public static function sendNotification( '', $subject, $message, - [], + MailAttachments::empty(), false, false );