From 688b42b0baebc09b563f20c84456de4102ffd724 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Thu, 18 Jun 2026 11:21:07 +0200 Subject: [PATCH 01/43] [Feature] Mail: IRSS --- .../Attachments/FileDataRCHandling.php | 102 ++--- .../classes/Attachments/MailAttachments.php | 157 +++++++ .../class.ilMailDeliveryJob.php | 3 +- .../class.ilMassMailDeliveryJob.php | 3 +- .../MailDeletionHandler.php | 53 ++- .../Folder/OutboxDatabaseRepository.php | 5 +- .../ILIAS/Mail/classes/MailDeliveryData.php | 6 +- .../Mail/classes/Message/MailBoxQuery.php | 14 +- .../Mail/classes/Message/MailRecordData.php | 13 +- .../Mail/classes/Message/MailRecordMapper.php | 9 +- .../classes/Setup/MailDBUpdateSteps11.php | 17 + .../MigrateMailAttachmentsToIRSS.php | 179 ++++++++ .../classes/Setup/class.ilMailSetupAgent.php | 4 +- .../Mail/classes/class.ilFileDataMail.php | 423 +++++++++++++++++- .../ILIAS/Mail/classes/class.ilMail.php | 148 +++--- .../classes/class.ilMailAttachmentGUI.php | 95 ++-- .../class.ilMailAttachmentStakeholder.php | 37 +- .../Mail/classes/class.ilMailFolderGUI.php | 63 ++- .../Mail/classes/class.ilMailFormGUI.php | 107 +++-- .../Mail/classes/class.ilMailNotification.php | 4 +- .../ILIAS/Mail/classes/class.ilObjMailGUI.php | 3 +- .../ILIAS/Mail/tests/MailAttachmentsTest.php | 59 +++ components/ILIAS/Mail/tests/ilMailTest.php | 35 +- 23 files changed, 1256 insertions(+), 283 deletions(-) create mode 100644 components/ILIAS/Mail/classes/Attachments/MailAttachments.php create mode 100644 components/ILIAS/Mail/classes/Setup/Migration/MigrateMailAttachmentsToIRSS.php create mode 100644 components/ILIAS/Mail/tests/MailAttachmentsTest.php diff --git a/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php b/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php index 9f375c4dc329..ba389232a459 100644 --- a/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php +++ b/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php @@ -18,9 +18,8 @@ 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 { @@ -28,36 +27,14 @@ trait FileDataRCHandling * @param list $path_to_files */ protected function getCurrentCollection( - array $path_to_files, - ilMailAttachmentStakeholder $stakeholder + array $path_to_files ): \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(); + $rcid = $this->fdm->createCollectionFromPaths($path_to_files); + if ($rcid === null) { + throw new Exception('Storing file into collection failed: no files found'); } - return $collection; + return $this->fdm->getCollection($rcid); } /** @@ -66,33 +43,26 @@ protected function getCurrentCollection( */ public function filesFromLegacyToIRSS(array $mail_data): array { - $files = []; + $attachments = $mail_data['attachments'] ?? null; + if (!$attachments instanceof MailAttachments || !$attachments->isLegacy()) { + throw new InvalidArgumentException('Legacy mail attachments expected.'); + } + $path_to_files = []; - foreach ($mail_data['attachments'] as $file) { + foreach ($attachments->legacyFilenames() as $file) { $path_to_files[] = $this->fdm->getAbsoluteAttachmentPoolPathByFilename($file); } - $collection = $this->getCurrentCollection($path_to_files, new ilMailAttachmentStakeholder()); - foreach ($collection->getResourceIdentifications() as $rcid) { - $files[] = $rcid->serialize(); - } + $collection = $this->getCurrentCollection($path_to_files); - return $files; + return $this->fdm->getRidsFromCollection($collection->getIdentification()); } /** - * @param array $mail_data + * @param list $filenames */ - public function getIdforCollection(array $mail_data): ?ResourceCollectionIdentification + public function getIdforCollection(array $filenames): ?ResourceCollectionIdentification { - $files = []; - $path_to_files = []; - foreach ($mail_data as $attachment) { - $path_to_files[] = $this->fdm->getAbsoluteAttachmentPoolPathByFilename($attachment); - } - $collection = $this->getCurrentCollection($path_to_files, new ilMailAttachmentStakeholder()); - $rcid = $collection->getIdentification(); - - return $rcid; + return $this->fdm->createCollectionFromPoolFilenames($filenames); } /** @@ -100,22 +70,14 @@ public function getIdforCollection(array $mail_data): ?ResourceCollectionIdentif */ 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 $files; + return $this->fdm->getRidsFromCollection($identification); } /** * @param array $attachments - * @return list * @throws ilMailAttachmentsTotalSizeLimitExceededException */ - protected function handleAttachments(array $attachments): array + protected function handleAttachments(array $attachments): ResourceCollectionIdentification { $limit = $this->fdm->getAttachmentsTotalSizeLimit(); $total_size = 0; @@ -131,23 +93,23 @@ protected function handleAttachments(array $attachments): array ); } - $files = []; + $resource_identifications = []; 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 ($info->getFileIdentifier() === 'unknown') { + continue; + } + $found = $this->storage->manage()->find($attachment); + if ($found === null) { + throw new Exception("File '" . $info->getName() . "' could not be found in IRSS"); } + $resource_identifications[] = $found; + } + + if ($resource_identifications === []) { + throw new Exception('No attachments could be stored'); } - return $files; + return $this->fdm->createCollectionFromResourceIdentifications($resource_identifications); } } diff --git a/components/ILIAS/Mail/classes/Attachments/MailAttachments.php b/components/ILIAS/Mail/classes/Attachments/MailAttachments.php new file mode 100644 index 000000000000..8499fd933354 --- /dev/null +++ b/components/ILIAS/Mail/classes/Attachments/MailAttachments.php @@ -0,0 +1,157 @@ + */ + 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, []); + } + + /** + * @param list $filenames + */ + public static function fromLegacyFilenames(array $filenames): self + { + if ($filenames === []) { + return self::empty(); + } + + return new self(null, array_values($filenames)); + } + + public static function fromDb(?string $raw): ?self + { + if ($raw === null || $raw === '') { + return null; + } + + if (str_contains($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); + } + + 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/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/BackgroundTask/class.ilMassMailDeliveryJob.php b/components/ILIAS/Mail/classes/BackgroundTask/class.ilMassMailDeliveryJob.php index d97d5ddbe707..777b298ac8ad 100755 --- a/components/ILIAS/Mail/classes/BackgroundTask/class.ilMassMailDeliveryJob.php +++ b/components/ILIAS/Mail/classes/BackgroundTask/class.ilMassMailDeliveryJob.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 ilMassMailDeliveryJob extends AbstractJob { @@ -73,7 +74,7 @@ public function run(array $input, Observer $observer): Value $recipients_bcc, $value_object->getSubject(), $value_object->getBody(), - $value_object->getAttachments(), + MailAttachments::fromLegacyFilenames($value_object->getAttachments()), $value_object->isUsingPlaceholders() ); $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..ee6ecb035326 100755 --- a/components/ILIAS/Mail/classes/Cron/ExpiredOrOrphanedMails/MailDeletionHandler.php +++ b/components/ILIAS/Mail/classes/Cron/ExpiredOrOrphanedMails/MailDeletionHandler.php @@ -31,6 +31,7 @@ use Throwable; use RecursiveIteratorIterator; use RecursiveDirectoryIterator; +use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; use SplFileInfo; class MailDeletionHandler @@ -41,6 +42,7 @@ class MailDeletionHandler private readonly ilSetting $settings; private readonly ilLogger $logger; private readonly ilDBStatement $mail_ids_for_path_stmt; + private readonly ilDBStatement $mail_ids_for_rcid_stmt; /** @var callable(string): void|null */ private $delete_directory_callback; @@ -53,7 +55,7 @@ public function __construct( ?ilDBInterface $db = null, ?ilSetting $setting = null, ?ilLogger $logger = null, - ?callable $delete_directory_callback = null + ?callable $delete_directory_callback = null, ) { global $DIC; @@ -66,6 +68,46 @@ public function __construct( 'SELECT COUNT(*) cnt FROM mail_attachment WHERE path = ?', [ilDBConstants::T_TEXT] ); + $this->mail_ids_for_rcid_stmt = $this->db->prepare( + 'SELECT COUNT(*) cnt FROM mail_attachment WHERE rcid = ?', + [ilDBConstants::T_TEXT] + ); + } + + /** + * @return list + */ + private function determineDeletableAttachmentRcids(): array + { + $rcids = []; + $res = $this->db->query( + ' + SELECT rcid, COUNT(mail_id) cnt_mail_ids + 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 + ) . ' GROUP BY rcid' + ); + + while ($row = $this->db->fetchAssoc($res)) { + $num_usages_total = (int) $this->db->fetchAssoc( + $this->db->execute( + $this->mail_ids_for_rcid_stmt, + [$row['rcid']] + ) + )['cnt']; + $num_usages_within_deleted_mails = (int) $row['cnt_mail_ids']; + + if ($num_usages_within_deleted_mails >= $num_usages_total) { + $rcids[] = $row['rcid']; + } + } + + return $rcids; } /** @@ -122,6 +164,15 @@ private function deleteDirectory(string $directory): void private function deleteAttachments(): void { + $mail_file_data = new ilFileDataMail(); + foreach ($this->determineDeletableAttachmentRcids() as $rcid) { + try { + $mail_file_data->removeCollection(new ResourceCollectionIdentification($rcid)); + } catch (Throwable $e) { + $this->logger->warning($e->getMessage()); + } + } + $attachment_paths = $this->determineDeletableAttachmentPaths(); $i = 0; 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..43c6a06040fd 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 readonly 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; } diff --git a/components/ILIAS/Mail/classes/Message/MailBoxQuery.php b/components/ILIAS/Mail/classes/Message/MailBoxQuery.php index 807fadfc12a6..eccfb35453a9 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 { @@ -52,9 +52,8 @@ class MailBoxQuery private string $order_direction = self::DEFAULT_ORDER_DIRECTION; private ?bool $scheduled_only = null; - public function __construct( - private readonly int $user_id, - ) { + public function __construct(private readonly int $user_id) + { global $DIC; $this->db = $DIC->database(); } @@ -287,11 +286,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/Setup/MailDBUpdateSteps11.php b/components/ILIAS/Mail/classes/Setup/MailDBUpdateSteps11.php index 5a476ae33cc7..f9154cd088de 100644 --- a/components/ILIAS/Mail/classes/Setup/MailDBUpdateSteps11.php +++ b/components/ILIAS/Mail/classes/Setup/MailDBUpdateSteps11.php @@ -68,4 +68,21 @@ public function step_2(): void ); } } + + public function step_3(): void + { + if (!$this->db->tableColumnExists('mail_attachment', 'rcid')) { + $this->db->addTableColumn( + 'mail_attachment', + 'rcid', + [ + 'type' => ilDBConstants::T_TEXT, + 'length' => 64, + 'notnull' => false, + 'default' => null, + ] + ); + } + } + } diff --git a/components/ILIAS/Mail/classes/Setup/Migration/MigrateMailAttachmentsToIRSS.php b/components/ILIAS/Mail/classes/Setup/Migration/MigrateMailAttachmentsToIRSS.php new file mode 100644 index 000000000000..3423d393021c --- /dev/null +++ b/components/ILIAS/Mail/classes/Setup/Migration/MigrateMailAttachmentsToIRSS.php @@ -0,0 +1,179 @@ +helper = new ilResourceStorageMigrationHelper( + new ilMailAttachmentStakeholder(), + $environment + ); + } + + public function step(Environment $environment): void + { + $db = $this->helper->getDatabase(); + $res = $db->query( + 'SELECT path FROM mail_attachment + WHERE (rcid IS NULL OR rcid = "") + AND path IS NOT NULL AND path != "" + GROUP BY path + LIMIT ' . self::PATHS_PER_STEP + ); + + $mail_path = rtrim($this->helper->getClientDataDir(), '/') . '/mail'; + + while ($row = $db->fetchObject($res)) { + $relative_path = (string) $row->path; + $absolute_path = $mail_path . '/' . $relative_path; + + if (!is_dir($absolute_path)) { + $this->markPathAsSkipped($relative_path); + + continue; + } + + $owner_id = $this->resolveOwnerIdForPath($relative_path); + $rcid = $this->helper->moveFilesOfPathToCollection( + $absolute_path, + $owner_id, + $owner_id + ); + + if ($rcid === null) { + $this->markPathAsSkipped($relative_path); + + continue; + } + + $this->assignRcidToPath($relative_path, $rcid); + $this->updateMailAttachmentFields($relative_path, $rcid); + } + } + + public function getRemainingAmountOfSteps(): int + { + return (int) $this->helper->getDatabase()->fetchObject( + $this->helper->getDatabase()->query( + 'SELECT COUNT(DISTINCT path) cnt FROM mail_attachment + WHERE (rcid IS NULL OR rcid = "") + AND path IS NOT NULL AND path != ""' + ) + )->cnt; + } + + private function resolveOwnerIdForPath(string $relative_path): int + { + $db = $this->helper->getDatabase(); + $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 + LIMIT 1', + [ilDBConstants::T_TEXT], + [$relative_path] + ); + + $row = $db->fetchObject($res); + if ($row !== null && (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 + { + $db = $this->helper->getDatabase(); + $res = $db->queryF( + 'SELECT m.mail_id, m.attachments FROM mail_attachment ma + INNER JOIN mail m ON m.mail_id = ma.mail_id + WHERE ma.path = %s', + [ilDBConstants::T_TEXT], + [$relative_path] + ); + + while ($row = $db->fetchObject($res)) { + if (!is_string($row->attachments) || $row->attachments === '') { + continue; + } + if (!str_contains($row->attachments, 'a:')) { + continue; + } + + $db->update( + 'mail', + [ + 'attachments' => [ilDBConstants::T_CLOB, $rcid->serialize()], + ], + [ + 'mail_id' => [ilDBConstants::T_INTEGER, (int) $row->mail_id], + ] + ); + } + } + + 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], + ['-', $relative_path] + ); + } +} diff --git a/components/ILIAS/Mail/classes/Setup/class.ilMailSetupAgent.php b/components/ILIAS/Mail/classes/Setup/class.ilMailSetupAgent.php index c0c184280aa5..45e8337ea07e 100755 --- a/components/ILIAS/Mail/classes/Setup/class.ilMailSetupAgent.php +++ b/components/ILIAS/Mail/classes/Setup/class.ilMailSetupAgent.php @@ -23,6 +23,7 @@ use ILIAS\Setup\ObjectiveCollection; use ILIAS\Mail\Setup\MailDBUpdateSteps11; use ILIAS\Mail\Setup\Migration\MailOutboxMigration; +use ILIAS\Mail\Setup\Migration\MigrateMailAttachmentsToIRSS; class ilMailSetupAgent implements Setup\Agent { @@ -74,7 +75,8 @@ public function getStatusObjective(Setup\Metrics\Storage $storage): Setup\Object public function getMigrations(): array { return [ - new MailOutboxMigration() + new MailOutboxMigration(), + new MigrateMailAttachmentsToIRSS(), ]; } } diff --git a/components/ILIAS/Mail/classes/class.ilFileDataMail.php b/components/ILIAS/Mail/classes/class.ilFileDataMail.php index ba439f571bc2..ea38d436f5a0 100755 --- a/components/ILIAS/Mail/classes/class.ilFileDataMail.php +++ b/components/ILIAS/Mail/classes/class.ilFileDataMail.php @@ -19,7 +19,14 @@ declare(strict_types=1); use ILIAS\Filesystem\Filesystem; +use ILIAS\ResourceStorage\Services; +use ILIAS\Filesystem\Stream\Streams; use ILIAS\FileUpload\DTO\UploadResult; +use ILIAS\ResourceStorage\Collection\ResourceCollection; +use ILIAS\ResourceStorage\Identification\ResourceIdentification; +use ILIAS\ResourceStorage\Resource\Repository\CollectionDBRepository; +use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; +use ILIAS\FileDelivery\Delivery as FileDelivery; class ilFileDataMail extends ilFileData { @@ -29,6 +36,8 @@ class ilFileDataMail extends ilFileData 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 +52,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; @@ -91,6 +91,22 @@ public function getAbsoluteAttachmentPoolPathPrefix(): string */ public function getAttachmentPathAndFilenameByMd5Hash(string $md5FileHash, int $mail_id): array { + $rcid = $this->getRcidForMail($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 @@ -398,6 +414,17 @@ public function checkFilesExist(array $a_files): bool return true; } + 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()] + ); + } + public function assignAttachmentsToDirectory(int $a_mail_id, int $a_sent_mail_id): void { $storage = self::getStorage($a_sent_mail_id, $this->user_id); @@ -410,18 +437,56 @@ public function assignAttachmentsToDirectory(int $a_mail_id, int $a_sent_mail_id ); } + public function getRcidForMail(int $mail_id): ?ResourceCollectionIdentification + { + $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 countMailsReferencingRcid(string $rcid): int + { + $res = $this->db->queryF( + 'SELECT COUNT(mail_id) cnt FROM mail_attachment WHERE rcid = %s', + ['text'], + [$rcid] + ); + + return (int) $this->db->fetchObject($res)->cnt; + } + 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 ($path !== '') { + if ($rcid !== '' && $rcid !== '-') { + if ($this->countMailsReferencingRcid($rcid) === 1) { + $this->removeCollection(new ResourceCollectionIdentification($rcid)); + } + } elseif ($path !== '') { $res = $this->db->query( 'SELECT COUNT(mail_id) count_mail_id FROM mail_attachment WHERE path = ' . $this->db->quote($path, 'text') @@ -518,6 +583,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 +611,26 @@ public function onUserDelete(): void } } + $rcid_query = ' + 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 != "-" + AND (SELECT COUNT(tmp.rcid) FROM mail_attachment tmp WHERE tmp.rcid = ma1.rcid) = 1 + '; + $rcid_res = $this->db->queryF( + $rcid_query, + ['integer'], + [$this->user_id] + ); + while ($row = $this->db->fetchAssoc($rcid_res)) { + try { + $this->removeCollection(new ResourceCollectionIdentification($row['rcid'])); + } catch (Exception) { + } + } + // Delete each mail attachment rows assigned to a message of the deleted user. $this->db->manipulateF( ' @@ -570,6 +656,12 @@ 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 +715,307 @@ public function deliverAttachmentsAsZip( ilFileUtils::getValidFilename($download_filename . '.zip') ); } + + public function getStakeholder(): ilMailAttachmentStakeholder + { + return $this->stakeholder; + } + + 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 + */ + public 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); + } + + /** + * @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(basename($path)); + if ($this->resourceIdByHashInCollection($collection, $hash) !== null) { + continue; + } + + $collection->add($this->streamFromPath($path, $hash)); + $added = true; + } + + if (!$added) { + return null; + } + + $this->irss->collection()->store($collection); + + return $collection->getIdentification(); + } + + public function poolFilenameHash(string $pool_filename): string + { + return md5(basename($this->getAbsoluteAttachmentPoolPathByFilename($pool_filename))); + } + + /** + * @param list $absolute_paths + */ + public function createCollectionFromPaths(array $absolute_paths): ?ResourceCollectionIdentification + { + $resource_identifications = []; + foreach ($absolute_paths as $absolute_path) { + if (!is_file($absolute_path)) { + continue; + } + $resource_identifications[] = $this->streamFromPath($absolute_path); + } + + if ($resource_identifications === []) { + return null; + } + + return $this->createCollectionFromResourceIdentifications($resource_identifications); + } + + public function getCollection(ResourceCollectionIdentification $rcid): ResourceCollection + { + if (!$this->collectionIsKnown($rcid)) { + throw new OutOfBoundsException( + sprintf('Mail attachment collection "%s" does not exist.', $rcid->serialize()) + ); + } + + $this->repairCollectionHeaderIfNeeded($rcid); + + return $this->irss->collection()->get($rcid, $this->user_id); + } + + /** + * @return list + */ + private function getAssignedRidStrings(ResourceCollectionIdentification $rcid): array + { + $res = $this->db->queryF( + 'SELECT ' . CollectionDBRepository::R_IDENTIFICATION . + ' FROM ' . CollectionDBRepository::COLLECTION_ASSIGNMENT_TABLE_NAME . + ' WHERE ' . CollectionDBRepository::C_IDENTIFICATION . ' = %s ORDER BY position ASC', + [ilDBConstants::T_TEXT], + [$rcid->serialize()] + ); + + $rids = []; + while ($row = $this->db->fetchAssoc($res)) { + $rids[] = (string) $row[CollectionDBRepository::R_IDENTIFICATION]; + } + + return $rids; + } + + private function collectionIsKnown(ResourceCollectionIdentification $rcid): bool + { + return $this->irss->collection()->exists($rcid->serialize()) + || $this->getAssignedRidStrings($rcid) !== []; + } + + private function repairCollectionHeaderIfNeeded(ResourceCollectionIdentification $rcid): void + { + if ($this->irss->collection()->exists($rcid->serialize()) + || $this->getAssignedRidStrings($rcid) === []) { + return; + } + + $this->db->replace( + CollectionDBRepository::COLLECTION_TABLE_NAME, + [ + CollectionDBRepository::C_IDENTIFICATION => [ilDBConstants::T_TEXT, $rcid->serialize()], + ], + [ + 'title' => [ilDBConstants::T_TEXT, ''], + 'owner_id' => [ilDBConstants::T_INTEGER, $this->user_id], + ] + ); + } + + public function getResourceIdByHash( + ResourceCollectionIdentification $rcid, + string $hash + ): ?ResourceIdentification { + if (!$this->collectionIsKnown($rcid)) { + return null; + } + + 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); + if ($revision->getTitle() === $hash) { + return $resource_identification; + } + } + + return null; + } + + 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; + } + + /** + * @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[$file_title] = [ + 'md5' => $revision->getTitle(), + '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(); + } + + public function removeCollection(ResourceCollectionIdentification $rcid, bool $ignore_usage = true): void + { + $this->irss->collection()->remove( + $this->irss->collection()->id($rcid->serialize()), + $this->stakeholder, + $ignore_usage + ); + } + + /** + * @return list + */ + public function getPathsForMimeAttachments(ResourceCollectionIdentification $rcid): array + { + $attachments = []; + foreach ($this->getCollection($rcid)->getResourceIdentifications() as $resource_identification) { + $revision = $this->irss->manage()->getCurrentRevision($resource_identification); + $info = $revision->getInformation(); + $stream = $this->irss->consume()->stream($resource_identification); + $temp_path = ilFileUtils::ilTempnam(); + file_put_contents($temp_path, (string) $stream->getStream()); + $attachments[] = [ + 'path' => $temp_path, + '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..5c10b1c8eb3b 100755 --- a/components/ILIAS/Mail/classes/class.ilMail.php +++ b/components/ILIAS/Mail/classes/class.ilMail.php @@ -24,7 +24,7 @@ use ILIAS\Mail\Recipient; use ILIAS\Mail\Service\MailSignatureService; use ILIAS\Mail\Transformation\Utf8Mb4Sanitizer; -use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; +use ILIAS\Mail\Attachments\MailAttachments; use ILIAS\Mail\Folder\MailScheduleData; use ILIAS\Mail\Message\MailRecordMapper; @@ -410,12 +410,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, @@ -431,7 +428,7 @@ public function updateDraft( $this->table_mail, [ 'folder_id' => ['integer', $a_folder_id], - 'attachments' => ['clob', serialize($a_attachments)], + 'attachments' => [ilDBConstants::T_BLOB, $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], @@ -472,7 +469,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()], @@ -503,7 +500,7 @@ public function scheduledMail( private function sendInternalMail( int $folder_id, int $sender_usr_id, - array $attachments, + MailAttachments $attachments, string $to, string $cc, string $bcc, @@ -529,7 +526,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_BLOB, $attachments->toDb()], 'send_time' => ['timestamp', date('Y-m-d H:i:s')], 'rcp_to' => ['clob', $to], 'rcp_cc' => ['clob', $cc], @@ -768,9 +765,11 @@ 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, + $mail_data->getAttachments(), + $mail_data->getInternalMailId() + ); } $this->delegateExternalEmails( @@ -781,13 +780,9 @@ private function sendChanneledMails( ); } - /** - * @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 +916,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,13 +923,14 @@ 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(); } $this->db->replace( $this->table_mail_saved, @@ -945,7 +938,7 @@ public function persistToStage( '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], @@ -978,18 +971,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; @@ -1007,8 +995,11 @@ public function enqueue( ' | Attachments: ' . print_r($a_attachment, true) ); - if ($a_attachment && !$this->mail_file_data->checkFilesExist($a_attachment)) { - return [new ilMailError('mail_attachment_file_not_exist', [implode(', ', $a_attachment)])]; + if ( + $a_attachment->isLegacy() + && !$this->mail_file_data->checkFilesExist($a_attachment->legacyFilenames()) + ) { + return [new ilMailError('mail_attachment_file_not_exist', [implode(', ', $a_attachment->legacyFilenames())])]; } $errors = $this->checkMail($a_rcp_to, $a_rcp_cc, $a_rcp_bcc, $a_m_subject); @@ -1064,7 +1055,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, @@ -1111,9 +1102,16 @@ public function sendMail( ); $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()); + $this->assignMailAttachments( + $internal_message_id, + $mail_data->getAttachments(), + $internal_message_id + ); + if ($mail_data->getAttachments()->isLegacy()) { + $this->mail_file_data->saveFiles( + $internal_message_id, + $mail_data->getAttachments()->legacyFilenames() + ); } $num_external_email_addresses = $this->getCountRecipients( @@ -1202,11 +1200,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 +1222,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,32 +1257,62 @@ function (string $message): string { $mailer->Bcc($bcc); } - foreach ($attachments as $attachment) { - $mailer->Attach( - $this->mail_file_data->getAbsoluteAttachmentPoolPathByFilename($attachment), - '', - 'inline', - $attachment - ); + if ($attachments->isIrss()) { + $mime_attachments = $this->mail_file_data->getPathsForMimeAttachments($attachments->rcid()); + foreach ($mime_attachments as $attachment) { + $mailer->Attach( + $attachment['path'], + '', + 'inline', + $attachment['name'] + ); + } + } elseif ($attachments->isLegacy()) { + foreach ($attachments->legacyFilenames() as $attachment) { + $mailer->Attach( + $this->mail_file_data->getAbsoluteAttachmentPoolPathByFilename($attachment), + '', + 'inline', + $attachment + ); + } } $mailer->Send(); } - public function saveAttachments(?ResourceCollectionIdentification $attachments): void - { - if (!is_null($attachments)) { - $attachments = $attachments->serialize(); + private function assignMailAttachments( + int $mail_id, + MailAttachments $attachments, + int $sent_mail_id + ): void { + if ($attachments->isEmpty()) { + return; } - $this->db->update( - $this->table_mail_saved, - [ - 'attachments' => ['text', $attachments], - ], - [ - 'user_id' => ['integer', $this->user_id], - ] + if ($attachments->isIrss()) { + $this->mail_file_data->assignAttachmentsToCollection($mail_id, $attachments->rcid()); + return; + } + + $this->mail_file_data->assignAttachmentsToDirectory($mail_id, $sent_mail_id); + } + + 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'] ?? []) ); } diff --git a/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php b/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php index fcecc2d65943..f382f6cb50d8 100644 --- a/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php +++ b/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php @@ -19,6 +19,7 @@ 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; @@ -112,12 +113,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([ @@ -130,19 +125,27 @@ private function saveAttachments(): void $files_of_request = array_map(static fn(array $file): string => $file['name'], $this->fdm->getUserFilesData()); } + $files = []; + $size_of_affected_files = 0; 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)) - ); + $decoded = urldecode((string) $file); + $pool_path = $this->fdm->getAbsoluteAttachmentPoolPathByFilename($decoded); + if (!is_file($pool_path)) { + continue; } + + $files[] = $decoded; + $size_of_affected_files += (int) filesize($pool_path); } - if ($files !== [] && - $this->fdm->getAttachmentsTotalSizeLimit() !== null && - $size_of_affected_files > $this->fdm->getAttachmentsTotalSizeLimit()) { + if ($files === []) { + $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 +155,26 @@ private function saveAttachments(): void return; } - $rcid_for_files = $this->getIdforCollection($files); - $this->umail->saveAttachments($rcid_for_files); + try { + $mail_data = $this->umail->retrieveFromStage(); + $stage_attachments = $mail_data['attachments'] ?? null; + $existing_rcid = ($stage_attachments instanceof MailAttachments && $stage_attachments->isIrss()) + ? $stage_attachments->rcid() + : null; + + $rcid = $this->fdm->adoptPoolFilenamesToCollection($existing_rcid, $files); + 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 @@ -229,20 +248,11 @@ private function deleteAttachmentsCommand(): void $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); - } + $stage_attachments = $mail_data['attachments'] ?? null; + if ($stage_attachments instanceof MailAttachments && $stage_attachments->isIrss()) { + $files_to_legacy = $this->FilesFromIRSSToLegacy($stage_attachments->rcid()); + $rcid = $this->handleAttachments($files_to_legacy); + $this->umail->saveAttachments(MailAttachments::fromIrss($rcid)); } $this->tpl->setOnScreenMessage($this->tpl::MESSAGE_TYPE_SUCCESS, $this->lng->txt('mail_files_deleted'), true); @@ -288,11 +298,22 @@ private function showAttachmentsCommand(): void } $mail_data = $this->umail->retrieveFromStage(); + $stage_attachments = $mail_data['attachments'] ?? null; + $adopted_pool_hashes = []; + if ($stage_attachments instanceof MailAttachments && $stage_attachments->isIrss()) { + foreach ($this->fdm->getAttachmentListing($stage_attachments->rcid()) as $file) { + $adopted_pool_hashes[$file['md5']] = true; + } + } + $files = $this->fdm->getUserFilesData(); $records = []; $checked_items = []; foreach ($files as $file) { - if (is_array($mail_data['attachments']) && in_array($file['name'], $mail_data['attachments'], true)) { + if ($stage_attachments instanceof MailAttachments && $stage_attachments->isLegacy() + && in_array($file['name'], $stage_attachments->legacyFilenames(), true)) { + $checked_items[] = urlencode($file['name']); + } elseif (isset($adopted_pool_hashes[$this->fdm->poolFilenameHash($file['name'])])) { $checked_items[] = urlencode($file['name']); } @@ -340,6 +361,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 +374,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(), }; } diff --git a/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php b/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php index af9a116d3bab..cac1dd147e8a 100644 --- a/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php +++ b/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php @@ -18,14 +18,11 @@ declare(strict_types=1); +use ILIAS\ResourceStorage\Identification\ResourceIdentification; use ILIAS\ResourceStorage\Stakeholder\AbstractResourceStakeholder; class ilMailAttachmentStakeholder extends AbstractResourceStakeholder { - public function __construct() - { - } - public function getId(): string { return 'mail_attachments'; @@ -33,6 +30,36 @@ public function getId(): string 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; + } + + $user_id = $DIC->user()->getId(); + $db = $DIC->database(); + $rid = $identification->serialize(); + + $res = $db->queryF( + 'SELECT 1 FROM il_resource_rca rca + INNER JOIN mail_attachment ma ON ma.rcid = rca.rcid + INNER JOIN mail m ON m.mail_id = ma.mail_id + WHERE rca.rid = %s AND m.user_id = %s + LIMIT 1', + [ilDBConstants::T_TEXT, ilDBConstants::T_INTEGER], + [$rid, $user_id] + ); + + return $db->numRows($res) > 0; } } diff --git a/components/ILIAS/Mail/classes/class.ilMailFolderGUI.php b/components/ILIAS/Mail/classes/class.ilMailFolderGUI.php index 868783b1a8bb..0ccc51a6490d 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()); @@ -1185,7 +1195,11 @@ protected function deliverFile(): void $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,38 @@ 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 { + $mail_file_data->deliverAttachmentsAsZip( + $mail_data['m_subject'], + $mail_id, + [], + $type === 'draft' + ); + } + return; + } + + if ($attachments->isLegacy() && count($attachments->legacyFilenames()) === 1) { + $attachment = current($attachments->legacyFilenames()); try { if ($type === 'draft') { @@ -1224,25 +1258,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); diff --git a/components/ILIAS/Mail/classes/class.ilMailFormGUI.php b/components/ILIAS/Mail/classes/class.ilMailFormGUI.php index d8ae254bbc2d..b39ff46f8089 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 = ''; @@ -230,12 +231,13 @@ protected function decodeAttachmentFiles(array $files): array public function saveMessageToOutbox(array $form_values, Form $form): void { - $files = []; + $attachments = MailAttachments::empty(); if (count($form_values['attachments']) > 0) { - $files = $this->tryHandleAttachments($form_values['attachments'], $form); - if ($files === null) { + $handled = $this->tryHandleAttachments($form_values['attachments'], $form); + if ($handled === null) { return; } + $attachments = $handled; } $rcp_to = ''; @@ -280,7 +282,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 ), @@ -327,12 +329,13 @@ public function sendMessage(): void return; } - $files = []; + $attachments = MailAttachments::empty(); if (count($value['attachments']) > 0) { - $files = $this->tryHandleAttachments($value['attachments'], $form); - if ($files === null) { + $handled = $this->tryHandleAttachments($value['attachments'], $form); + if ($handled === null) { return; } + $attachments = $handled; } $mailer = $this->umail @@ -362,7 +365,7 @@ 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(); @@ -426,12 +429,13 @@ public function saveDraft(): void if ($value['m_subject'] === '') { $value['m_subject'] = $this->lng->txt('mail_no_subject'); } - $files = []; + $attachments = MailAttachments::empty(); if (count($value['attachments']) > 0) { - $files = $this->tryHandleAttachments($value['attachments'], $form); - if ($files === null) { + $handled = $this->tryHandleAttachments($value['attachments'], $form); + if ($handled === null) { return; } + $attachments = $handled; } $draft_folder_id = $this->mbox->getDraftsFolder(); @@ -441,7 +445,7 @@ 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->request_attachments = $attachments; $this->showSubmissionErrors($errors); $this->showForm($form); return; @@ -456,7 +460,7 @@ public function saveDraft(): void $this->umail->updateDraft( $draft_folder_id, - $files, + $attachments, $rcp_to, $rcp_cc, $rcp_bcc, @@ -717,8 +721,14 @@ public function showForm(?Form $form = null): void 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) { + if ($mail_data['attachments']->isIrss()) { + $mail_data['attachments'] = $this->FilesFromIRSSToLegacy($mail_data['attachments']->rcid()); + } elseif ($mail_data['attachments']->isLegacy()) { + $mail_data['attachments'] = $this->filesFromLegacyToIRSS($mail_data); + } else { + $mail_data['attachments'] = []; + } } ilMailFormCall::setContextId($mail_data['tpl_ctx_id']); @@ -737,15 +747,20 @@ 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) { + if ($mail_data['attachments']->isIrss()) { + $mail_data['attachments'] = $this->FilesFromIRSSToLegacy($mail_data['attachments']->rcid()); + } elseif ($mail_data['attachments']->isLegacy()) { + if ($error = $this->mfile->adoptAttachments( + $mail_data['attachments']->legacyFilenames(), + $mail_id + )) { + $this->tpl->setOnScreenMessage('info', $error); + } + $mail_data['attachments'] = $this->filesFromLegacyToIRSS($mail_data); + } else { + $mail_data['attachments'] = []; + } } break; @@ -847,8 +862,12 @@ public function showForm(?Form $form = null): void } } - if ($this->request_attachments) { - $mail_data['attachments'] = $this->request_attachments; + if ($this->request_attachments instanceof MailAttachments) { + if ($this->request_attachments->isIrss()) { + $mail_data['attachments'] = $this->FilesFromIRSSToLegacy($this->request_attachments->rcid()); + } elseif ($this->request_attachments->isLegacy()) { + $mail_data['attachments'] = $this->request_attachments->legacyFilenames(); + } } break; } @@ -943,16 +962,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 +981,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() @@ -983,12 +998,11 @@ public function searchMailingListsTo(): void /** * @param array $attachments - * @return list|null */ - protected function tryHandleAttachments(array $attachments, ?Form $form = null): ?array + protected function tryHandleAttachments(array $attachments, ?Form $form = null): ?MailAttachments { try { - return $this->handleAttachments($attachments); + return MailAttachments::fromIrss($this->handleAttachments($attachments)); } catch (ilMailAttachmentsTotalSizeLimitExceededException $e) { $this->tpl->setOnScreenMessage(ilGlobalTemplateInterface::MESSAGE_TYPE_FAILURE, $e->getMessage()); $this->showForm($form); @@ -1027,6 +1041,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 +1074,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 +1084,18 @@ 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']); + $mail_attachments = $mail_data['attachments'] ?? null; + if ($mail_attachments instanceof MailAttachments && !$mail_attachments->isEmpty()) { + if ($mail_attachments->isIrss()) { + $mail_data['attachments'] = $this->FilesFromIRSSToLegacy($mail_attachments->rcid()); + } elseif ($mail_attachments->isLegacy()) { + $mail_data['attachments'] = $mail_attachments->legacyFilenames(); + } else { + $mail_data['attachments'] = []; } - $attachments = $attachments->withValue($mail_data['attachments'] ?? []); + $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.ilMailNotification.php b/components/ILIAS/Mail/classes/class.ilMailNotification.php index 0b79f5d3313d..a2c9a230148b 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; @@ -239,7 +241,7 @@ public function sendMail(array $a_rcp, bool $a_parse_recipients = true): void '', $this->getSubject(), $this->getBody(), - $this->getAttachments() + MailAttachments::fromLegacyFilenames($this->getAttachments()) ); if ($errors !== []) { ilLoggerFactory::getLogger('mail')->dump($errors, ilLogLevel::ERROR); 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/tests/MailAttachmentsTest.php b/components/ILIAS/Mail/tests/MailAttachmentsTest.php new file mode 100644 index 000000000000..4357cdc8f219 --- /dev/null +++ b/components/ILIAS/Mail/tests/MailAttachmentsTest.php @@ -0,0 +1,59 @@ +serialize()); + + $this->assertInstanceOf(MailAttachments::class, $parsed); + $this->assertTrue($parsed->isIrss()); + $this->assertSame('rcid-123', $parsed->rcid()->serialize()); + } + + 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 testIsEmpty(): void + { + $this->assertTrue(MailAttachments::empty()->isEmpty()); + $this->assertFalse(MailAttachments::fromIrss(new ResourceCollectionIdentification('rcid-789'))->isEmpty()); + } +} diff --git a/components/ILIAS/Mail/tests/ilMailTest.php b/components/ILIAS/Mail/tests/ilMailTest.php index 80507a8b0ce1..15188597f788 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 { @@ -264,7 +266,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); @@ -482,7 +484,7 @@ public function testUpdateDraft(): void $draft_id, $instance->updateDraft( $folder_id, - [], + MailAttachments::empty(), $to, $cc, $bcc, @@ -630,17 +632,34 @@ public function testGetIliasMailerName(): void 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->getMockBuilder(ilDBStatement::class)->getMock(); - $this->mock_database->expects($this->once())->method('update')->with( + $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([ + '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); From 8999ae6a775d01d9b832ff637a90cafcd55b7f84 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Thu, 18 Jun 2026 14:46:06 +0200 Subject: [PATCH 02/43] [Feature] Mail: IRSS MimeMailTransport --- .../Mail/classes/Mime/MailMimeAttachment.php | 95 +++++++++++++++++++ .../class.ilMailMimeTransportBase.php | 48 +++++++++- .../Mail/classes/class.ilFileDataMail.php | 19 ++-- .../ILIAS/Mail/classes/class.ilMail.php | 10 +- .../ILIAS/Mail/classes/class.ilMimeMail.php | 60 +++++++----- 5 files changed, 192 insertions(+), 40 deletions(-) create mode 100644 components/ILIAS/Mail/classes/Mime/MailMimeAttachment.php diff --git a/components/ILIAS/Mail/classes/Mime/MailMimeAttachment.php b/components/ILIAS/Mail/classes/Mime/MailMimeAttachment.php new file mode 100644 index 000000000000..768d8841f49e --- /dev/null +++ b/components/ILIAS/Mail/classes/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/classes/Mime/Transport/class.ilMailMimeTransportBase.php b/components/ILIAS/Mail/classes/Mime/Transport/class.ilMailMimeTransportBase.php index d08e72952133..4aa17ab31061 100755 --- a/components/ILIAS/Mail/classes/Mime/Transport/class.ilMailMimeTransportBase.php +++ b/components/ILIAS/Mail/classes/Mime/Transport/class.ilMailMimeTransportBase.php @@ -19,6 +19,8 @@ declare(strict_types=1); use PHPMailer\PHPMailer\PHPMailer; +use ILIAS\Mail\Mime\MailMimeAttachment; +use ILIAS\ResourceStorage\Services; abstract class ilMailMimeTransportBase implements ilMailMimeTransport { @@ -101,8 +103,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 +189,46 @@ 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; + } + + /** @var Services $irss */ + $irss = $GLOBALS['DIC']->resourceStorage(); + $stream = $irss->consume()->stream($resource_identification)->getStream(); + + return $this->getMailer()->addStringAttachment( + (string) $stream, + $attachment->getDisplayName() !== '' + ? $attachment->getDisplayName() + : $resource_identification->serialize(), + PHPMailer::ENCODING_BASE64, + $attachment->getMimeType(), + $attachment->getDisposition() + ); + } } diff --git a/components/ILIAS/Mail/classes/class.ilFileDataMail.php b/components/ILIAS/Mail/classes/class.ilFileDataMail.php index ea38d436f5a0..ec4a28059c80 100755 --- a/components/ILIAS/Mail/classes/class.ilFileDataMail.php +++ b/components/ILIAS/Mail/classes/class.ilFileDataMail.php @@ -999,19 +999,20 @@ public function removeCollection(ResourceCollectionIdentification $rcid, bool $i } /** - * @return list + * @return list */ - public function getPathsForMimeAttachments(ResourceCollectionIdentification $rcid): array + public function getIrssMimeAttachments(ResourceCollectionIdentification $rcid): array { $attachments = []; - foreach ($this->getCollection($rcid)->getResourceIdentifications() as $resource_identification) { - $revision = $this->irss->manage()->getCurrentRevision($resource_identification); - $info = $revision->getInformation(); - $stream = $this->irss->consume()->stream($resource_identification); - $temp_path = ilFileUtils::ilTempnam(); - file_put_contents($temp_path, (string) $stream->getStream()); + 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[] = [ - 'path' => $temp_path, + 'rid' => $rid, 'name' => $info->getTitle(), ]; } diff --git a/components/ILIAS/Mail/classes/class.ilMail.php b/components/ILIAS/Mail/classes/class.ilMail.php index 5c10b1c8eb3b..abfdad4a7aea 100755 --- a/components/ILIAS/Mail/classes/class.ilMail.php +++ b/components/ILIAS/Mail/classes/class.ilMail.php @@ -27,6 +27,7 @@ use ILIAS\Mail\Attachments\MailAttachments; use ILIAS\Mail\Folder\MailScheduleData; use ILIAS\Mail\Message\MailRecordMapper; +use ILIAS\ResourceStorage\Identification\ResourceIdentification; class ilMail { @@ -1258,12 +1259,9 @@ function (string $message): string { } if ($attachments->isIrss()) { - $mime_attachments = $this->mail_file_data->getPathsForMimeAttachments($attachments->rcid()); - foreach ($mime_attachments as $attachment) { - $mailer->Attach( - $attachment['path'], - '', - 'inline', + foreach ($this->mail_file_data->getIrssMimeAttachments($attachments->rcid()) as $attachment) { + $mailer->AttachResource( + new ResourceIdentification($attachment['rid']), $attachment['name'] ); } 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; From 7ec6b41fd1c71275b51abad6776a5dd16bee8666 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Mon, 22 Jun 2026 11:04:43 +0200 Subject: [PATCH 03/43] [Feature] Mail: Fix DBConstants Type failing Unit Tests --- components/ILIAS/Mail/classes/class.ilMail.php | 2 +- components/ILIAS/Mail/tests/ilMailTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/ILIAS/Mail/classes/class.ilMail.php b/components/ILIAS/Mail/classes/class.ilMail.php index abfdad4a7aea..a69ab70593e2 100755 --- a/components/ILIAS/Mail/classes/class.ilMail.php +++ b/components/ILIAS/Mail/classes/class.ilMail.php @@ -429,7 +429,7 @@ public function updateDraft( $this->table_mail, [ 'folder_id' => ['integer', $a_folder_id], - 'attachments' => [ilDBConstants::T_BLOB, $a_attachments->toDb()], + '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], diff --git a/components/ILIAS/Mail/tests/ilMailTest.php b/components/ILIAS/Mail/tests/ilMailTest.php index 15188597f788..d6b9e0809801 100755 --- a/components/ILIAS/Mail/tests/ilMailTest.php +++ b/components/ILIAS/Mail/tests/ilMailTest.php @@ -463,7 +463,7 @@ public function testUpdateDraft(): void $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], From 19c7552b04dc35b1f728605c5b6d8757f3dd923e Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Mon, 6 Jul 2026 11:02:05 +0200 Subject: [PATCH 04/43] [Feature] Mail: IRSS Migrate Component MailAttachement APIs --- .../Badge/classes/class.ilBadgeHandler.php | 3 +- .../class.ilCalendarMailNotification.php | 12 +- .../classes/class.ilTimingsCronReminder.php | 3 +- .../classes/Notification/Notification.php | 14 +-- .../class.ilExAssignmentReminder.php | 4 +- .../Exercise/classes/class.ilObjExercise.php | 23 ++-- ...ss.ilForumCronNotificationDataProvider.php | 39 +++++- ...ass.ilForumMailEventNotificationSender.php | 6 +- .../class.ilForumNotificationDataProvider.php | 39 ++++-- .../classes/class.ilForumMailNotification.php | 2 +- .../interface.ilForumNotificationMailData.php | 4 + .../class.ilLearningModuleNotification.php | 4 +- .../Attachments/FileDataRCHandling.php | 99 ++++++++++++++- .../class.ilMassMailDeliveryJob.php | 2 +- .../MailDeletionHandler.php | 1 + .../Object/class.ilMailValueObject.php | 15 +-- .../class.ilMailValueObjectJsonService.php | 19 ++- .../Mail/classes/class.ilAccountMail.php | 15 ++- .../Mail/classes/class.ilFileDataMail.php | 78 ++++++++++++ .../classes/class.ilMailAttachmentGUI.php | 5 +- .../Mail/classes/class.ilMailFormGUI.php | 117 +++++++++++------- .../Mail/classes/class.ilMailNotification.php | 11 +- .../Mail/tests/ilMailTaskProcessorTest.php | 19 +-- .../Cron/class.ilMembershipCronMinMembers.php | 3 +- .../class.ilMembershipCronNotifications.php | 3 +- .../class.NotificationsManager.php | 4 +- .../classes/ilNotificationMailHandler.php | 3 +- .../classes/class.ilSkillNotifications.php | 3 +- .../classes/Mail/class.ilPRGMail.php | 3 +- .../class.ilSurveyParticipantsGUI.php | 3 +- .../Participants/class.ilSurveyRaterGUI.php | 4 +- .../Survey/classes/class.ilObjSurvey.php | 15 +-- .../classes/class.ilTestMailNotification.php | 12 +- .../Cron/class.ilUserCronCheckAccounts.php | 3 +- .../User/src/Settings/NewAccountMail/Mail.php | 1 + .../classes/class.ilECSAppEventListener.php | 4 +- .../ILIAS/Wiki/classes/class.ilWikiUtil.php | 6 +- 37 files changed, 453 insertions(+), 148 deletions(-) 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..66346174d4be 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 @@ -462,17 +464,19 @@ 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( + $rcid = $attachment->createCollectionFromContent( $ics_filename, $export->getExportString() ); - $this->setAttachments([$effective_ics_filename]); + $this->setAttachments( + $rcid !== null + ? MailAttachments::fromIrss($rcid) + : MailAttachments::empty() + ); } protected function deleteAttachments(): void { - $attachment = new ilFileDataMail($this->getSender()); - $attachment->unlinkFiles($this->getAttachments()); } } diff --git a/components/ILIAS/Course/classes/class.ilTimingsCronReminder.php b/components/ILIAS/Course/classes/class.ilTimingsCronReminder.php index 88a264feb927..30b1d3731c8c 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); 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..c979348db31f 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\ResourceIdentification; /** * Class ilObjExercise @@ -455,15 +457,16 @@ 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 = []; + $attachments = MailAttachments::empty(); if (count($files) > 0) { $mfile_obj = new ilFileDataMail($GLOBALS['DIC']['ilUser']->getId()); + $source_ids = []; foreach ($if->getFiles() as $file) { - $file_names[] = $file["name"]; - $mfile_obj->storeAsAttachment( - $file["name"], - $if->getStream($file["rid"])->getContents() - ); + $source_ids[] = new ResourceIdentification($file['rid']); + } + $rcid = $mfile_obj->createCollectionFromForeignResources($source_ids); + if ($rcid !== null) { + $attachments = MailAttachments::fromIrss($rcid); } } @@ -485,16 +488,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..d8653fee13ad 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,13 +109,31 @@ 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; + } - foreach ($filesOfPost as $attachment) { - $this->attachments[] = $attachment['name']; - } + $fileDataForum = new ilFileDataForum($this->getObjId(), $this->getPostId()); + foreach ($fileDataForum->getFilesOfPost() as $attachment) { + $this->attachments[] = $attachment['name']; + } + + $post = new ilForumPost($this->getPostId()); + $rcid_string = $post->getRCID(); + if ($rcid_string === '' || $rcid_string === ilForumPost::NO_RCID) { + return; + } + + global $DIC; + $forum_collection = $DIC->resourceStorage()->collection()->get( + new ResourceCollectionIdentification($rcid_string) + ); + $fileDataMail = new ilFileDataMail(ANONYMOUS_USER_ID); + $mail_rcid = $fileDataMail->createCollectionFromForeignResources( + iterator_to_array($forum_collection->getResourceIdentifications(), false) + ); + if ($mail_rcid !== null) { + $this->mail_attachments = MailAttachments::fromIrss($mail_rcid); } } @@ -216,6 +238,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..60e6dfc44ee4 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,16 +323,30 @@ 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']); - } + $rcid_string = $this->objPost->getRCID(); + if ($rcid_string === '' || $rcid_string === ilForumPost::NO_RCID) { + return; + } + + global $DIC; + $forum_collection = $DIC->resourceStorage()->collection()->get( + new ResourceCollectionIdentification($rcid_string) + ); + $fileDataMail = new ilFileDataMail(ANONYMOUS_USER_ID); + $mail_rcid = $fileDataMail->createCollectionFromForeignResources( + iterator_to_array($forum_collection->getResourceIdentifications(), false) + ); + if ($mail_rcid !== null) { + $this->mail_attachments = MailAttachments::fromIrss($mail_rcid); } } 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/classes/Attachments/FileDataRCHandling.php b/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php index ba389232a459..9296ffb4d895 100644 --- a/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php +++ b/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php @@ -20,6 +20,7 @@ use ILIAS\Mail\Attachments\MailAttachments; use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; +use ILIAS\ResourceStorage\Identification\ResourceIdentification; trait FileDataRCHandling { @@ -48,13 +49,12 @@ public function filesFromLegacyToIRSS(array $mail_data): array throw new InvalidArgumentException('Legacy mail attachments expected.'); } - $path_to_files = []; - foreach ($attachments->legacyFilenames() as $file) { - $path_to_files[] = $this->fdm->getAbsoluteAttachmentPoolPathByFilename($file); + $rcid = $this->fdm->createCollectionFromPoolFilenames($attachments->legacyFilenames()); + if ($rcid === null) { + return []; } - $collection = $this->getCurrentCollection($path_to_files); - return $this->fdm->getRidsFromCollection($collection->getIdentification()); + return $this->fdm->getRidsFromCollection($rcid); } /** @@ -73,6 +73,58 @@ public function FilesFromIRSSToLegacy(ResourceCollectionIdentification $identifi return $this->fdm->getRidsFromCollection($identification); } + /** + * @param list $form_attachment_rids + * @throws ilMailAttachmentsTotalSizeLimitExceededException + */ + protected function attachmentsFromFormUpload( + array $form_attachment_rids, + ?MailAttachments $stage_attachments = null + ): MailAttachments { + if ($form_attachment_rids === []) { + return $stage_attachments ?? MailAttachments::empty(); + } + + $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) + ); + } + + $resource_identifications = []; + foreach ($form_attachment_rids as $attachment) { + $found = $this->storage->manage()->find($attachment); + if ($found === null) { + continue; + } + $resource_identifications[] = $found; + } + + if ($resource_identifications === []) { + return $stage_attachments ?? MailAttachments::empty(); + } + + $stage_rcid = ($stage_attachments instanceof MailAttachments && $stage_attachments->isIrss()) + ? $stage_attachments->rcid() + : null; + + if ($stage_rcid !== null && $this->fdm->collectionContainsResources($stage_rcid, $resource_identifications)) { + return MailAttachments::fromIrss($stage_rcid); + } + + return MailAttachments::fromIrss( + $this->fdm->createCollectionFromResourceIdentifications($resource_identifications) + ); + } + /** * @param array $attachments * @throws ilMailAttachmentsTotalSizeLimitExceededException @@ -112,4 +164,41 @@ protected function handleAttachments(array $attachments): ResourceCollectionIden return $this->fdm->createCollectionFromResourceIdentifications($resource_identifications); } + + protected function stageAttachmentsFromMailAttachments(MailAttachments $attachments): MailAttachments + { + if ($attachments->isEmpty()) { + return MailAttachments::empty(); + } + + if ($attachments->isIrss()) { + $foreign = iterator_to_array( + $this->fdm->getCollection($attachments->rcid())->getResourceIdentifications(), + false + ); + $cloned_rcid = $this->fdm->createCollectionFromForeignResources($foreign); + + return $cloned_rcid !== null + ? MailAttachments::fromIrss($cloned_rcid) + : MailAttachments::empty(); + } + + $rcid = $this->fdm->createCollectionFromPoolFilenames($attachments->legacyFilenames()); + + return $rcid !== null + ? MailAttachments::fromIrss($rcid) + : MailAttachments::empty(); + } + + /** + * @return list + */ + protected function formRidsFromMailAttachments(MailAttachments $attachments): array + { + if ($attachments->isIrss()) { + return $this->FilesFromIRSSToLegacy($attachments->rcid()); + } + + return []; + } } diff --git a/components/ILIAS/Mail/classes/BackgroundTask/class.ilMassMailDeliveryJob.php b/components/ILIAS/Mail/classes/BackgroundTask/class.ilMassMailDeliveryJob.php index 777b298ac8ad..6ac98a4f7175 100755 --- a/components/ILIAS/Mail/classes/BackgroundTask/class.ilMassMailDeliveryJob.php +++ b/components/ILIAS/Mail/classes/BackgroundTask/class.ilMassMailDeliveryJob.php @@ -74,7 +74,7 @@ public function run(array $input, Observer $observer): Value $recipients_bcc, $value_object->getSubject(), $value_object->getBody(), - MailAttachments::fromLegacyFilenames($value_object->getAttachments()), + $value_object->getAttachments(), $value_object->isUsingPlaceholders() ); $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 ee6ecb035326..218ecc60ee62 100755 --- a/components/ILIAS/Mail/classes/Cron/ExpiredOrOrphanedMails/MailDeletionHandler.php +++ b/components/ILIAS/Mail/classes/Cron/ExpiredOrOrphanedMails/MailDeletionHandler.php @@ -33,6 +33,7 @@ use RecursiveDirectoryIterator; use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; use SplFileInfo; +use ilFileDataMail; class MailDeletionHandler { 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/class.ilAccountMail.php b/components/ILIAS/Mail/classes/class.ilAccountMail.php index c0fc6f57141f..aea9615499eb 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); + } + } else { + $attachment = $amail->getAttachment($this->irss); + if ($attachment !== null) { + $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 ec4a28059c80..f8e1a0cad517 100755 --- a/components/ILIAS/Mail/classes/class.ilFileDataMail.php +++ b/components/ILIAS/Mail/classes/class.ilFileDataMail.php @@ -834,6 +834,56 @@ public function createCollectionFromPaths(array $absolute_paths): ?ResourceColle return $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); + + return $this->createCollectionFromResourceIdentifications([ + $this->irss->manage()->stream( + Streams::ofString($content), + $this->stakeholder, + md5($sanitized_name) + ), + ]); + } + + /** + * @param list $resource_identifications + */ + public function createCollectionFromForeignResources( + array $resource_identifications + ): ?ResourceCollectionIdentification { + if ($resource_identifications === []) { + return null; + } + + $mail_resources = []; + foreach ($resource_identifications as $source) { + $revision = $this->irss->manage()->getCurrentRevision($source); + $stream = $this->irss->consume()->stream($source); + $mail_resources[] = $this->irss->manage()->stream( + $stream->getStream(), + $this->stakeholder, + $revision->getTitle() + ); + } + + return $this->createCollectionFromResourceIdentifications($mail_resources); + } + public function getCollection(ResourceCollectionIdentification $rcid): ResourceCollection { if (!$this->collectionIsKnown($rcid)) { @@ -943,6 +993,34 @@ public function getRidsFromCollection(ResourceCollectionIdentification $rcid): a return $rids; } + + /** + * @param list $resource_identifications + */ + public function collectionContainsResources( + ResourceCollectionIdentification $rcid, + array $resource_identifications + ): bool { + if ($resource_identifications === []) { + return true; + } + + $collection_serials = []; + foreach ($this->getCollection($rcid)->getResourceIdentifications() as $resource_identification) { + $collection_serials[] = $resource_identification->serialize(); + } + + $expected_serials = []; + foreach ($resource_identifications as $resource_identification) { + $expected_serials[] = $resource_identification->serialize(); + } + + sort($collection_serials); + sort($expected_serials); + + return $collection_serials === $expected_serials; + } + /** * @return array */ diff --git a/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php b/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php index f382f6cb50d8..7d0a8b9d5132 100644 --- a/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php +++ b/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php @@ -310,10 +310,7 @@ private function showAttachmentsCommand(): void $records = []; $checked_items = []; foreach ($files as $file) { - if ($stage_attachments instanceof MailAttachments && $stage_attachments->isLegacy() - && in_array($file['name'], $stage_attachments->legacyFilenames(), true)) { - $checked_items[] = urlencode($file['name']); - } elseif (isset($adopted_pool_hashes[$this->fdm->poolFilenameHash($file['name'])])) { + if (isset($adopted_pool_hashes[$this->fdm->poolFilenameHash($file['name'])])) { $checked_items[] = urlencode($file['name']); } diff --git a/components/ILIAS/Mail/classes/class.ilMailFormGUI.php b/components/ILIAS/Mail/classes/class.ilMailFormGUI.php index b39ff46f8089..9bafa40548f3 100755 --- a/components/ILIAS/Mail/classes/class.ilMailFormGUI.php +++ b/components/ILIAS/Mail/classes/class.ilMailFormGUI.php @@ -231,13 +231,16 @@ protected function decodeAttachmentFiles(array $files): array public function saveMessageToOutbox(array $form_values, Form $form): void { - $attachments = MailAttachments::empty(); - if (count($form_values['attachments']) > 0) { - $handled = $this->tryHandleAttachments($form_values['attachments'], $form); - if ($handled === null) { - return; - } - $attachments = $handled; + $stage = $this->umail->retrieveFromStage(); + $stage_attachments = ($stage['attachments'] ?? null) instanceof MailAttachments + ? $stage['attachments'] + : null; + try { + $attachments = $this->attachmentsFromFormUpload($form_values['attachments'], $stage_attachments); + } catch (ilMailAttachmentsTotalSizeLimitExceededException $e) { + $this->tpl->setOnScreenMessage(ilGlobalTemplateInterface::MESSAGE_TYPE_FAILURE, $e->getMessage()); + $this->showForm($form); + return; } $rcp_to = ''; @@ -329,13 +332,16 @@ public function sendMessage(): void return; } - $attachments = MailAttachments::empty(); - if (count($value['attachments']) > 0) { - $handled = $this->tryHandleAttachments($value['attachments'], $form); - if ($handled === null) { - return; - } - $attachments = $handled; + $stage = $this->umail->retrieveFromStage(); + $stage_attachments = ($stage['attachments'] ?? null) instanceof MailAttachments + ? $stage['attachments'] + : null; + try { + $attachments = $this->attachmentsFromFormUpload($value['attachments'], $stage_attachments); + } catch (ilMailAttachmentsTotalSizeLimitExceededException $e) { + $this->tpl->setOnScreenMessage(ilGlobalTemplateInterface::MESSAGE_TYPE_FAILURE, $e->getMessage()); + $this->showForm($form); + return; } $mailer = $this->umail @@ -429,13 +435,16 @@ public function saveDraft(): void if ($value['m_subject'] === '') { $value['m_subject'] = $this->lng->txt('mail_no_subject'); } - $attachments = MailAttachments::empty(); - if (count($value['attachments']) > 0) { - $handled = $this->tryHandleAttachments($value['attachments'], $form); - if ($handled === null) { - return; - } - $attachments = $handled; + $stage = $this->umail->retrieveFromStage(); + $stage_attachments = ($stage['attachments'] ?? null) instanceof MailAttachments + ? $stage['attachments'] + : null; + try { + $attachments = $this->attachmentsFromFormUpload($value['attachments'], $stage_attachments); + } catch (ilMailAttachmentsTotalSizeLimitExceededException $e) { + $this->tpl->setOnScreenMessage(ilGlobalTemplateInterface::MESSAGE_TYPE_FAILURE, $e->getMessage()); + $this->showForm($form); + return; } $draft_folder_id = $this->mbox->getDraftsFolder(); @@ -721,14 +730,23 @@ public function showForm(?Form $form = null): void ilSession::set('draft', $mail_id); $mail_data = $this->umail->getMail($mail_id); - if ($mail_data['attachments'] instanceof MailAttachments) { - if ($mail_data['attachments']->isIrss()) { - $mail_data['attachments'] = $this->FilesFromIRSSToLegacy($mail_data['attachments']->rcid()); - } elseif ($mail_data['attachments']->isLegacy()) { - $mail_data['attachments'] = $this->filesFromLegacyToIRSS($mail_data); - } else { - $mail_data['attachments'] = []; - } + 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']); @@ -747,20 +765,21 @@ 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 ($mail_data['attachments'] instanceof MailAttachments) { - if ($mail_data['attachments']->isIrss()) { - $mail_data['attachments'] = $this->FilesFromIRSSToLegacy($mail_data['attachments']->rcid()); - } elseif ($mail_data['attachments']->isLegacy()) { - if ($error = $this->mfile->adoptAttachments( - $mail_data['attachments']->legacyFilenames(), - $mail_id - )) { - $this->tpl->setOnScreenMessage('info', $error); - } - $mail_data['attachments'] = $this->filesFromLegacyToIRSS($mail_data); - } else { - $mail_data['attachments'] = []; - } + if ($mail_data['attachments'] instanceof MailAttachments && !$mail_data['attachments']->isEmpty()) { + $stage_attachments = $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; @@ -866,7 +885,12 @@ public function showForm(?Form $form = null): void if ($this->request_attachments->isIrss()) { $mail_data['attachments'] = $this->FilesFromIRSSToLegacy($this->request_attachments->rcid()); } elseif ($this->request_attachments->isLegacy()) { - $mail_data['attachments'] = $this->request_attachments->legacyFilenames(); + $rcid = $this->fdm->createCollectionFromPoolFilenames( + $this->request_attachments->legacyFilenames() + ); + $mail_data['attachments'] = $rcid !== null + ? $this->FilesFromIRSSToLegacy($rcid) + : []; } } break; @@ -1089,7 +1113,10 @@ protected function buildFormElements(?array $mail_data): array if ($mail_attachments->isIrss()) { $mail_data['attachments'] = $this->FilesFromIRSSToLegacy($mail_attachments->rcid()); } elseif ($mail_attachments->isLegacy()) { - $mail_data['attachments'] = $mail_attachments->legacyFilenames(); + $rcid = $this->fdm->createCollectionFromPoolFilenames($mail_attachments->legacyFilenames()); + $mail_data['attachments'] = $rcid !== null + ? $this->FilesFromIRSSToLegacy($rcid) + : []; } else { $mail_data['attachments'] = []; } diff --git a/components/ILIAS/Mail/classes/class.ilMailNotification.php b/components/ILIAS/Mail/classes/class.ilMailNotification.php index a2c9a230148b..b7fffae61980 100755 --- a/components/ILIAS/Mail/classes/class.ilMailNotification.php +++ b/components/ILIAS/Mail/classes/class.ilMailNotification.php @@ -29,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 = []; @@ -43,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()); @@ -111,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; } @@ -241,7 +242,7 @@ public function sendMail(array $a_rcp, bool $a_parse_recipients = true): void '', $this->getSubject(), $this->getBody(), - MailAttachments::fromLegacyFilenames($this->getAttachments()) + $this->getAttachments() ); if ($errors !== []) { ilLoggerFactory::getLogger('mail')->dump($errors, ilLogLevel::ERROR); 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/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..94678e4e01a8 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 = new ilFileDataMail(ANONYMOUS_USER_ID); + $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/Wiki/classes/class.ilWikiUtil.php b/components/ILIAS/Wiki/classes/class.ilWikiUtil.php index 40b7ebfba23c..dcec6c0986e2 100755 --- a/components/ILIAS/Wiki/classes/class.ilWikiUtil.php +++ b/components/ILIAS/Wiki/classes/class.ilWikiUtil.php @@ -30,6 +30,8 @@ * */ // From include/Unicode/UtfNormal.php +use ILIAS\Mail\Attachments\MailAttachments; + if (!defined('UTF8_REPLACEMENT')) { define('UTF8_REPLACEMENT', "\xef\xbf\xbd" /*codepointToUtf8( UNICODE_REPLACEMENT )*/); } @@ -628,7 +630,7 @@ public static function sendNotification( "", $subject, $message, - array() + MailAttachments::empty() );*/ $message .= ilMail::_getInstallationSignature(); $mails[] = new ilMailValueObject( @@ -638,7 +640,7 @@ public static function sendNotification( '', $subject, $message, - [], + MailAttachments::empty(), false, false ); From 472e397f0ef9273f5c2cf936183ada951a6e6915 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Wed, 15 Jul 2026 08:04:24 +0200 Subject: [PATCH 05/43] [Feature] Mail: IRSS More Path-Code removals --- .../class.ilCalendarMailNotification.php | 6 - .../classes/class.ilTimingsCronReminder.php | 2 +- .../Exercise/classes/class.ilObjExercise.php | 19 +- ...ss.ilForumCronNotificationDataProvider.php | 14 +- .../class.ilForumNotificationDataProvider.php | 12 +- components/ILIAS/Mail/README.md | 6 +- .../Attachments/FileDataRCHandling.php | 25 -- .../Attachments/MailAttachmentTableGUI.php | 6 +- .../class.ilMassMailDeliveryJob.php | 2 +- .../ILIAS/Mail/classes/MailDeliveryData.php | 9 +- .../class.ilMailMimeTransportBase.php | 13 +- .../class.ilMailMimeTransportFactory.php | 13 +- .../Mail/classes/Service/MailService.php | 2 +- .../MigrateMailAttachmentsToIRSS.php | 119 ++++- .../Mail/classes/class.ilAccountMail.php | 6 +- .../Mail/classes/class.ilFileDataMail.php | 418 ++++++++++++------ .../ILIAS/Mail/classes/class.ilMail.php | 150 +++++-- .../classes/class.ilMailAttachmentGUI.php | 106 +++-- .../Mail/classes/class.ilMailFormGUI.php | 15 - .../ILIAS/Mail/tests/ilMailMimeTest.php | 12 +- .../ECS/classes/class.ilECSObjectSettings.php | 3 +- .../ECS/classes/class.ilRemoteObjectBase.php | 4 +- 22 files changed, 639 insertions(+), 323 deletions(-) diff --git a/components/ILIAS/Calendar/classes/class.ilCalendarMailNotification.php b/components/ILIAS/Calendar/classes/class.ilCalendarMailNotification.php index 66346174d4be..e10d7e90bb82 100755 --- a/components/ILIAS/Calendar/classes/class.ilCalendarMailNotification.php +++ b/components/ILIAS/Calendar/classes/class.ilCalendarMailNotification.php @@ -450,8 +450,6 @@ public function send(): void $this->sendMail(array($user_id), true); break; } - - $this->deleteAttachments(); } protected function addAttachment(): void @@ -475,8 +473,4 @@ protected function addAttachment(): void : MailAttachments::empty() ); } - - protected function deleteAttachments(): void - { - } } diff --git a/components/ILIAS/Course/classes/class.ilTimingsCronReminder.php b/components/ILIAS/Course/classes/class.ilTimingsCronReminder.php index 30b1d3731c8c..d7eeea1b2602 100755 --- a/components/ILIAS/Course/classes/class.ilTimingsCronReminder.php +++ b/components/ILIAS/Course/classes/class.ilTimingsCronReminder.php @@ -335,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/Exercise/classes/class.ilObjExercise.php b/components/ILIAS/Exercise/classes/class.ilObjExercise.php index c979348db31f..cc39eb2ed164 100755 --- a/components/ILIAS/Exercise/classes/class.ilObjExercise.php +++ b/components/ILIAS/Exercise/classes/class.ilObjExercise.php @@ -24,7 +24,7 @@ use ILIAS\Exercise\InternalService; use ILIAS\Exercise\Assignment\Mandatory\MandatoryAssignmentsManager; use ILIAS\Mail\Attachments\MailAttachments; -use ILIAS\ResourceStorage\Identification\ResourceIdentification; +use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; /** * Class ilObjExercise @@ -456,18 +456,13 @@ public function sendAssignment(ilExAssignment $a_ass, array $a_members): void // instruction files $if = $this->service->domain()->assignment()->instructionFiles($a_ass->getId()); - $files = $if->getFiles(); $attachments = MailAttachments::empty(); - if (count($files) > 0) { - $mfile_obj = new ilFileDataMail($GLOBALS['DIC']['ilUser']->getId()); - $source_ids = []; - foreach ($if->getFiles() as $file) { - $source_ids[] = new ResourceIdentification($file['rid']); - } - $rcid = $mfile_obj->createCollectionFromForeignResources($source_ids); - if ($rcid !== null) { - $attachments = MailAttachments::fromIrss($rcid); - } + if ($if->getFiles() !== []) { + // The mail service copies the collection per recipient on send (copy-on-send), + // so referencing the assignment collection directly is sufficient here. + $attachments = MailAttachments::fromIrss( + new ResourceCollectionIdentification($if->getCollectionIdString()) + ); } // recipients diff --git a/components/ILIAS/Forum/classes/Notification/class.ilForumCronNotificationDataProvider.php b/components/ILIAS/Forum/classes/Notification/class.ilForumCronNotificationDataProvider.php index d8653fee13ad..0962a9ca4195 100755 --- a/components/ILIAS/Forum/classes/Notification/class.ilForumCronNotificationDataProvider.php +++ b/components/ILIAS/Forum/classes/Notification/class.ilForumCronNotificationDataProvider.php @@ -62,7 +62,7 @@ class ilForumCronNotificationDataProvider implements ilForumNotificationMailData public function __construct( array $row, private readonly int $notification_type, - ?ilForumNotificationCache $notificationCache = null + ?ilForumNotificationCache $notificationCache = null, ) { $this->closest_container = $row['closest_container']; $this->obj_id = (int) $row['obj_id']; @@ -124,17 +124,11 @@ private function readAttachments(): void return; } - global $DIC; - $forum_collection = $DIC->resourceStorage()->collection()->get( + // The mail service copies the collection per recipient on send (copy-on-send), + // so referencing the post collection directly is sufficient here. + $this->mail_attachments = MailAttachments::fromIrss( new ResourceCollectionIdentification($rcid_string) ); - $fileDataMail = new ilFileDataMail(ANONYMOUS_USER_ID); - $mail_rcid = $fileDataMail->createCollectionFromForeignResources( - iterator_to_array($forum_collection->getResourceIdentifications(), false) - ); - if ($mail_rcid !== null) { - $this->mail_attachments = MailAttachments::fromIrss($mail_rcid); - } } public function addRecipient(int $user_id): void diff --git a/components/ILIAS/Forum/classes/Notification/class.ilForumNotificationDataProvider.php b/components/ILIAS/Forum/classes/Notification/class.ilForumNotificationDataProvider.php index 60e6dfc44ee4..9d7050009a6e 100755 --- a/components/ILIAS/Forum/classes/Notification/class.ilForumNotificationDataProvider.php +++ b/components/ILIAS/Forum/classes/Notification/class.ilForumNotificationDataProvider.php @@ -337,17 +337,11 @@ private function readAttachments(): void return; } - global $DIC; - $forum_collection = $DIC->resourceStorage()->collection()->get( + // The mail service copies the collection per recipient on send (copy-on-send), + // so referencing the post collection directly is sufficient here. + $this->mail_attachments = MailAttachments::fromIrss( new ResourceCollectionIdentification($rcid_string) ); - $fileDataMail = new ilFileDataMail(ANONYMOUS_USER_ID); - $mail_rcid = $fileDataMail->createCollectionFromForeignResources( - iterator_to_array($forum_collection->getResourceIdentifications(), false) - ); - if ($mail_rcid !== null) { - $this->mail_attachments = MailAttachments::fromIrss($mail_rcid); - } } /** diff --git a/components/ILIAS/Mail/README.md b/components/ILIAS/Mail/README.md index 5d6a7d44a418..101b094c31e1 100755 --- a/components/ILIAS/Mail/README.md +++ b/components/ILIAS/Mail/README.md @@ -159,7 +159,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( @@ -385,10 +385,10 @@ $mail->enqueue( $bc, $subject, $message, - [ + MailAttachments::fromLegacyFilenames([ $attachment_filename, 'HelloWorld.jpg' - ] + ]) ); // or $attachment->unlinkFiles(['HelloWorld.jpg', $attachment_filename]); diff --git a/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php b/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php index 9296ffb4d895..8b6b3a20c157 100644 --- a/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php +++ b/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php @@ -38,32 +38,7 @@ protected function getCurrentCollection( return $this->fdm->getCollection($rcid); } - /** - * @param array $mail_data - * @return list - */ - public function filesFromLegacyToIRSS(array $mail_data): array - { - $attachments = $mail_data['attachments'] ?? null; - if (!$attachments instanceof MailAttachments || !$attachments->isLegacy()) { - throw new InvalidArgumentException('Legacy mail attachments expected.'); - } - $rcid = $this->fdm->createCollectionFromPoolFilenames($attachments->legacyFilenames()); - if ($rcid === null) { - return []; - } - - return $this->fdm->getRidsFromCollection($rcid); - } - - /** - * @param list $filenames - */ - public function getIdforCollection(array $filenames): ?ResourceCollectionIdentification - { - return $this->fdm->createCollectionFromPoolFilenames($filenames); - } /** * @return list 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.ilMassMailDeliveryJob.php b/components/ILIAS/Mail/classes/BackgroundTask/class.ilMassMailDeliveryJob.php index 6ac98a4f7175..962d21f76f2f 100755 --- a/components/ILIAS/Mail/classes/BackgroundTask/class.ilMassMailDeliveryJob.php +++ b/components/ILIAS/Mail/classes/BackgroundTask/class.ilMassMailDeliveryJob.php @@ -26,7 +26,6 @@ use ILIAS\BackgroundTasks\Types\SingleType; use ILIAS\BackgroundTasks\Types\Type; use ILIAS\BackgroundTasks\Value; -use ILIAS\Mail\Attachments\MailAttachments; class ilMassMailDeliveryJob extends AbstractJob { @@ -49,6 +48,7 @@ public function run(array $input, Observer $observer): Value $mail = new ilMail((int) $input[0]->getValue()); $mail->setSaveInSentbox($value_object->shouldSaveInSentBox()); + $mail->setShareAttachments(true); $context_id = $input[2]->getValue(); $mail = $mail ->withContextId((string) $context_id) diff --git a/components/ILIAS/Mail/classes/MailDeliveryData.php b/components/ILIAS/Mail/classes/MailDeliveryData.php index 43c6a06040fd..187cb0860970 100755 --- a/components/ILIAS/Mail/classes/MailDeliveryData.php +++ b/components/ILIAS/Mail/classes/MailDeliveryData.php @@ -28,7 +28,7 @@ public function __construct( private readonly string $bcc, private readonly string $subject, private readonly string $message, - private readonly MailAttachments $attachments, + private MailAttachments $attachments, private readonly bool $use_placeholder, private ?int $internal_mail_id = null, private readonly int $user_id = 0 @@ -87,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/Mime/Transport/class.ilMailMimeTransportBase.php b/components/ILIAS/Mail/classes/Mime/Transport/class.ilMailMimeTransportBase.php index 4aa17ab31061..f29817b49dd9 100755 --- a/components/ILIAS/Mail/classes/Mime/Transport/class.ilMailMimeTransportBase.php +++ b/components/ILIAS/Mail/classes/Mime/Transport/class.ilMailMimeTransportBase.php @@ -20,14 +20,17 @@ use PHPMailer\PHPMailer\PHPMailer; use ILIAS\Mail\Mime\MailMimeAttachment; -use ILIAS\ResourceStorage\Services; +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); } @@ -217,9 +220,7 @@ private function addResourceAttachment(MailMimeAttachment $attachment): bool return false; } - /** @var Services $irss */ - $irss = $GLOBALS['DIC']->resourceStorage(); - $stream = $irss->consume()->stream($resource_identification)->getStream(); + $stream = $this->irss->consume()->stream($resource_identification)->getStream(); return $this->getMailer()->addStringAttachment( (string) $stream, 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/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/Migration/MigrateMailAttachmentsToIRSS.php b/components/ILIAS/Mail/classes/Setup/Migration/MigrateMailAttachmentsToIRSS.php index 3423d393021c..7423fecee77e 100644 --- a/components/ILIAS/Mail/classes/Setup/Migration/MigrateMailAttachmentsToIRSS.php +++ b/components/ILIAS/Mail/classes/Setup/Migration/MigrateMailAttachmentsToIRSS.php @@ -21,15 +21,18 @@ namespace ILIAS\Mail\Setup\Migration; use ilDBConstants; +use ILIAS\Mail\Attachments\MailAttachments; use ILIAS\Setup\Migration; use ILIAS\Setup\Environment; use ilMailAttachmentStakeholder; use ilResourceStorageMigrationHelper; use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; +use ILIAS\ResourceStorage\Identification\ResourceIdentification; class MigrateMailAttachmentsToIRSS implements Migration { private const int PATHS_PER_STEP = 5; + private const int MAILS_PER_STEP = 5; private ?ilResourceStorageMigrationHelper $helper = null; public function getLabel(): string @@ -56,6 +59,12 @@ public function prepare(Environment $environment): void } public function step(Environment $environment): void + { + $this->migrateSentAttachmentDirectories(); + $this->migrateSerializedMailAttachments(); + } + + private function migrateSentAttachmentDirectories(): void { $db = $this->helper->getDatabase(); $res = $db->query( @@ -96,15 +105,121 @@ public function step(Environment $environment): void } } + private function migrateSerializedMailAttachments(): void + { + $db = $this->helper->getDatabase(); + $res = $db->query( + 'SELECT mail_id, user_id, attachments FROM mail + WHERE attachments LIKE ' . $db->quote('a:%', 'text') . ' + LIMIT ' . self::MAILS_PER_STEP + ); + + $mail_path = rtrim($this->helper->getClientDataDir(), '/') . '/mail'; + + while ($row = $db->fetchObject($res)) { + $attachments = MailAttachments::fromDb((string) $row->attachments); + if ($attachments === null || !$attachments->isLegacy()) { + $this->clearMailAttachmentsColumn((int) $row->mail_id); + + continue; + } + + $rcid = $this->migratePoolFilenamesToCollection( + $attachments->legacyFilenames(), + (int) $row->user_id, + $mail_path + ); + + if ($rcid === null) { + $this->clearMailAttachmentsColumn((int) $row->mail_id); + + continue; + } + + $db->update( + 'mail', + [ + 'attachments' => [ilDBConstants::T_CLOB, $rcid->serialize()], + ], + [ + 'mail_id' => [ilDBConstants::T_INTEGER, (int) $row->mail_id], + ] + ); + } + } + + /** + * @param list $filenames + */ + private function migratePoolFilenamesToCollection( + array $filenames, + int $user_id, + string $mail_path + ): ?ResourceCollectionIdentification { + $collection = $this->helper->getCollectionBuilder()->new($user_id); + + foreach ($filenames as $filename) { + $absolute_path = $mail_path . '/' . $user_id . '_' . $filename; + if (!is_file($absolute_path)) { + continue; + } + + $resource_id = $this->helper->movePathToStorage( + $absolute_path, + $user_id, + null, + static fn(): string => md5($filename) + ); + + if ($resource_id instanceof ResourceIdentification) { + $collection->add($resource_id); + } + } + + if ($collection->count() === 0) { + return null; + } + + if (!$this->helper->getCollectionBuilder()->store($collection)) { + return null; + } + + return $collection->getIdentification(); + } + + 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 { - return (int) $this->helper->getDatabase()->fetchObject( - $this->helper->getDatabase()->query( + $db = $this->helper->getDatabase(); + + $path_count = (int) $db->fetchObject( + $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->fetchObject( + $db->query( + 'SELECT COUNT(mail_id) cnt FROM mail + WHERE attachments LIKE ' . $db->quote('a:%', 'text') + ) + )->cnt; + + return $path_count + $mail_count; } private function resolveOwnerIdForPath(string $relative_path): int diff --git a/components/ILIAS/Mail/classes/class.ilAccountMail.php b/components/ILIAS/Mail/classes/class.ilAccountMail.php index aea9615499eb..0b3eea69378c 100755 --- a/components/ILIAS/Mail/classes/class.ilAccountMail.php +++ b/components/ILIAS/Mail/classes/class.ilAccountMail.php @@ -178,8 +178,10 @@ public function send(): bool } } else { $attachment = $amail->getAttachment($this->irss); - if ($attachment !== null) { - $mmail->Attach($attachment->getPath(), '', 'attachment', $attachment->getFilename()); + if ($attachment !== null && is_file($attachment->getPath())) { + $file_data_mail = new ilFileDataMail(ANONYMOUS_USER_ID); + $rid = $file_data_mail->streamFromPath($attachment->getPath()); + $mmail->AttachResource($rid, $attachment->getFilename()); } } diff --git a/components/ILIAS/Mail/classes/class.ilFileDataMail.php b/components/ILIAS/Mail/classes/class.ilFileDataMail.php index f8e1a0cad517..ead6dea4ade5 100755 --- a/components/ILIAS/Mail/classes/class.ilFileDataMail.php +++ b/components/ILIAS/Mail/classes/class.ilFileDataMail.php @@ -18,6 +18,7 @@ declare(strict_types=1); +use ILIAS\Mail\Attachments\MailAttachments; use ILIAS\Filesystem\Filesystem; use ILIAS\ResourceStorage\Services; use ILIAS\Filesystem\Stream\Streams; @@ -30,6 +31,10 @@ class ilFileDataMail extends ilFileData { + private 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; @@ -155,32 +160,6 @@ 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 - { - 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 ''; - } - public function checkReadWrite(): bool { if (is_writable($this->mail_path) && is_readable($this->mail_path)) { @@ -231,93 +210,257 @@ private function getUnsentFiles(): array return $files; } - public function storeAsAttachment(string $a_filename, string $a_content): string + + /** + * @deprecated Legacy pool write; use uploadToPool() instead. + */ + public function storeUploadedFile(UploadResult $result): string { - if (strlen($a_content) >= $this->getUploadLimit()) { - throw new DomainException( - sprintf( - 'Mail upload limit reached for user with id %s', - $this->user_id - ) - ); + return $this->uploadToPool($result)->serialize(); + } + + public function uploadToPool(UploadResult $result): ResourceIdentification + { + $rid = $this->uploadToIrss($result); + $pool_rcid = $this->resolveUserPoolRcid(); + $updated_rcid = $this->adoptPoolResourcesToCollection($pool_rcid, [$rid]); + + if ($updated_rcid === null) { + throw new RuntimeException('Could not store mail attachment in pool.'); } - $name = ilFileUtils::_sanitizeFilemame($a_filename); - $this->rotateFiles($this->getMailPath() . '/' . $this->user_id . '_' . $name); + if ($pool_rcid === null || $pool_rcid->serialize() !== $updated_rcid->serialize()) { + $this->persistUserPoolRcid($updated_rcid); + } - $abs_path = $this->getMailPath() . '/' . $this->user_id . '_' . $name; + return $rid; + } - $fp = fopen($abs_path, 'wb+'); - if (!is_resource($fp)) { - throw new RuntimeException( - sprintf( - 'Could not read file: %s', - $abs_path - ) - ); + 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; + $this->repairCollectionHeaderIfNeeded($rcid); + + return $rcid; } - public function storeUploadedFile(UploadResult $result): string + /** + * @return \Generator + */ + private function iteratePoolResourceIdentifications(ResourceCollectionIdentification $pool_rcid): \Generator { - $filename = ilFileUtils::_sanitizeFilemame( - $result->getName() - ); + $this->repairCollectionHeaderIfNeeded($pool_rcid); - $this->rotateFiles($this->getMailPath() . '/' . $this->user_id . '_' . $filename); + foreach ($this->getAssignedRidStrings($pool_rcid) as $rid_string) { + $resource_identification = $this->irss->manage()->find($rid_string); + if ($resource_identification !== null) { + yield $resource_identification; + } + } + } - ilFileUtils::moveUploadedFile( - $result->getPath(), - $filename, - $this->getMailPath() . '/' . $this->user_id . '_' . $filename - ); + /** + * @return list + */ + public function getUserPoolFilesData(): array + { + $files = []; - return $filename; + $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 copyAttachmentFile(string $a_abs_path, string $a_new_name): bool + public function isLegacyPoolItemIdentifier(string $identifier): bool { - @copy($a_abs_path, $this->getMailPath() . '/' . $this->user_id . '_' . $a_new_name); + return str_starts_with($identifier, self::LEGACY_POOL_ITEM_PREFIX); + } - return true; + 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)); + $pool_rcid = $this->resolveUserPoolRcid(); + $updated_rcid = $this->adoptPoolResourcesToCollection($pool_rcid, [$rid]); + if ($updated_rcid === null) { + return null; + } + + if ($pool_rcid === null || $pool_rcid->serialize() !== $updated_rcid->serialize()) { + $this->persistUserPoolRcid($updated_rcid); + } + + $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) { + $resource_identifications[] = $resource_identification; } } - return ''; + return $resource_identifications; + } + + /** + * @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); + + if (!$this->isResourceReferencedOutsideCollection($rid, $pool_rcid)) { + $this->irss->manage()->remove($rid, $this->stakeholder); + } + } + + 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()], + ] + ); + } + + private function isResourceReferencedOutsideCollection( + ResourceIdentification $rid, + ResourceCollectionIdentification $collection_identification + ): bool { + $res = $this->db->queryF( + 'SELECT rcid FROM il_resource_rca WHERE rid = %s', + [ilDBConstants::T_TEXT], + [$rid->serialize()] + ); + + while ($row = $this->db->fetchObject($res)) { + if ($row->rcid !== $collection_identification->serialize()) { + return true; + } + } + + return false; } public function unlinkFile(string $a_filename): bool @@ -355,49 +498,6 @@ private function isValidAttachmentPoolFilename(string $filename): bool !str_contains($filename, '\\'); } - /** - * Saves all attachment files in a specific mail directory .../mail//mail__/... - * @param list $a_attachments - */ - public function saveFiles(int $a_mail_id, array $a_attachments): void - { - if (!is_numeric($a_mail_id) || $a_mail_id < 1) { - throw new InvalidArgumentException('The passed mail_id must be a valid integer!'); - } - - foreach ($a_attachments as $attachment) { - $this->saveFile($a_mail_id, $attachment); - } - } - - 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]; - } - - /** - * Save attachment file in a specific mail directory .../mail//mail__/... - */ - public function saveFile(int $a_mail_id, string $a_attachment): bool - { - $storage = self::getStorage($a_mail_id, $this->user_id); - $storage->create(); - $storage_directory = $storage->getAbsolutePath(); - - if (!is_dir($storage_directory)) { - return false; - } - - return copy( - $this->mail_path . '/' . $this->user_id . '_' . $a_attachment, - $storage_directory . '/' . $a_attachment - ); - } - /** * @param list $a_files */ @@ -425,18 +525,6 @@ public function assignAttachmentsToCollection( ); } - public function assignAttachmentsToDirectory(int $a_mail_id, int $a_sent_mail_id): void - { - $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()] - ); - } - public function getRcidForMail(int $mail_id): ?ResourceCollectionIdentification { $res = $this->db->queryF( @@ -561,6 +649,14 @@ protected function initAttachmentMaxUploadSize(): void public function onUserDelete(): void { + $pool_rcid = $this->resolveUserPoolRcid(); + if ($pool_rcid !== null) { + try { + $this->removeCollection($pool_rcid); + } catch (Exception) { + } + } + // Delete uploaded mail files which are not attached to any message try { $iter = new RegexIterator( @@ -621,7 +717,7 @@ public function onUserDelete(): void '; $rcid_res = $this->db->queryF( $rcid_query, - ['integer'], + [ilDBConstants::T_INTEGER], [$this->user_id] ); while ($row = $this->db->fetchAssoc($rcid_res)) { @@ -765,6 +861,21 @@ public function createCollectionFromPoolFilenames(array $filenames): ?ResourceCo 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 */ @@ -809,11 +920,6 @@ public function adoptPoolFilenamesToCollection( return $collection->getIdentification(); } - public function poolFilenameHash(string $pool_filename): string - { - return md5(basename($this->getAbsoluteAttachmentPoolPathByFilename($pool_filename))); - } - /** * @param list $absolute_paths */ @@ -860,6 +966,21 @@ public function createCollectionFromContent(string $name, string $content): ?Res ]); } + public function copyCollectionForDelivery( + ResourceCollectionIdentification $source + ): ?ResourceCollectionIdentification { + if (!$this->collectionIsKnown($source)) { + return null; + } + + $resource_identifications = iterator_to_array( + $this->getCollection($source)->getResourceIdentifications(), + false + ); + + return $this->createCollectionFromForeignResources($resource_identifications); + } + /** * @param list $resource_identifications */ @@ -893,10 +1014,25 @@ public function getCollection(ResourceCollectionIdentification $rcid): ResourceC } $this->repairCollectionHeaderIfNeeded($rcid); + $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->repairCollectionHeaderIfNeeded($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 */ diff --git a/components/ILIAS/Mail/classes/class.ilMail.php b/components/ILIAS/Mail/classes/class.ilMail.php index a69ab70593e2..dc96270d2887 100755 --- a/components/ILIAS/Mail/classes/class.ilMail.php +++ b/components/ILIAS/Mail/classes/class.ilMail.php @@ -46,6 +46,9 @@ class ilMail private bool $append_installation_signature = false; private bool $append_user_signature = false; + private bool $share_attachments = false; + private ?MailAttachments $shared_delivery_attachments = null; + private ?string $context_id = null; private array $context_parameters = []; @@ -157,6 +160,35 @@ 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) { + if ($this->shared_delivery_attachments === null) { + $copied_rcid = $this->mail_file_data->copyCollectionForDelivery($source->rcid()); + $this->shared_delivery_attachments = $copied_rcid !== null + ? MailAttachments::fromIrss($copied_rcid) + : MailAttachments::empty(); + } + + return $this->shared_delivery_attachments; + } + + $copied_rcid = $this->mail_file_data->copyCollectionForDelivery($source->rcid()); + + return $copied_rcid !== null + ? MailAttachments::fromIrss($copied_rcid) + : MailAttachments::empty(); + } + private function readMailObjectReferenceId(): void { $this->mail_obj_ref_id = ilMailGlobalServices::getMailObjectRefId(); @@ -395,7 +427,22 @@ 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()) { + $migrated = $this->mail_file_data->migrateLegacyPoolAttachments($row['attachments']); + if ($migrated !== null) { + $row['attachments'] = $migrated; + if (isset($row['mail_id'])) { + $this->persistMailAttachmentsColumn((int) $row['mail_id'], $migrated); + } + } + } + + return $row; } public function getNewDraftId(int $folder_id): int @@ -747,10 +794,12 @@ private function sendChanneledMails( $mbox->setUsrId($recipient->getUserId()); $recipient_inbox_id = $mbox->getInboxFolder(); + $delivery_attachments = $this->getDeliveryAttachments($mail_data->getAttachments()); + $internal_mail_id = $this->sendInternalMail( $recipient_inbox_id, $this->user_id, - $mail_data->getAttachments(), + $delivery_attachments, $mail_data->getTo(), $mail_data->getCc(), '', @@ -768,14 +817,14 @@ private function sendChanneledMails( $this->assignMailAttachments( $internal_mail_id, - $mail_data->getAttachments(), + $delivery_attachments, $mail_data->getInternalMailId() ); } $this->delegateExternalEmails( $mail_data->getSubject(), - $mail_data->getAttachments(), + $this->getDeliveryAttachments($mail_data->getAttachments()), $message, $usr_id_to_external_email_addresses_map ); @@ -993,15 +1042,18 @@ 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->isLegacy() - && !$this->mail_file_data->checkFilesExist($a_attachment->legacyFilenames()) - ) { - return [new ilMailError('mail_attachment_file_not_exist', [implode(', ', $a_attachment->legacyFilenames())])]; + $normalized = $this->normalizeLegacyAttachments($a_attachment); + if ($normalized['errors'] !== []) { + return $normalized['errors']; } + $a_attachment = $normalized['attachments']; $errors = $this->checkMail($a_rcp_to, $a_rcp_cc, $a_rcp_bcc, $a_m_subject); if ($errors !== []) { @@ -1093,8 +1145,16 @@ public function enqueue( public function sendMail( MailDeliveryData $mail_data ): array { + $normalized = $this->normalizeLegacyAttachments($mail_data->getAttachments()); + if ($normalized['errors'] !== []) { + return $normalized['errors']; + } + $mail_data = $mail_data->withAttachments($normalized['attachments']); + $this->shared_delivery_attachments = null; + + $sentbox_attachments = $this->getDeliveryAttachments($mail_data->getAttachments()); $internal_message_id = $this->saveInSentbox( - $mail_data->getAttachments(), + $sentbox_attachments, $mail_data->getTo(), $mail_data->getCc(), $mail_data->getBcc(), @@ -1105,15 +1165,9 @@ public function sendMail( $this->assignMailAttachments( $internal_message_id, - $mail_data->getAttachments(), + $sentbox_attachments, $internal_message_id ); - if ($mail_data->getAttachments()->isLegacy()) { - $this->mail_file_data->saveFiles( - $internal_message_id, - $mail_data->getAttachments()->legacyFilenames() - ); - } $num_external_email_addresses = $this->getCountRecipients( $mail_data->getTo(), @@ -1142,7 +1196,7 @@ public function sendMail( $mail_data->isUsePlaceholder() ? $this->replacePlaceholders($mail_data->getMessage()) : $mail_data->getMessage(), - $mail_data->getAttachments() + $this->getDeliveryAttachments($mail_data->getAttachments()) ); } else { $this->logger->debug('No external email addresses given in recipient string'); @@ -1265,35 +1319,61 @@ function (string $message): string { $attachment['name'] ); } - } elseif ($attachments->isLegacy()) { - foreach ($attachments->legacyFilenames() as $attachment) { - $mailer->Attach( - $this->mail_file_data->getAbsoluteAttachmentPoolPathByFilename($attachment), - '', - 'inline', - $attachment - ); - } } $mailer->Send(); } + /** + * @return array{attachments: MailAttachments, errors: list} + */ + private function normalizeLegacyAttachments(MailAttachments $attachments): array + { + 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( + 'mail', + [ + 'attachments' => [ilDBConstants::T_CLOB, $attachments->toDb()], + ], + [ + 'mail_id' => [ilDBConstants::T_INTEGER, $mail_id], + ] + ); + } + private function assignMailAttachments( int $mail_id, MailAttachments $attachments, int $sent_mail_id ): void { - if ($attachments->isEmpty()) { - return; - } - - if ($attachments->isIrss()) { - $this->mail_file_data->assignAttachmentsToCollection($mail_id, $attachments->rcid()); + if ($attachments->isEmpty() || !$attachments->isIrss()) { return; } - $this->mail_file_data->assignAttachmentsToDirectory($mail_id, $sent_mail_id); + $this->mail_file_data->assignAttachmentsToCollection($mail_id, $attachments->rcid()); } public function saveAttachments(?MailAttachments $attachments): void diff --git a/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php b/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php index 7d0a8b9d5132..c011c4ca3619 100644 --- a/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php +++ b/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php @@ -25,6 +25,7 @@ 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; @@ -122,23 +123,16 @@ 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()); } - $files = []; + $resource_identifications = $this->fdm->resolvePoolIdentifiersToResources($files_of_request); $size_of_affected_files = 0; - foreach ($files_of_request as $file) { - $decoded = urldecode((string) $file); - $pool_path = $this->fdm->getAbsoluteAttachmentPoolPathByFilename($decoded); - if (!is_file($pool_path)) { - continue; - } - - $files[] = $decoded; - $size_of_affected_files += (int) filesize($pool_path); + foreach ($resource_identifications as $rid) { + $size_of_affected_files += $this->storage->manage()->getCurrentRevision($rid)->getInformation()->getSize(); } - if ($files === []) { + if ($resource_identifications === []) { $this->tpl->setOnScreenMessage($this->tpl::MESSAGE_TYPE_INFO, $this->lng->txt('select_one'), true); $this->showAttachmentsCommand(); return; @@ -162,7 +156,7 @@ private function saveAttachments(): void ? $stage_attachments->rcid() : null; - $rcid = $this->fdm->adoptPoolFilenamesToCollection($existing_rcid, $files); + $rcid = $this->fdm->adoptPoolResourcesToCollection($existing_rcid, $resource_identifications); if ($rcid === null) { throw new RuntimeException($this->lng->txt('mail_error_reading_attachment')); } @@ -194,7 +188,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 === []) { @@ -210,11 +204,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) { + $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) ); } @@ -238,26 +238,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(); - $stage_attachments = $mail_data['attachments'] ?? null; - if ($stage_attachments instanceof MailAttachments && $stage_attachments->isIrss()) { - $files_to_legacy = $this->FilesFromIRSSToLegacy($stage_attachments->rcid()); - $rcid = $this->handleAttachments($files_to_legacy); - $this->umail->saveAttachments(MailAttachments::fromIrss($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); } @@ -306,15 +301,16 @@ private function showAttachmentsCommand(): void } } - $files = $this->fdm->getUserFilesData(); + $files = $this->fdm->getUserPoolFilesData(); $records = []; $checked_items = []; foreach ($files as $file) { - if (isset($adopted_pool_hashes[$this->fdm->poolFilenameHash($file['name'])])) { - $checked_items[] = urlencode($file['name']); + if (isset($adopted_pool_hashes[$file['md5']])) { + $checked_items[] = $file['rid']; } $records[] = [ + 'rid' => $file['rid'], 'filename' => $file['name'], 'filesize' => (int) $file['size'], 'filecreatedate' => (int) $file['ctime'], @@ -382,14 +378,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); @@ -397,17 +394,44 @@ protected function getUploadResult(): HandlerResult protected function getRemoveResult(string $identifier): HandlerResult { - throw new DomainException('Not necessary for this handler'); + // Dropzone remove affects only the current form state; persisted pool files are deleted via the table. + 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) { + 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.ilMailFormGUI.php b/components/ILIAS/Mail/classes/class.ilMailFormGUI.php index 9bafa40548f3..53abd6c4da25 100755 --- a/components/ILIAS/Mail/classes/class.ilMailFormGUI.php +++ b/components/ILIAS/Mail/classes/class.ilMailFormGUI.php @@ -213,21 +213,6 @@ 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 { diff --git a/components/ILIAS/Mail/tests/ilMailMimeTest.php b/components/ILIAS/Mail/tests/ilMailMimeTest.php index 7b72c166ce00..310ed8549e8d 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->getMockBuilder(ILIAS\ResourceStorage\Services::class)->disableOriginalConstructor()->getMock(); + + $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->getMockBuilder(ILIAS\ResourceStorage\Services::class)->disableOriginalConstructor()->getMock(); + + $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->getMockBuilder(ILIAS\ResourceStorage\Services::class)->disableOriginalConstructor()->getMock(); + + $factory = new ilMailMimeTransportFactory($settings, $event_handler, $irss); $this->assertInstanceOf(ilMailMimeTransportSendmail::class, $factory->getTransport()); } 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() ); } From 88b63db36338d11cb8436e190f5fd8dcd87a1680 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Wed, 15 Jul 2026 09:31:02 +0200 Subject: [PATCH 06/43] [Feature] Mail: IRSS Review remove some comments --- components/ILIAS/Exercise/classes/class.ilObjExercise.php | 2 -- .../class.ilForumCronNotificationDataProvider.php | 4 +--- .../Notification/class.ilForumNotificationDataProvider.php | 2 -- components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php | 1 - 4 files changed, 1 insertion(+), 8 deletions(-) diff --git a/components/ILIAS/Exercise/classes/class.ilObjExercise.php b/components/ILIAS/Exercise/classes/class.ilObjExercise.php index cc39eb2ed164..a39cdaa0b731 100755 --- a/components/ILIAS/Exercise/classes/class.ilObjExercise.php +++ b/components/ILIAS/Exercise/classes/class.ilObjExercise.php @@ -458,8 +458,6 @@ public function sendAssignment(ilExAssignment $a_ass, array $a_members): void $if = $this->service->domain()->assignment()->instructionFiles($a_ass->getId()); $attachments = MailAttachments::empty(); if ($if->getFiles() !== []) { - // The mail service copies the collection per recipient on send (copy-on-send), - // so referencing the assignment collection directly is sufficient here. $attachments = MailAttachments::fromIrss( new ResourceCollectionIdentification($if->getCollectionIdString()) ); diff --git a/components/ILIAS/Forum/classes/Notification/class.ilForumCronNotificationDataProvider.php b/components/ILIAS/Forum/classes/Notification/class.ilForumCronNotificationDataProvider.php index 0962a9ca4195..47b900239245 100755 --- a/components/ILIAS/Forum/classes/Notification/class.ilForumCronNotificationDataProvider.php +++ b/components/ILIAS/Forum/classes/Notification/class.ilForumCronNotificationDataProvider.php @@ -62,7 +62,7 @@ class ilForumCronNotificationDataProvider implements ilForumNotificationMailData public function __construct( array $row, private readonly int $notification_type, - ?ilForumNotificationCache $notificationCache = null, + ?ilForumNotificationCache $notificationCache = null ) { $this->closest_container = $row['closest_container']; $this->obj_id = (int) $row['obj_id']; @@ -124,8 +124,6 @@ private function readAttachments(): void return; } - // The mail service copies the collection per recipient on send (copy-on-send), - // so referencing the post collection directly is sufficient here. $this->mail_attachments = MailAttachments::fromIrss( new ResourceCollectionIdentification($rcid_string) ); diff --git a/components/ILIAS/Forum/classes/Notification/class.ilForumNotificationDataProvider.php b/components/ILIAS/Forum/classes/Notification/class.ilForumNotificationDataProvider.php index 9d7050009a6e..092ef784639d 100755 --- a/components/ILIAS/Forum/classes/Notification/class.ilForumNotificationDataProvider.php +++ b/components/ILIAS/Forum/classes/Notification/class.ilForumNotificationDataProvider.php @@ -337,8 +337,6 @@ private function readAttachments(): void return; } - // The mail service copies the collection per recipient on send (copy-on-send), - // so referencing the post collection directly is sufficient here. $this->mail_attachments = MailAttachments::fromIrss( new ResourceCollectionIdentification($rcid_string) ); diff --git a/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php b/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php index c011c4ca3619..1da7ccf92ea8 100644 --- a/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php +++ b/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php @@ -394,7 +394,6 @@ protected function getUploadResult(): HandlerResult protected function getRemoveResult(string $identifier): HandlerResult { - // Dropzone remove affects only the current form state; persisted pool files are deleted via the table. return new BasicHandlerResult( $this->getFileIdentifierParameterName(), HandlerResult::STATUS_OK, From e0f7d2307a7ed076633277f812c832049cf0acbd Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Wed, 15 Jul 2026 10:16:05 +0200 Subject: [PATCH 07/43] [Feature] Mail: IRSS Unit Tests --- .../ILIAS/Mail/tests/MailAttachmentsTest.php | 38 +++- .../MigrateMailAttachmentsToIRSSUnitTest.php | 194 ++++++++++++++++++ .../Mail/tests/ilFileDataMailPoolTest.php | 147 +++++++++++++ .../ILIAS/Mail/tests/ilMailCopyOnSendTest.php | 156 ++++++++++++++ 4 files changed, 532 insertions(+), 3 deletions(-) create mode 100644 components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php create mode 100644 components/ILIAS/Mail/tests/ilFileDataMailPoolTest.php create mode 100644 components/ILIAS/Mail/tests/ilMailCopyOnSendTest.php diff --git a/components/ILIAS/Mail/tests/MailAttachmentsTest.php b/components/ILIAS/Mail/tests/MailAttachmentsTest.php index 4357cdc8f219..b6219a5f049c 100644 --- a/components/ILIAS/Mail/tests/MailAttachmentsTest.php +++ b/components/ILIAS/Mail/tests/MailAttachmentsTest.php @@ -51,9 +51,41 @@ public function testSerializeRcidForBackgroundTask(): void $this->assertSame('rcid-456', $parsed->rcid()->serialize()); } - public function testIsEmpty(): void + public function testParseEmptyLegacyArray(): void { - $this->assertTrue(MailAttachments::empty()->isEmpty()); - $this->assertFalse(MailAttachments::fromIrss(new ResourceCollectionIdentification('rcid-789'))->isEmpty()); + $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()); } } diff --git a/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php b/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php new file mode 100644 index 000000000000..fbda226002e3 --- /dev/null +++ b/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php @@ -0,0 +1,194 @@ +migration = new MigrateMailAttachmentsToIRSS(); + $this->helper = $this->getMockBuilder(ilResourceStorageMigrationHelper::class) + ->disableOriginalConstructor() + ->onlyMethods([ + 'getDatabase', + 'getClientDataDir', + 'moveFilesOfPathToCollection', + 'movePathToStorage', + 'getCollectionBuilder', + ]) + ->getMock(); + $this->db = $this->createMock(ilDBInterface::class); + + $this->helper->method('getDatabase')->willReturn($this->db); + $this->helper->method('getClientDataDir')->willReturn('/tmp/client'); + + $reflection = new ReflectionClass($this->migration); + $property = $reflection->getProperty('helper'); + $property->setValue($this->migration, $this->helper); + } + + public function testResolveOwnerIdForPathUsesSenderId(): void + { + $statement = $this->createMock(ilDBStatement::class); + $this->db->expects($this->once()) + ->method('queryF') + ->willReturn($statement); + $this->db->expects($this->once()) + ->method('fetchObject') + ->with($statement) + ->willReturn((object) ['sender_id' => 42]); + + $owner_id = $this->invokePrivate('resolveOwnerIdForPath', ['migtest/s1']); + + $this->assertSame(42, $owner_id); + } + + public function testResolveOwnerIdForPathFallsBackToSystemUser(): void + { + $statement = $this->createMock(ilDBStatement::class); + $this->db->method('queryF')->willReturn($statement); + $this->db->method('fetchObject')->willReturn((object) ['sender_id' => 0]); + + $owner_id = $this->invokePrivate('resolveOwnerIdForPath', ['migtest/s5']); + + $this->assertSame(SYSTEM_USER_ID, $owner_id); + } + + public function testMigratePoolFilenamesToCollectionSkipsMissingFiles(): void + { + $dir = sys_get_temp_dir() . '/mail-migration-unit-' . uniqid('', true); + $mail_path = $dir . '/mail'; + mkdir($mail_path, 0775, true); + file_put_contents($mail_path . '/6_present.svg', ''); + + $this->helper->method('getClientDataDir')->willReturn($dir); + + $collection = $this->createMock(ResourceCollection::class); + $collection->method('count')->willReturn(1); + $collection->expects($this->once())->method('add'); + $collection->method('getIdentification')->willReturn( + new ResourceCollectionIdentification('migrated-rcid') + ); + + $collection_builder = $this->createMock(CollectionBuilder::class); + $collection_builder->method('new')->willReturn($collection); + $collection_builder->method('store')->willReturn(true); + + $this->helper->method('getCollectionBuilder')->willReturn($collection_builder); + $this->helper->expects($this->once()) + ->method('movePathToStorage') + ->willReturn(new ResourceIdentification('rid-present')); + + $rcid = $this->invokePrivate('migratePoolFilenamesToCollection', [ + ['present.svg', 'missing.svg'], + 6, + $mail_path, + ]); + + $this->assertInstanceOf(ResourceCollectionIdentification::class, $rcid); + $this->assertSame('migrated-rcid', $rcid->serialize()); + + unlink($mail_path . '/6_present.svg'); + rmdir($mail_path); + rmdir($dir); + } + + public function testMigratePoolFilenamesToCollectionReturnsNullWhenNoFilesExist(): void + { + $dir = sys_get_temp_dir() . '/mail-migration-unit-empty-' . uniqid('', true); + $mail_path = $dir . '/mail'; + mkdir($mail_path, 0775, true); + + $collection = $this->createMock(ResourceCollection::class); + $collection->method('count')->willReturn(0); + + $collection_builder = $this->createMock(CollectionBuilder::class); + $collection_builder->method('new')->willReturn($collection); + + $this->helper->method('getCollectionBuilder')->willReturn($collection_builder); + $this->helper->expects($this->never())->method('movePathToStorage'); + + $rcid = $this->invokePrivate('migratePoolFilenamesToCollection', [ + ['missing.svg'], + 6, + $mail_path, + ]); + + $this->assertNull($rcid); + + rmdir($mail_path); + rmdir($dir); + } + + public function testUpdateMailAttachmentFieldsSkipsNonSerializedAttachments(): void + { + $statement = $this->createMock(ilDBStatement::class); + $this->db->expects($this->once())->method('queryF')->willReturn($statement); + $this->db->method('fetchObject')->willReturnOnConsecutiveCalls( + (object) ['mail_id' => 900011, 'attachments' => '657497dc-5079-4f95-b19d-aecdaf81ff1a'], + null + ); + $this->db->expects($this->never())->method('update'); + + $this->invokePrivate('updateMailAttachmentFields', [ + 'migtest/s11', + new ResourceCollectionIdentification('new-rcid'), + ]); + } + + public function testMarkPathAsSkippedWritesDashMarker(): void + { + $this->db->expects($this->once()) + ->method('manipulateF') + ->with( + 'UPDATE mail_attachment SET rcid = %s WHERE path = %s', + [ilDBConstants::T_TEXT, ilDBConstants::T_TEXT], + ['-', 'migtest/missing'] + ); + + $this->invokePrivate('markPathAsSkipped', ['migtest/missing']); + } + + /** + * @param list $arguments + */ + private function invokePrivate(string $method, array $arguments): mixed + { + $reflection = new ReflectionClass($this->migration); + + return $reflection->getMethod($method)->invoke($this->migration, ...$arguments); + } +} diff --git a/components/ILIAS/Mail/tests/ilFileDataMailPoolTest.php b/components/ILIAS/Mail/tests/ilFileDataMailPoolTest.php new file mode 100644 index 000000000000..bcc813b81ea6 --- /dev/null +++ b/components/ILIAS/Mail/tests/ilFileDataMailPoolTest.php @@ -0,0 +1,147 @@ +file_data_mail = $this->getMockBuilder(ilFileDataMail::class) + ->disableOriginalConstructor() + ->onlyMethods(['migrateLegacyPoolFilenameToResource']) + ->getMock(); + } + + public function testLegacyPoolItemIdentifier(): void + { + $this->assertTrue( + $this->file_data_mail->isLegacyPoolItemIdentifier('legacy:chef-man-cap-svgrepo-com.svg') + ); + $this->assertFalse( + $this->file_data_mail->isLegacyPoolItemIdentifier('657497dc-5079-4f95-b19d-aecdaf81ff1a') + ); + } + + public function testLegacyPoolFilenameFromIdentifier(): void + { + $this->assertSame( + 'pepper-svgrepo-com.svg', + $this->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'); + + $this->file_data_mail + ->expects($this->once()) + ->method('migrateLegacyPoolFilenameToResource') + ->with('old.svg') + ->willReturn($legacy_rid); + + $irss = $this->createIrssMock([ + 'irss-rid' => $irss_rid, + ]); + $this->injectIrss($irss); + + $resolved = $this->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 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 createIrssMock(array $known_rids): IRSS&MockObject + { + $manage = $this->createMock(Manager::class); + $manage->method('find')->willReturnCallback( + static function (string $rid) use ($known_rids): ?ResourceIdentification { + return $known_rids[$rid] ?? null; + } + ); + + $irss = $this->getMockBuilder(IRSS::class)->disableOriginalConstructor()->getMock(); + $irss->method('manage')->willReturn($manage); + + return $irss; + } + + private function injectIrss(IRSS $irss): void + { + $reflection = new ReflectionClass(ilFileDataMail::class); + $property = $reflection->getProperty('irss'); + $property->setValue($this->file_data_mail, $irss); + } +} diff --git a/components/ILIAS/Mail/tests/ilMailCopyOnSendTest.php b/components/ILIAS/Mail/tests/ilMailCopyOnSendTest.php new file mode 100644 index 000000000000..b335b2246c1b --- /dev/null +++ b/components/ILIAS/Mail/tests/ilMailCopyOnSendTest.php @@ -0,0 +1,156 @@ +getMockBuilder(Factory::class)->disableOriginalConstructor()->getMock(); + $this->setGlobalVariable('refinery', $refinery); + } + + public function testDeliveryAttachmentsReturnsLegacyUnchanged(): void + { + $mail = $this->createMail(); + $this->mail_file_data->expects($this->never())->method('copyCollectionForDelivery'); + + $legacy = MailAttachments::fromLegacyFilenames(['file.pdf']); + $result = $this->invokeDeliveryAttachments($legacy, $mail); + + $this->assertSame($legacy, $result); + } + + public function testDeliveryAttachmentsReturnsEmptyUnchanged(): void + { + $mail = $this->createMail(); + $this->mail_file_data->expects($this->never())->method('copyCollectionForDelivery'); + + $empty = MailAttachments::empty(); + $result = $this->invokeDeliveryAttachments($empty, $mail); + + $this->assertTrue($result->isEmpty()); + } + + public function testDeliveryAttachmentsCopiesIrssCollectionPerCall(): void + { + $mail = $this->createMail(); + $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('copyCollectionForDelivery') + ->with($source) + ->willReturnOnConsecutiveCalls($copy_a, $copy_b); + + $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->createMail(); + $source = new ResourceCollectionIdentification('source-rcid'); + $this->mail_file_data + ->expects($this->once()) + ->method('copyCollectionForDelivery') + ->willReturn(null); + + $result = $this->invokeDeliveryAttachments(MailAttachments::fromIrss($source), $mail); + + $this->assertTrue($result->isEmpty()); + } + + public function testSharedDeliveryAttachmentsReuseSingleCopy(): void + { + $mail = $this->createMail(); + $source = new ResourceCollectionIdentification('source-rcid'); + $shared = new ResourceCollectionIdentification('shared-copy'); + + $this->mail_file_data + ->expects($this->once()) + ->method('copyCollectionForDelivery') + ->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); + } + + private function createMail(): ilMail + { + $this->mail_file_data = $this->getMockBuilder(ilFileDataMail::class) + ->disableOriginalConstructor() + ->onlyMethods(['copyCollectionForDelivery']) + ->getMock(); + + return new ilMail( + 6, + $this->createMock(ilMailAddressTypeFactory::class), + $this->createMock(ilMailRfc822AddressParserFactory::class), + $this->createMock(ilAppEventHandler::class), + $this->createMock(ilLogger::class), + $this->createMock(ilDBInterface::class), + $this->createMock(ilLanguage::class), + $this->mail_file_data, + $this->createMock(ilMailOptions::class), + $this->createMock(ilMailbox::class), + $this->createMock(ilMailMimeSenderFactory::class), + static fn(string $login): int => 6, + $this->createMock(AutoresponderService::class), + 0, + 234, + $this->createMock(ilObjUser::class), + $this->createMock(ilMailTemplatePlaceholderResolver::class), + null, + null, + $this->createMock(MailSignatureService::class), + ); + } + + private function invokeDeliveryAttachments(MailAttachments $source, ?ilMail $mail = null): MailAttachments + { + $mail ??= $this->createMail(); + + $reflection = new ReflectionClass($mail); + + return $reflection->getMethod('getDeliveryAttachments')->invoke($mail, $source); + } +} From 3682d2a484b5aba379295710e26e7f34308fbfc5 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Wed, 15 Jul 2026 10:29:47 +0200 Subject: [PATCH 08/43] [Feature] Mail: IRSS Unit Tests --- .../ILIAS/Mail/tests/ilMailCopyOnSendTest.php | 58 +++++-------------- 1 file changed, 15 insertions(+), 43 deletions(-) diff --git a/components/ILIAS/Mail/tests/ilMailCopyOnSendTest.php b/components/ILIAS/Mail/tests/ilMailCopyOnSendTest.php index b335b2246c1b..ec8f66e25f75 100644 --- a/components/ILIAS/Mail/tests/ilMailCopyOnSendTest.php +++ b/components/ILIAS/Mail/tests/ilMailCopyOnSendTest.php @@ -18,28 +18,18 @@ declare(strict_types=1); -use ILIAS\Refinery\Factory; +use PHPUnit\Framework\TestCase; use ILIAS\Mail\Attachments\MailAttachments; -use ILIAS\Mail\Service\MailSignatureService; use PHPUnit\Framework\MockObject\MockObject; -use ILIAS\Mail\Autoresponder\AutoresponderService; use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; -class ilMailCopyOnSendTest extends ilMailBaseTestCase +class ilMailCopyOnSendTest extends TestCase { private MockObject&ilFileDataMail $mail_file_data; - protected function setUp(): void - { - parent::setUp(); - - $refinery = $this->getMockBuilder(Factory::class)->disableOriginalConstructor()->getMock(); - $this->setGlobalVariable('refinery', $refinery); - } - public function testDeliveryAttachmentsReturnsLegacyUnchanged(): void { - $mail = $this->createMail(); + $mail = $this->createMailInstance(); $this->mail_file_data->expects($this->never())->method('copyCollectionForDelivery'); $legacy = MailAttachments::fromLegacyFilenames(['file.pdf']); @@ -50,7 +40,7 @@ public function testDeliveryAttachmentsReturnsLegacyUnchanged(): void public function testDeliveryAttachmentsReturnsEmptyUnchanged(): void { - $mail = $this->createMail(); + $mail = $this->createMailInstance(); $this->mail_file_data->expects($this->never())->method('copyCollectionForDelivery'); $empty = MailAttachments::empty(); @@ -61,7 +51,7 @@ public function testDeliveryAttachmentsReturnsEmptyUnchanged(): void public function testDeliveryAttachmentsCopiesIrssCollectionPerCall(): void { - $mail = $this->createMail(); + $mail = $this->createMailInstance(); $source = new ResourceCollectionIdentification('source-rcid'); $copy_a = new ResourceCollectionIdentification('copy-a'); $copy_b = new ResourceCollectionIdentification('copy-b'); @@ -81,7 +71,7 @@ public function testDeliveryAttachmentsCopiesIrssCollectionPerCall(): void public function testDeliveryAttachmentsReturnsEmptyWhenCopyFails(): void { - $mail = $this->createMail(); + $mail = $this->createMailInstance(); $source = new ResourceCollectionIdentification('source-rcid'); $this->mail_file_data ->expects($this->once()) @@ -95,7 +85,7 @@ public function testDeliveryAttachmentsReturnsEmptyWhenCopyFails(): void public function testSharedDeliveryAttachmentsReuseSingleCopy(): void { - $mail = $this->createMail(); + $mail = $this->createMailInstance(); $source = new ResourceCollectionIdentification('source-rcid'); $shared = new ResourceCollectionIdentification('shared-copy'); @@ -114,41 +104,23 @@ public function testSharedDeliveryAttachmentsReuseSingleCopy(): void $this->assertSame($first, $second); } - private function createMail(): ilMail + private function createMailInstance(): ilMail { $this->mail_file_data = $this->getMockBuilder(ilFileDataMail::class) ->disableOriginalConstructor() ->onlyMethods(['copyCollectionForDelivery']) ->getMock(); - return new ilMail( - 6, - $this->createMock(ilMailAddressTypeFactory::class), - $this->createMock(ilMailRfc822AddressParserFactory::class), - $this->createMock(ilAppEventHandler::class), - $this->createMock(ilLogger::class), - $this->createMock(ilDBInterface::class), - $this->createMock(ilLanguage::class), - $this->mail_file_data, - $this->createMock(ilMailOptions::class), - $this->createMock(ilMailbox::class), - $this->createMock(ilMailMimeSenderFactory::class), - static fn(string $login): int => 6, - $this->createMock(AutoresponderService::class), - 0, - 234, - $this->createMock(ilObjUser::class), - $this->createMock(ilMailTemplatePlaceholderResolver::class), - null, - null, - $this->createMock(MailSignatureService::class), - ); + $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 invokeDeliveryAttachments(MailAttachments $source, ?ilMail $mail = null): MailAttachments + private function invokeDeliveryAttachments(MailAttachments $source, ilMail $mail): MailAttachments { - $mail ??= $this->createMail(); - $reflection = new ReflectionClass($mail); return $reflection->getMethod('getDeliveryAttachments')->invoke($mail, $source); From 542d7885ae69eea81e6324ea84f465b3e59b3126 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Tue, 22 Sep 2026 10:53:05 +0200 Subject: [PATCH 09/43] =?UTF-8?q?[Fix]=20Mail:=20IRSS-Stage=20f=C3=BCr=20g?= =?UTF-8?q?eplante=20Mails=20und=20Size-Tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outbox analog zu Draft in die Attachment-Stage übernehmen und Size-Test auf die Collection-API umstellen. --- .../Mail/classes/class.ilMailFormGUI.php | 20 ++++++ .../FileDataRCHandlingAttachmentsSizeTest.php | 57 ++++++++++----- .../FileDataRCHandlingFormUploadTest.php | 70 +++++++++++++++++++ 3 files changed, 129 insertions(+), 18 deletions(-) create mode 100644 components/ILIAS/Mail/tests/FileDataRCHandlingFormUploadTest.php diff --git a/components/ILIAS/Mail/classes/class.ilMailFormGUI.php b/components/ILIAS/Mail/classes/class.ilMailFormGUI.php index 53abd6c4da25..53c372ffb994 100755 --- a/components/ILIAS/Mail/classes/class.ilMailFormGUI.php +++ b/components/ILIAS/Mail/classes/class.ilMailFormGUI.php @@ -741,6 +741,26 @@ public function showForm(?Form $form = null): void case self::MAIL_FORM_TYPE_OUTBOX: 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; diff --git a/components/ILIAS/Mail/tests/FileDataRCHandlingAttachmentsSizeTest.php b/components/ILIAS/Mail/tests/FileDataRCHandlingAttachmentsSizeTest.php index bedf2d75a418..b456354695b8 100644 --- a/components/ILIAS/Mail/tests/FileDataRCHandlingAttachmentsSizeTest.php +++ b/components/ILIAS/Mail/tests/FileDataRCHandlingAttachmentsSizeTest.php @@ -18,10 +18,12 @@ declare(strict_types=1); -use ILIAS\Filesystem\Stream\FileStream; +use ILIAS\ResourceStorage\Services; +use ILIAS\ResourceStorage\Manager\Manager; use ILIAS\FileUpload\Handler\FileInfoResult; use PHPUnit\Framework\Attributes\DataProvider; -use ILIAS\ResourceStorage\Consumer\FileStreamConsumer; +use ILIAS\ResourceStorage\Identification\ResourceIdentification; +use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; class FileDataRCHandlingAttachmentsSizeTest extends ilMailBaseTestCase { @@ -33,7 +35,7 @@ public function testHandleAttachmentsRejectsWhenTotalSizeExceedsLimit( ?float $limit, array $files ): void { - $subject = $this->createSubject($limit, $files, expect_store: false); + $subject = $this->createSubject($limit, $files, expect_collection: false); $this->expectException(ilMailAttachmentsTotalSizeLimitExceededException::class); $subject->handle(array_column($files, 'id')); @@ -47,11 +49,12 @@ public function testHandleAttachmentsStoresWhenTotalSizeIsWithinLimit( ?float $limit, array $files ): void { - $subject = $this->createSubject($limit, $files, expect_store: true); + $expected_rcid = new ResourceCollectionIdentification('mail-rcid'); + $subject = $this->createSubject($limit, $files, expect_collection: true, expected_rcid: $expected_rcid); $result = $subject->handle(array_column($files, 'id')); - $this->assertSame(array_column($files, 'id'), $result); + $this->assertSame($expected_rcid, $result); } /** @@ -83,41 +86,58 @@ 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_collection, + ?ResourceCollectionIdentification $expected_rcid = null + ): FileDataRCHandlingAttachmentsSizeTestSubject { $infos = []; + $resource_identifications = []; foreach ($files as $file) { $info = $this->createMock(FileInfoResult::class); $info->method('getFileIdentifier')->willReturn($file['id']); $info->method('getSize')->willReturn($file['size']); $info->method('getName')->willReturn($file['id']); $infos[$file['id']] = $info; + $resource_identifications[$file['id']] = new ResourceIdentification($file['id']); } $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_collection) { + $fdm->expects($this->once()) + ->method('createCollectionFromResourceIdentifications') + ->with(array_values($resource_identifications)) + ->willReturn($expected_rcid); } else { - $fdm->expects($this->never())->method('storeAsAttachment'); + $fdm->expects($this->never())->method('createCollectionFromResourceIdentifications'); } + $manager = $this->createMock(Manager::class); + if ($expect_collection) { + $manager->expects($this->exactly(count($files))) + ->method('find') + ->willReturnCallback( + static fn(string $id): ResourceIdentification => $resource_identifications[$id] + ); + } else { + $manager->expects($this->never())->method('find'); + } + + $storage = $this->createMock(Services::class); + $storage->method('manage')->willReturn($manager); + $upload_handler = $this->createMock(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->method('txt')->willReturnArgument(0); $this->setGlobalVariable('lng', $lng); - return new FileDataRCHandlingAttachmentsSizeTestSubject($fdm, $upload_handler, $lng); + return new FileDataRCHandlingAttachmentsSizeTestSubject($fdm, $upload_handler, $lng, $storage); } } @@ -130,7 +150,8 @@ class FileDataRCHandlingAttachmentsSizeTestSubject 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..0a6e9a9b6007 --- /dev/null +++ b/components/ILIAS/Mail/tests/FileDataRCHandlingFormUploadTest.php @@ -0,0 +1,70 @@ +createSubject(); + + $result = $subject->fromFormUpload([], $stage); + + $this->assertSame($stage, $result); + } + + public function testFormUploadWithoutNewFilesAndWithoutStageReturnsEmpty(): void + { + $subject = $this->createSubject(); + + $result = $subject->fromFormUpload([], null); + + $this->assertTrue($result->isEmpty()); + } + + private function createSubject(): FileDataRCHandlingFormUploadTestSubject + { + return new FileDataRCHandlingFormUploadTestSubject( + $this->createMock(ilFileDataMail::class), + $this->createMock(ilMailFormUploadHandlerGUI::class), + $this->createMock(ilLanguage::class), + $this->createMock(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 + ) { + } +} From 3dd3c486ffb21dfa13b57996ad8cb3fddb377fc6 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Fri, 25 Sep 2026 11:35:07 +0200 Subject: [PATCH 10/43] =?UTF-8?q?[Perf]=20Mail:=20IRSS-Migration=20zuerst?= =?UTF-8?q?=20neueste=20Anh=C3=A4nge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neueste Inbox-Mails (send_time DESC) zuerst nach IRSS, alte Seiten zuletzt. --- .../MigrateMailAttachmentsToIRSS.php | 11 ++++-- .../MigrateMailAttachmentsToIRSSUnitTest.php | 37 +++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/components/ILIAS/Mail/classes/Setup/Migration/MigrateMailAttachmentsToIRSS.php b/components/ILIAS/Mail/classes/Setup/Migration/MigrateMailAttachmentsToIRSS.php index 7423fecee77e..0c4e99e8eced 100644 --- a/components/ILIAS/Mail/classes/Setup/Migration/MigrateMailAttachmentsToIRSS.php +++ b/components/ILIAS/Mail/classes/Setup/Migration/MigrateMailAttachmentsToIRSS.php @@ -68,10 +68,12 @@ private function migrateSentAttachmentDirectories(): void { $db = $this->helper->getDatabase(); $res = $db->query( - 'SELECT path FROM mail_attachment - WHERE (rcid IS NULL OR rcid = "") - AND path IS NOT NULL AND path != "" - GROUP BY path + '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 LIMIT ' . self::PATHS_PER_STEP ); @@ -111,6 +113,7 @@ private function migrateSerializedMailAttachments(): void $res = $db->query( 'SELECT mail_id, user_id, attachments FROM mail WHERE attachments LIKE ' . $db->quote('a:%', 'text') . ' + ORDER BY send_time DESC, mail_id DESC LIMIT ' . self::MAILS_PER_STEP ); diff --git a/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php b/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php index fbda226002e3..da531e84d6db 100644 --- a/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php +++ b/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php @@ -169,6 +169,43 @@ public function testUpdateMailAttachmentFieldsSkipsNonSerializedAttachments(): v ]); } + public function testMigrateSerializedMailAttachmentsSelectsNewestMailsFirst(): void + { + $captured_sql = ''; + $statement = $this->createMock(ilDBStatement::class); + $this->db->method('quote')->willReturnCallback(static fn(string $value): string => "'" . $value . "'"); + $this->db->expects($this->once()) + ->method('query') + ->willReturnCallback(function (string $sql) use ($statement, &$captured_sql): ilDBStatement { + $captured_sql = $sql; + + return $statement; + }); + $this->db->method('fetchObject')->willReturn(null); + + $this->invokePrivate('migrateSerializedMailAttachments', []); + + $this->assertStringContainsString('ORDER BY send_time DESC', $captured_sql); + } + + public function testMigrateSentAttachmentDirectoriesSelectsPathsOfNewestMailsFirst(): void + { + $captured_sql = ''; + $statement = $this->createMock(ilDBStatement::class); + $this->db->expects($this->once()) + ->method('query') + ->willReturnCallback(function (string $sql) use ($statement, &$captured_sql): ilDBStatement { + $captured_sql = $sql; + + return $statement; + }); + $this->db->method('fetchObject')->willReturn(null); + + $this->invokePrivate('migrateSentAttachmentDirectories', []); + + $this->assertStringContainsString('ORDER BY MAX(m.send_time) DESC', $captured_sql); + } + public function testMarkPathAsSkippedWritesDashMarker(): void { $this->db->expects($this->once()) From e5aeefa75d16c1eae6920f52d7a2f2ad726ea83b Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Tue, 29 Sep 2026 15:02:49 +0200 Subject: [PATCH 11/43] [Fix] Mail: IRSS-Review setLimit, Release-12-Steps und src SQL-LIMIT durch ilDB->setLimit ersetzen, rcid-Spalte in MailDBUpdateSteps12, neue Klassen nach src/, ROADMAP IRSS auf Done. --- components/ILIAS/Mail/ROADMAP.md | 7 ++- .../classes/Setup/MailDBUpdateSteps11.php | 17 ------ .../classes/Setup/class.ilMailSetupAgent.php | 3 ++ .../class.ilMailAttachmentStakeholder.php | 4 +- .../Attachments/MailAttachments.php | 0 .../Mime/MailMimeAttachment.php | 0 .../Database/V12/MailDBUpdateSteps12.php | 52 +++++++++++++++++++ .../MigrateMailAttachmentsToIRSS.php | 12 ++--- .../MigrateMailAttachmentsToIRSSUnitTest.php | 7 +++ 9 files changed, 75 insertions(+), 27 deletions(-) rename components/ILIAS/Mail/{classes => src}/Attachments/MailAttachments.php (100%) rename components/ILIAS/Mail/{classes => src}/Mime/MailMimeAttachment.php (100%) create mode 100644 components/ILIAS/Mail/src/Setup/Database/V12/MailDBUpdateSteps12.php rename components/ILIAS/Mail/{classes => src}/Setup/Migration/MigrateMailAttachmentsToIRSS.php (97%) 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/Setup/MailDBUpdateSteps11.php b/components/ILIAS/Mail/classes/Setup/MailDBUpdateSteps11.php index f9154cd088de..5a476ae33cc7 100644 --- a/components/ILIAS/Mail/classes/Setup/MailDBUpdateSteps11.php +++ b/components/ILIAS/Mail/classes/Setup/MailDBUpdateSteps11.php @@ -68,21 +68,4 @@ public function step_2(): void ); } } - - public function step_3(): void - { - if (!$this->db->tableColumnExists('mail_attachment', 'rcid')) { - $this->db->addTableColumn( - 'mail_attachment', - 'rcid', - [ - 'type' => ilDBConstants::T_TEXT, - 'length' => 64, - 'notnull' => false, - 'default' => null, - ] - ); - } - } - } diff --git a/components/ILIAS/Mail/classes/Setup/class.ilMailSetupAgent.php b/components/ILIAS/Mail/classes/Setup/class.ilMailSetupAgent.php index 45e8337ea07e..6fcca805e8c4 100755 --- a/components/ILIAS/Mail/classes/Setup/class.ilMailSetupAgent.php +++ b/components/ILIAS/Mail/classes/Setup/class.ilMailSetupAgent.php @@ -22,6 +22,7 @@ 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; @@ -54,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()), ); } @@ -69,6 +71,7 @@ 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()), ); } diff --git a/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php b/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php index cac1dd147e8a..dde2a070e3c2 100644 --- a/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php +++ b/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php @@ -50,12 +50,12 @@ public function canBeAccessedByCurrentUser(ResourceIdentification $identificatio $db = $DIC->database(); $rid = $identification->serialize(); + $db->setLimit(1, 0); $res = $db->queryF( 'SELECT 1 FROM il_resource_rca rca INNER JOIN mail_attachment ma ON ma.rcid = rca.rcid INNER JOIN mail m ON m.mail_id = ma.mail_id - WHERE rca.rid = %s AND m.user_id = %s - LIMIT 1', + WHERE rca.rid = %s AND m.user_id = %s', [ilDBConstants::T_TEXT, ilDBConstants::T_INTEGER], [$rid, $user_id] ); diff --git a/components/ILIAS/Mail/classes/Attachments/MailAttachments.php b/components/ILIAS/Mail/src/Attachments/MailAttachments.php similarity index 100% rename from components/ILIAS/Mail/classes/Attachments/MailAttachments.php rename to components/ILIAS/Mail/src/Attachments/MailAttachments.php diff --git a/components/ILIAS/Mail/classes/Mime/MailMimeAttachment.php b/components/ILIAS/Mail/src/Mime/MailMimeAttachment.php similarity index 100% rename from components/ILIAS/Mail/classes/Mime/MailMimeAttachment.php rename to components/ILIAS/Mail/src/Mime/MailMimeAttachment.php 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..f9d42e121cbb --- /dev/null +++ b/components/ILIAS/Mail/src/Setup/Database/V12/MailDBUpdateSteps12.php @@ -0,0 +1,52 @@ +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, + ] + ); + } + } +} diff --git a/components/ILIAS/Mail/classes/Setup/Migration/MigrateMailAttachmentsToIRSS.php b/components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php similarity index 97% rename from components/ILIAS/Mail/classes/Setup/Migration/MigrateMailAttachmentsToIRSS.php rename to components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php index 0c4e99e8eced..7ccf9dd932fe 100644 --- a/components/ILIAS/Mail/classes/Setup/Migration/MigrateMailAttachmentsToIRSS.php +++ b/components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php @@ -67,14 +67,14 @@ public function step(Environment $environment): void private function migrateSentAttachmentDirectories(): void { $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 - LIMIT ' . self::PATHS_PER_STEP + ORDER BY MAX(m.send_time) DESC' ); $mail_path = rtrim($this->helper->getClientDataDir(), '/') . '/mail'; @@ -110,11 +110,11 @@ private function migrateSentAttachmentDirectories(): void private function migrateSerializedMailAttachments(): void { $db = $this->helper->getDatabase(); + $db->setLimit(self::MAILS_PER_STEP); $res = $db->query( 'SELECT mail_id, user_id, attachments FROM mail WHERE attachments LIKE ' . $db->quote('a:%', 'text') . ' - ORDER BY send_time DESC, mail_id DESC - LIMIT ' . self::MAILS_PER_STEP + ORDER BY send_time DESC, mail_id DESC' ); $mail_path = rtrim($this->helper->getClientDataDir(), '/') . '/mail'; @@ -228,12 +228,12 @@ public function getRemainingAmountOfSteps(): int 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 - LIMIT 1', + ORDER BY m.send_time ASC', [ilDBConstants::T_TEXT], [$relative_path] ); diff --git a/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php b/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php index da531e84d6db..370df092e22f 100644 --- a/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php +++ b/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php @@ -63,6 +63,9 @@ protected function setUp(): void public function testResolveOwnerIdForPathUsesSenderId(): void { $statement = $this->createMock(ilDBStatement::class); + $this->db->expects($this->once()) + ->method('setLimit') + ->with(1, 0); $this->db->expects($this->once()) ->method('queryF') ->willReturn($statement); @@ -174,6 +177,7 @@ public function testMigrateSerializedMailAttachmentsSelectsNewestMailsFirst(): v $captured_sql = ''; $statement = $this->createMock(ilDBStatement::class); $this->db->method('quote')->willReturnCallback(static fn(string $value): string => "'" . $value . "'"); + $this->db->expects($this->once())->method('setLimit')->with(5); $this->db->expects($this->once()) ->method('query') ->willReturnCallback(function (string $sql) use ($statement, &$captured_sql): ilDBStatement { @@ -186,12 +190,14 @@ public function testMigrateSerializedMailAttachmentsSelectsNewestMailsFirst(): v $this->invokePrivate('migrateSerializedMailAttachments', []); $this->assertStringContainsString('ORDER BY send_time DESC', $captured_sql); + $this->assertStringNotContainsString('LIMIT', $captured_sql); } public function testMigrateSentAttachmentDirectoriesSelectsPathsOfNewestMailsFirst(): void { $captured_sql = ''; $statement = $this->createMock(ilDBStatement::class); + $this->db->expects($this->once())->method('setLimit')->with(5); $this->db->expects($this->once()) ->method('query') ->willReturnCallback(function (string $sql) use ($statement, &$captured_sql): ilDBStatement { @@ -204,6 +210,7 @@ public function testMigrateSentAttachmentDirectoriesSelectsPathsOfNewestMailsFir $this->invokePrivate('migrateSentAttachmentDirectories', []); $this->assertStringContainsString('ORDER BY MAX(m.send_time) DESC', $captured_sql); + $this->assertStringNotContainsString('LIMIT', $captured_sql); } public function testMarkPathAsSkippedWritesDashMarker(): void From 35864cf0b67e0c8156f6380fbf971cd9c74bbdc4 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Fri, 9 Oct 2026 12:01:50 +0200 Subject: [PATCH 12/43] =?UTF-8?q?[Fix]=20Mail:=20IRSS=20Referenzz=C3=A4hlu?= =?UTF-8?q?ng=20und=20Speicherlecks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Collections werden nur noch über releaseCollectionIfUnreferenced() freigegeben (mail_attachment, mail_saved, Pool des Besitzers); Ressourcen nur, wenn sie in keiner anderen Collection mehr liegen - Entwürfe und geplante Mails referenzieren ihre Collection über mail_attachment - Gemeinsame Liefer-Collection für Sentbox und alle Empfänger, Snapshot beim Einreihen - Stage wird nach Entwurf/Planen geleert, leeres Formular bedeutet keine Anhänge - Lösch-Cron: leere Pfade nie löschen (sonst gesamtes mail-Verzeichnis) - Einzel-Download (TypeError) und ZIP-Download für Entwürfe repariert - removeCollection() legt bei fehlendem Header keine neue Collection mehr an - Index auf mail_attachment.rcid für die Referenzzählung --- .../Attachments/FileDataRCHandling.php | 31 +- .../MailDeletionHandler.php | 51 +- .../Mail/classes/class.ilAccountMail.php | 4 +- .../Mail/classes/class.ilFileDataMail.php | 502 ++++++++++++++++-- .../ILIAS/Mail/classes/class.ilMail.php | 304 +++++++---- .../classes/class.ilMailAttachmentGUI.php | 4 +- .../Mail/classes/class.ilMailFolderGUI.php | 34 +- .../Mail/classes/class.ilMailFormGUI.php | 48 +- .../class.ilMailFormUploadHandlerGUI.php | 4 +- .../ILIAS/Mail/classes/class.ilMailbox.php | 8 +- .../Mail/src/Attachments/MailAttachments.php | 6 +- .../Database/V12/MailDBUpdateSteps12.php | 10 + .../FileDataRCHandlingFormUploadTest.php | 8 +- .../ILIAS/Mail/tests/MailAttachmentsTest.php | 8 + .../MailDeletionHandlerAttachmentPathTest.php | 74 +++ .../ilFileDataMailCollectionReferenceTest.php | 68 +++ .../Mail/tests/ilFileDataMailFileNameTest.php | 70 +++ .../tests/ilFileDataMailSourceReleaseTest.php | 151 ++++++ .../ILIAS/Mail/tests/ilMailCopyOnSendTest.php | 87 ++- .../ilMailFolderGUIAttachmentHashesTest.php | 72 +++ components/ILIAS/Mail/tests/ilMailTest.php | 62 ++- 21 files changed, 1350 insertions(+), 256 deletions(-) create mode 100644 components/ILIAS/Mail/tests/MailDeletionHandlerAttachmentPathTest.php create mode 100644 components/ILIAS/Mail/tests/ilFileDataMailCollectionReferenceTest.php create mode 100644 components/ILIAS/Mail/tests/ilFileDataMailFileNameTest.php create mode 100644 components/ILIAS/Mail/tests/ilFileDataMailSourceReleaseTest.php create mode 100644 components/ILIAS/Mail/tests/ilMailFolderGUIAttachmentHashesTest.php diff --git a/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php b/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php index 8b6b3a20c157..0d901ae3eadd 100644 --- a/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php +++ b/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php @@ -24,22 +24,6 @@ trait FileDataRCHandling { - /** - * @param list $path_to_files - */ - protected function getCurrentCollection( - array $path_to_files - ): \ILIAS\ResourceStorage\Collection\ResourceCollection { - $rcid = $this->fdm->createCollectionFromPaths($path_to_files); - if ($rcid === null) { - throw new Exception('Storing file into collection failed: no files found'); - } - - return $this->fdm->getCollection($rcid); - } - - - /** * @return list */ @@ -56,8 +40,9 @@ protected function attachmentsFromFormUpload( array $form_attachment_rids, ?MailAttachments $stage_attachments = null ): MailAttachments { + // The submitted form is the single source of truth: no files in the form means no attachments if ($form_attachment_rids === []) { - return $stage_attachments ?? MailAttachments::empty(); + return MailAttachments::empty(); } $limit = $this->fdm->getAttachmentsTotalSizeLimit(); @@ -84,7 +69,7 @@ protected function attachmentsFromFormUpload( } if ($resource_identifications === []) { - return $stage_attachments ?? MailAttachments::empty(); + return MailAttachments::empty(); } $stage_rcid = ($stage_attachments instanceof MailAttachments && $stage_attachments->isIrss()) @@ -147,14 +132,10 @@ protected function stageAttachmentsFromMailAttachments(MailAttachments $attachme } if ($attachments->isIrss()) { - $foreign = iterator_to_array( - $this->fdm->getCollection($attachments->rcid())->getResourceIdentifications(), - false - ); - $cloned_rcid = $this->fdm->createCollectionFromForeignResources($foreign); + $stage_rcid = $this->fdm->createCollectionReferencingResourcesOf($attachments->rcid()); - return $cloned_rcid !== null - ? MailAttachments::fromIrss($cloned_rcid) + return $stage_rcid !== null + ? MailAttachments::fromIrss($stage_rcid) : MailAttachments::empty(); } diff --git a/components/ILIAS/Mail/classes/Cron/ExpiredOrOrphanedMails/MailDeletionHandler.php b/components/ILIAS/Mail/classes/Cron/ExpiredOrOrphanedMails/MailDeletionHandler.php index 218ecc60ee62..dc669a8152cf 100755 --- a/components/ILIAS/Mail/classes/Cron/ExpiredOrOrphanedMails/MailDeletionHandler.php +++ b/components/ILIAS/Mail/classes/Cron/ExpiredOrOrphanedMails/MailDeletionHandler.php @@ -43,7 +43,6 @@ class MailDeletionHandler private readonly ilSetting $settings; private readonly ilLogger $logger; private readonly ilDBStatement $mail_ids_for_path_stmt; - private readonly ilDBStatement $mail_ids_for_rcid_stmt; /** @var callable(string): void|null */ private $delete_directory_callback; @@ -57,6 +56,7 @@ public function __construct( ?ilSetting $setting = null, ?ilLogger $logger = null, ?callable $delete_directory_callback = null, + private readonly ?ilFileDataMail $mail_file_data = null, ) { global $DIC; @@ -69,21 +69,17 @@ public function __construct( 'SELECT COUNT(*) cnt FROM mail_attachment WHERE path = ?', [ilDBConstants::T_TEXT] ); - $this->mail_ids_for_rcid_stmt = $this->db->prepare( - 'SELECT COUNT(*) cnt FROM mail_attachment WHERE rcid = ?', - [ilDBConstants::T_TEXT] - ); } /** * @return list */ - private function determineDeletableAttachmentRcids(): array + private function determineAttachmentRcidsOfDeletedMails(): array { $rcids = []; $res = $this->db->query( ' - SELECT rcid, COUNT(mail_id) cnt_mail_ids + SELECT DISTINCT rcid FROM mail_attachment WHERE rcid IS NOT NULL AND rcid != "" AND rcid != "-" AND ' . $this->db->in( @@ -91,21 +87,11 @@ private function determineDeletableAttachmentRcids(): array $this->collector->mailIdsToDelete(), false, ilDBConstants::T_INTEGER - ) . ' GROUP BY rcid' + ) ); while ($row = $this->db->fetchAssoc($res)) { - $num_usages_total = (int) $this->db->fetchAssoc( - $this->db->execute( - $this->mail_ids_for_rcid_stmt, - [$row['rcid']] - ) - )['cnt']; - $num_usages_within_deleted_mails = (int) $row['cnt_mail_ids']; - - if ($num_usages_within_deleted_mails >= $num_usages_total) { - $rcids[] = $row['rcid']; - } + $rcids[] = (string) $row['rcid']; } return $rcids; @@ -122,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, @@ -144,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']; } @@ -165,15 +153,7 @@ private function deleteDirectory(string $directory): void private function deleteAttachments(): void { - $mail_file_data = new ilFileDataMail(); - foreach ($this->determineDeletableAttachmentRcids() as $rcid) { - try { - $mail_file_data->removeCollection(new ResourceCollectionIdentification($rcid)); - } catch (Throwable $e) { - $this->logger->warning($e->getMessage()); - } - } - + $attachment_rcids = $this->determineAttachmentRcidsOfDeletedMails(); $attachment_paths = $this->determineDeletableAttachmentPaths(); $i = 0; @@ -237,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/class.ilAccountMail.php b/components/ILIAS/Mail/classes/class.ilAccountMail.php index 0b3eea69378c..cde79bc41f0d 100755 --- a/components/ILIAS/Mail/classes/class.ilAccountMail.php +++ b/components/ILIAS/Mail/classes/class.ilAccountMail.php @@ -179,9 +179,7 @@ public function send(): bool } else { $attachment = $amail->getAttachment($this->irss); if ($attachment !== null && is_file($attachment->getPath())) { - $file_data_mail = new ilFileDataMail(ANONYMOUS_USER_ID); - $rid = $file_data_mail->streamFromPath($attachment->getPath()); - $mmail->AttachResource($rid, $attachment->getFilename()); + $mmail->Attach($attachment->getPath(), '', 'attachment', $attachment->getFilename()); } } diff --git a/components/ILIAS/Mail/classes/class.ilFileDataMail.php b/components/ILIAS/Mail/classes/class.ilFileDataMail.php index ead6dea4ade5..0c13fb8a1cdc 100755 --- a/components/ILIAS/Mail/classes/class.ilFileDataMail.php +++ b/components/ILIAS/Mail/classes/class.ilFileDataMail.php @@ -18,21 +18,21 @@ declare(strict_types=1); -use ILIAS\Mail\Attachments\MailAttachments; 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\Resource\Repository\CollectionDBRepository; use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; -use ILIAS\FileDelivery\Delivery as FileDelivery; class ilFileDataMail extends ilFileData { private const string POOL_RCID_PREF_KEY = 'mail_attachment_pool_rcid'; - public const string LEGACY_POOL_ITEM_PREFIX = 'legacy:'; public string $mail_path; @@ -91,7 +91,7 @@ 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 @@ -146,7 +146,6 @@ public function getAttachmentPathAndFilenameByMd5Hash(string $md5FileHash, int $ throw new OutOfBoundsException(); } - private function getAttachmentPathByMailId(int $mail_id): string { $query = $this->db->query( @@ -160,6 +159,11 @@ private function getAttachmentPathByMailId(int $mail_id): string return ''; } + public function hasLegacyAttachmentDirectory(int $mail_id): bool + { + return $this->getAttachmentPathByMailId($mail_id) !== ''; + } + public function checkReadWrite(): bool { if (is_writable($this->mail_path) && is_readable($this->mail_path)) { @@ -210,9 +214,8 @@ private function getUnsentFiles(): array return $files; } - /** - * @deprecated Legacy pool write; use uploadToPool() instead. + * @deprecated legacy pool write; use uploadToPool() instead */ public function storeUploadedFile(UploadResult $result): string { @@ -222,10 +225,30 @@ public function storeUploadedFile(UploadResult $result): string public function uploadToPool(UploadResult $result): ResourceIdentification { $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(); - $updated_rcid = $this->adoptPoolResourcesToCollection($pool_rcid, [$rid]); + if ($pool_rcid !== null) { + $existing = $this->resourceIdByHashInCollection( + $this->getCollection($pool_rcid), + $this->poolResourceHash($rid) + ); + if ($existing !== null && $existing->serialize() !== $rid->serialize()) { + $this->removeFromPool($existing); + } + } + $updated_rcid = $this->adoptPoolResourcesToCollection($pool_rcid, [$rid]); if ($updated_rcid === null) { + $this->removeResourceIfUnreferenced($rid); throw new RuntimeException('Could not store mail attachment in pool.'); } @@ -266,7 +289,7 @@ private function resolveUserPoolRcid(): ?ResourceCollectionIdentification /** * @return \Generator */ - private function iteratePoolResourceIdentifications(ResourceCollectionIdentification $pool_rcid): \Generator + private function iteratePoolResourceIdentifications(ResourceCollectionIdentification $pool_rcid): Generator { $this->repairCollectionHeaderIfNeeded($pool_rcid); @@ -331,23 +354,19 @@ public function migrateLegacyPoolFilenameToResource(string $filename): ?Resource } $rid = $this->streamFromPath($path, md5($filename)); - $pool_rcid = $this->resolveUserPoolRcid(); - $updated_rcid = $this->adoptPoolResourcesToCollection($pool_rcid, [$rid]); - if ($updated_rcid === null) { + try { + $rid = $this->addResourceToPool($rid); + } catch (RuntimeException) { return null; } - if ($pool_rcid === null || $pool_rcid->serialize() !== $updated_rcid->serialize()) { - $this->persistUserPoolRcid($updated_rcid); - } - $this->unlinkFile($filename); return $rid; } /** - * @param list $identifiers + * @param list $identifiers * @return list */ public function resolvePoolIdentifiersToResources(array $identifiers): array @@ -419,9 +438,7 @@ public function removeFromPool(ResourceIdentification $rid): void $collection->remove($rid); $this->irss->collection()->store($collection); - if (!$this->isResourceReferencedOutsideCollection($rid, $pool_rcid)) { - $this->irss->manage()->remove($rid, $this->stakeholder); - } + $this->removeResourceIfUnreferenced($rid); } public function poolResourceHash(ResourceIdentification $rid): string @@ -429,7 +446,6 @@ public function poolResourceHash(ResourceIdentification $rid): string return $this->irss->manage()->getCurrentRevision($rid)->getTitle(); } - private function persistUserPoolRcid(ResourceCollectionIdentification $rcid): void { $this->db->replace( @@ -444,6 +460,133 @@ private function persistUserPoolRcid(ResourceCollectionIdentification $rcid): vo ); } + /** + * 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. Its resources are only deleted if + * no other collection (pool, stage, other mails) still contains them. + * 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; + } + + $resource_identifications = []; + foreach ($this->getRidsFromCollection($rcid) as $rid_string) { + $resource_identification = $this->irss->manage()->find($rid_string); + if ($resource_identification === null) { + continue; + } + + if (!$this->resourceHasOnlyMailStakeholder($resource_identification)) { + return; + } + + $resource_identifications[] = $resource_identification; + } + + $this->removeCollection($rcid); + + foreach ($resource_identifications as $resource_identification) { + $this->removeResourceIfUnreferenced($resource_identification); + } + } + + public function removeResourceIfNotInAnyCollection(ResourceIdentification $rid): void + { + $this->removeResourceIfUnreferenced($rid); + } + + private function removeResourceIfUnreferenced(ResourceIdentification $rid): void + { + $this->db->setLimit(1, 0); + $res = $this->db->queryF( + 'SELECT 1 FROM ' . CollectionDBRepository::COLLECTION_ASSIGNMENT_TABLE_NAME . + ' WHERE ' . CollectionDBRepository::R_IDENTIFICATION . ' = %s', + [ilDBConstants::T_TEXT], + [$rid->serialize()] + ); + if ($this->db->numRows($res) > 0) { + return; + } + + if (!$this->resourceHasOnlyMailStakeholder($rid)) { + return; + } + + $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, nobody references it and none of its resources is shared. + */ + 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) || + $this->isResourceReferencedOutsideCollection($resource_identification, $rcid)) { + return false; + } + } + + return true; + } + private function isResourceReferencedOutsideCollection( ResourceIdentification $rid, ResourceCollectionIdentification $collection_identification @@ -525,6 +668,36 @@ public function assignAttachmentsToCollection( ); } + /** + * 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 replaceMailAttachmentCollection(int $mail_id, ?ResourceCollectionIdentification $rcid): void + { + if ($this->hasLegacyAttachmentDirectory($mail_id)) { + return; + } + + $previous_rcid = $this->getRcidForMail($mail_id); + if ($previous_rcid !== null && $rcid !== null && $previous_rcid->serialize() === $rcid->serialize()) { + return; + } + + $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); + } + } + public function getRcidForMail(int $mail_id): ?ResourceCollectionIdentification { $res = $this->db->queryF( @@ -571,14 +744,21 @@ public function deassignAttachmentFromDirectory(int $a_mail_id): bool } if ($rcid !== '' && $rcid !== '-') { - if ($this->countMailsReferencingRcid($rcid) === 1) { - $this->removeCollection(new ResourceCollectionIdentification($rcid)); - } - } elseif ($path !== '') { + $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)) { @@ -614,7 +794,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( @@ -649,13 +829,34 @@ protected function initAttachmentMaxUploadSize(): void public function onUserDelete(): void { + $rcids_to_release = []; + $pool_rcid = $this->resolveUserPoolRcid(); if ($pool_rcid !== null) { - try { - $this->removeCollection($pool_rcid); - } catch (Exception) { + $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 { @@ -707,24 +908,19 @@ public function onUserDelete(): void } } - $rcid_query = ' + $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 != "-" - AND (SELECT COUNT(tmp.rcid) FROM mail_attachment tmp WHERE tmp.rcid = ma1.rcid) = 1 - '; - $rcid_res = $this->db->queryF( - $rcid_query, + ', [ilDBConstants::T_INTEGER], [$this->user_id] ); while ($row = $this->db->fetchAssoc($rcid_res)) { - try { - $this->removeCollection(new ResourceCollectionIdentification($row['rcid'])); - } catch (Exception) { - } + $rcids_to_release[(string) $row['rcid']] = new ResourceCollectionIdentification((string) $row['rcid']); } // Delete each mail attachment rows assigned to a message of the deleted user. @@ -741,6 +937,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) { + } + } } /** @@ -755,6 +959,7 @@ public function deliverAttachmentsAsZip( $rcid = $this->getRcidForMail($mail_id); if ($rcid !== null && !$is_draft) { $this->deliverCollectionAsZip($rcid, $basename); + return; } @@ -940,6 +1145,45 @@ public function createCollectionFromPaths(array $absolute_paths): ?ResourceColle return $this->createCollectionFromResourceIdentifications($resource_identifications); } + /** + * 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 + { + if (!$this->hasLegacyAttachmentDirectory($mail_id)) { + $rcid = $this->createCollectionFromPoolFilenames($filenames); + + return $rcid !== null ? MailAttachments::fromIrss($rcid) : 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->createCollectionReferencingResourcesOf($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 === '') { @@ -956,14 +1200,109 @@ public function createCollectionFromContent(string $name, string $content): ?Res } $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([ - $this->irss->manage()->stream( - Streams::ofString($content), - $this->stakeholder, - md5($sanitized_name) - ), - ]); + return $this->createCollectionFromResourceIdentifications([$resource_identification]); + } + + /** + * Creates a new collection header for the resources of the passed collection. + * Mail-owned resources are shared (no file copy), foreign resources are copied. + */ + public function createCollectionReferencingResourcesOf( + ResourceCollectionIdentification $source + ): ?ResourceCollectionIdentification { + if (!$this->collectionIsKnown($source)) { + return null; + } + + $mail_resources = []; + $foreign_resources = []; + foreach ($this->getRidsFromCollection($source) as $rid_string) { + $resource_identification = $this->irss->manage()->find($rid_string); + if ($resource_identification === null) { + continue; + } + + if ($this->resourceHasOnlyMailStakeholder($resource_identification)) { + $mail_resources[] = $resource_identification; + } else { + $foreign_resources[] = $resource_identification; + } + } + + if ($foreign_resources !== []) { + $copied_rcid = $this->createCollectionFromForeignResources($foreign_resources); + if ($copied_rcid !== null) { + foreach ($this->getRidsFromCollection($copied_rcid) as $rid_string) { + $copied = $this->irss->manage()->find($rid_string); + if ($copied !== null) { + $mail_resources[] = $copied; + } + } + $this->removeCollection($copied_rcid); + } + } + + if ($mail_resources === []) { + return null; + } + + return $this->createCollectionFromResourceIdentifications($mail_resources); + } + + /** + * Builds the stage collection after a selection in the attachment pool: Non-pool attachments of the + * current stage are kept, pool files are exactly the selected ones. + * + * @param list $selected_pool_resources + */ + public function createStageCollectionWithPoolSelection( + ?ResourceCollectionIdentification $stage_rcid, + array $selected_pool_resources + ): ?ResourceCollectionIdentification { + $pool_rids = []; + $pool_rcid = $this->resolveUserPoolRcid(); + if ($pool_rcid !== null) { + foreach ($this->getAssignedRidStrings($pool_rcid) as $rid_string) { + $pool_rids[$rid_string] = true; + } + } + + $resources = []; + $hashes = []; + if ($stage_rcid !== null && $this->collectionIsKnown($stage_rcid)) { + foreach ($this->getRidsFromCollection($stage_rcid) as $rid_string) { + if (isset($pool_rids[$rid_string])) { + continue; + } + $resource_identification = $this->irss->manage()->find($rid_string); + if ($resource_identification !== null) { + $resources[$rid_string] = $resource_identification; + $hashes[$this->poolResourceHash($resource_identification)] = true; + } + } + } + + foreach ($selected_pool_resources as $resource_identification) { + $hash = $this->poolResourceHash($resource_identification); + if (isset($hashes[$hash])) { + continue; + } + $resources[$resource_identification->serialize()] = $resource_identification; + $hashes[$hash] = true; + } + + if ($resources === []) { + return null; + } + + return $this->createCollectionFromResourceIdentifications(array_values($resources)); } public function copyCollectionForDelivery( @@ -995,11 +1334,13 @@ public function createCollectionFromForeignResources( foreach ($resource_identifications as $source) { $revision = $this->irss->manage()->getCurrentRevision($source); $stream = $this->irss->consume()->stream($source); - $mail_resources[] = $this->irss->manage()->stream( + $copied = $this->irss->manage()->stream( $stream->getStream(), $this->stakeholder, $revision->getTitle() ); + $this->applyResourceFileName($copied, $revision->getInformation()->getTitle()); + $mail_resources[] = $copied; } return $this->createCollectionFromResourceIdentifications($mail_resources); @@ -1056,14 +1397,14 @@ private function getAssignedRidStrings(ResourceCollectionIdentification $rcid): private function collectionIsKnown(ResourceCollectionIdentification $rcid): bool { - return $this->irss->collection()->exists($rcid->serialize()) - || $this->getAssignedRidStrings($rcid) !== []; + return $this->irss->collection()->exists($rcid->serialize()) || + $this->getAssignedRidStrings($rcid) !== []; } private function repairCollectionHeaderIfNeeded(ResourceCollectionIdentification $rcid): void { - if ($this->irss->collection()->exists($rcid->serialize()) - || $this->getAssignedRidStrings($rcid) === []) { + if ($this->irss->collection()->exists($rcid->serialize()) || + $this->getAssignedRidStrings($rcid) === []) { return; } @@ -1129,7 +1470,6 @@ public function getRidsFromCollection(ResourceCollectionIdentification $rcid): a return $rids; } - /** * @param list $resource_identifications */ @@ -1203,15 +1543,67 @@ public function deliverCollectionAsZip(ResourceCollectionIdentification $rcid, s ->run(); } - public function removeCollection(ResourceCollectionIdentification $rcid, bool $ignore_usage = true): void + /** + * Removes the collection header and its assignments. Resources are only deleted if + * explicitly requested; prefer releaseCollectionIfUnreferenced() which respects shared resources. + */ + public function removeCollection(ResourceCollectionIdentification $rcid, bool $delete_resources = false): void { + $this->repairCollectionHeaderIfNeeded($rcid); + if (!$this->irss->collection()->exists($rcid->serialize())) { + return; + } + $this->irss->collection()->remove( - $this->irss->collection()->id($rcid->serialize()), + $rcid, $this->stakeholder, - $ignore_usage + $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. Shared resources survive. + */ + 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 */ diff --git a/components/ILIAS/Mail/classes/class.ilMail.php b/components/ILIAS/Mail/classes/class.ilMail.php index dc96270d2887..1c0799c24f8f 100755 --- a/components/ILIAS/Mail/classes/class.ilMail.php +++ b/components/ILIAS/Mail/classes/class.ilMail.php @@ -18,16 +18,17 @@ declare(strict_types=1); -use ILIAS\BackgroundTasks\Implementation\Bucket\BasicBucket; -use ILIAS\Mail\Autoresponder\AutoresponderService; -use ILIAS\LegalDocuments\Conductor; use ILIAS\Mail\Recipient; -use ILIAS\Mail\Service\MailSignatureService; -use ILIAS\Mail\Transformation\Utf8Mb4Sanitizer; -use ILIAS\Mail\Attachments\MailAttachments; +use ILIAS\LegalDocuments\Conductor; use ILIAS\Mail\Folder\MailScheduleData; use ILIAS\Mail\Message\MailRecordMapper; +use ILIAS\Mail\Attachments\MailAttachments; +use ILIAS\Mail\Service\MailSignatureService; +use ILIAS\Mail\Transformation\Utf8Mb4Sanitizer; +use ILIAS\Mail\Autoresponder\AutoresponderService; +use ILIAS\BackgroundTasks\Implementation\Bucket\BasicBucket; use ILIAS\ResourceStorage\Identification\ResourceIdentification; +use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; class ilMail { @@ -35,27 +36,25 @@ class ilMail public const string ILIAS_HOST = 'ilias'; public const string PROP_CONTEXT_SUBJECT_PREFIX = 'subject_prefix'; - private MailSignatureService $signature_service; public int $user_id; private string $table_mail; private string $table_mail_saved; + /** @var array|null */ protected ?array $mail_data = []; private bool $save_in_sentbox; private bool $append_installation_signature = false; private bool $append_user_signature = false; - - private bool $share_attachments = false; + private bool $share_attachments = true; private ?MailAttachments $shared_delivery_attachments = null; - private ?string $context_id = null; private array $context_parameters = []; /** @var array */ private array $mail_options_by_usr_id_map = []; - /** @var array */ + /** @var array */ private array $user_instances_by_id_map = []; private int $max_recipient_character_length = 998; private readonly Conductor $legal_documents; @@ -172,16 +171,24 @@ private function getDeliveryAttachments(MailAttachments $source): MailAttachment } if ($this->share_attachments) { + // One collection for the sent mail and all recipients, reference counted via `mail_attachment` if ($this->shared_delivery_attachments === null) { - $copied_rcid = $this->mail_file_data->copyCollectionForDelivery($source->rcid()); - $this->shared_delivery_attachments = $copied_rcid !== null - ? MailAttachments::fromIrss($copied_rcid) - : MailAttachments::empty(); + $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->copyCollectionForDelivery($source->rcid()); return $copied_rcid !== null @@ -217,6 +224,7 @@ public function formatNamesForOutput(string $recipients): string $user = $this->getUserInstanceById($usr_id); if ($user) { $names[] = $user->getFullname() . ' [' . $recipient . ']'; + continue; } } @@ -409,9 +417,6 @@ public function moveMailsToFolder(array $mail_ids, int $folder_id): bool return $affected_rows > 0; } - /** - * @param list $mailIds - */ public function deleteMails(array $mail_ids): void { $mail_ids = array_filter(array_map('intval', $mail_ids)); @@ -432,14 +437,32 @@ public function fetchMailData(?array $row): ?array return null; } - if ($row['attachments'] instanceof MailAttachments && $row['attachments']->isLegacy()) { - $migrated = $this->mail_file_data->migrateLegacyPoolAttachments($row['attachments']); - if ($migrated !== null) { - $row['attachments'] = $migrated; - if (isset($row['mail_id'])) { - $this->persistMailAttachmentsColumn((int) $row['mail_id'], $migrated); - } - } + 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; + } + + $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; @@ -495,6 +518,11 @@ public function updateDraft( ] ); + $this->mail_file_data->replaceMailAttachmentCollection( + $a_draft_id, + $a_attachments->stageRcidOrNull() + ); + return $a_draft_id; } @@ -538,10 +566,15 @@ public function scheduledMail( } else { $outbox_id = $mail_data->getMailDeliveryData()->getInternalMailId(); $this->db->update($this->table_mail, $mail_values, [ - 'mail_id' => [ilDBConstants::T_INTEGER, $outbox_id], + 'mail_id' => [ilDBConstants::T_INTEGER, $outbox_id], ]); } + $this->mail_file_data->replaceMailAttachmentCollection( + $outbox_id, + $mail_data->getMailDeliveryData()->getAttachments()->stageRcidOrNull() + ); + return $outbox_id; } @@ -574,7 +607,7 @@ private function sendInternalMail( 'user_id' => ['integer', $usr_id], 'folder_id' => ['integer', $folder_id], 'sender_id' => ['integer', $sender_usr_id], - 'attachments' => [ilDBConstants::T_BLOB, $attachments->toDb()], + 'attachments' => [ilDBConstants::T_CLOB, $attachments->toDb()], 'send_time' => ['timestamp', date('Y-m-d H:i:s')], 'rcp_to' => ['clob', $to], 'rcp_cc' => ['clob', $cc], @@ -641,7 +674,6 @@ private function replacePlaceholders( return $message; } - private function distributeMail(MailDeliveryData $mail_data): bool { $this->auto_responder_service->emptyAutoresponderData(); @@ -707,7 +739,7 @@ private function sendMailWithReplacedEmptyPlaceholder( } /** - * @param list $to_usr_ids + * @param list $to_usr_ids * @param list $cc_bcc_recipients */ private function sendMailWithoutReplacedPlaceholder( @@ -736,6 +768,7 @@ private function sendChanneledMails( string $message ): void { $usr_id_to_external_email_addresses_map = []; + $external_attachments = null; foreach ($recipients as $recipient) { if (!$recipient->isUser()) { @@ -743,6 +776,7 @@ private function sendChanneledMails( 'Skipped recipient with id %s (User not found)', $recipient->getUserId() )); + continue; } @@ -753,6 +787,7 @@ private function sendChanneledMails( $recipient->getUserId(), is_string($can_read_internal->error()) ? $can_read_internal->error() : $can_read_internal->error()->getMessage() )); + continue; } @@ -767,6 +802,7 @@ private function sendChanneledMails( $recipient->getUserId(), implode(', ', $email_addresses) )); + continue; } @@ -795,6 +831,9 @@ private function sendChanneledMails( $recipient_inbox_id = $mbox->getInboxFolder(); $delivery_attachments = $this->getDeliveryAttachments($mail_data->getAttachments()); + if ($external_attachments === null) { + $external_attachments = $delivery_attachments; + } $internal_mail_id = $this->sendInternalMail( $recipient_inbox_id, @@ -822,12 +861,28 @@ private function sendChanneledMails( ); } - $this->delegateExternalEmails( - $mail_data->getSubject(), - $this->getDeliveryAttachments($mail_data->getAttachments()), - $message, - $usr_id_to_external_email_addresses_map - ); + if ($usr_id_to_external_email_addresses_map === []) { + 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); + } + } } private function delegateExternalEmails( @@ -896,7 +951,7 @@ private function delegateExternalEmails( } /** - * @param list $recipients + * @param list $recipients * @return list */ private function getUserIds(array $recipients): array @@ -956,6 +1011,7 @@ private function checkRecipients(string $recipients): array } } catch (Exception $e) { $position = strpos($e->getMessage(), ':'); + throw new ilMailException( ($position === false) ? $e->getMessage() : substr($e->getMessage(), $position + 2), $e->getCode(), @@ -982,6 +1038,9 @@ public function persistToStage( 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, [ @@ -1000,11 +1059,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( @@ -1092,9 +1174,15 @@ public function enqueue( $a_attachment, $a_use_placeholders ); + return $this->sendMail($mail_data); } + // The queued mail gets its own collection, so callers may release their stage or draft right away + $source_attachment = $a_attachment; + $a_attachment = $this->createQueueSnapshot($source_attachment); + $this->releaseSourceAttachments($source_attachment); + $task_factory = $DIC->backgroundTasks()->taskFactory(); $task_manager = $DIC->backgroundTasks()->taskManager(); @@ -1137,7 +1225,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 @@ -1152,63 +1239,68 @@ public function sendMail( $mail_data = $mail_data->withAttachments($normalized['attachments']); $this->shared_delivery_attachments = null; - $sentbox_attachments = $this->getDeliveryAttachments($mail_data->getAttachments()); - $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->assignMailAttachments( - $internal_message_id, - $sentbox_attachments, - $internal_message_id - ); - - $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($mail_data->getAttachments()); + $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); - 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->assignMailAttachments( + $internal_message_id, + $sentbox_attachments, + $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(), - $this->getDeliveryAttachments($mail_data->getAttachments()) + $num_external_email_addresses = $this->getCountRecipients( + $mail_data->getTo(), + $mail_data->getCc(), + $mail_data->getBcc() ); - } 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 ($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->getSaveInSentbox()) { - $this->deleteMails([$internal_message_id]); + if (!$this->getSaveInSentbox()) { + $this->deleteMails([$internal_message_id]); + } + } finally { + $this->releaseSourceAttachments($source_attachments); } if ($this->isSystemMail()) { @@ -1224,6 +1316,30 @@ public function sendMail( return array_values($errors); } + private function createQueueSnapshot(MailAttachments $attachments): MailAttachments + { + if ($attachments->isEmpty() || !$attachments->isIrss()) { + return $attachments; + } + + $snapshot_rcid = $this->mail_file_data->createCollectionReferencingResourcesOf($attachments->rcid()); + + return $snapshot_rcid !== null ? MailAttachments::fromIrss($snapshot_rcid) : MailAttachments::empty(); + } + + 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 */ @@ -1440,11 +1556,11 @@ private function getCountRecipients( string $bcc_recipients, bool $only_external_addresses = true ): int { - return ( + return $this->getCountRecipient($to_recipients, $only_external_addresses) + $this->getCountRecipient($cc_recipients, $only_external_addresses) + $this->getCountRecipient($bcc_recipients, $only_external_addresses) - ); + ; } private function getEmailRecipients(string $recipients): string @@ -1464,7 +1580,7 @@ public static function _getAutoGeneratedMessageString(?ilLanguage $lang = null): { global $DIC; - if (!($lang instanceof ilLanguage)) { + if (!$lang instanceof ilLanguage) { $lang = ilLanguageFactory::_getLanguage(); } @@ -1486,7 +1602,7 @@ public static function _getIliasMailerName(): string } /** - * @return self|bool + * @return bool|self */ public function appendInstallationSignature(?bool $a_flag = null) { @@ -1495,12 +1611,14 @@ public function appendInstallationSignature(?bool $a_flag = null) } $this->append_installation_signature = $a_flag; + return $this; } public static function _getInstallationSignature(): string { global $DIC; + return $DIC->mail()->signature()->installation(); } diff --git a/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php b/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php index 1da7ccf92ea8..27d01a81fcca 100644 --- a/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php +++ b/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php @@ -156,7 +156,9 @@ private function saveAttachments(): void ? $stage_attachments->rcid() : null; - $rcid = $this->fdm->adoptPoolResourcesToCollection($existing_rcid, $resource_identifications); + // A new collection is built instead of mutating the current stage collection, + // because the stage may share it with a draft or a scheduled mail + $rcid = $this->fdm->createStageCollectionWithPoolSelection($existing_rcid, $resource_identifications); if ($rcid === null) { throw new RuntimeException($this->lng->txt('mail_error_reading_attachment')); } diff --git a/components/ILIAS/Mail/classes/class.ilMailFolderGUI.php b/components/ILIAS/Mail/classes/class.ilMailFolderGUI.php index 0ccc51a6490d..d7f9e1569eac 100755 --- a/components/ILIAS/Mail/classes/class.ilMailFolderGUI.php +++ b/components/ILIAS/Mail/classes/class.ilMailFolderGUI.php @@ -1187,12 +1187,12 @@ 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); if (isset($file['rcid']) && $file['rcid'] instanceof ResourceCollectionIdentification) { @@ -1238,11 +1238,10 @@ protected function deliverAttachments(): void $file = reset($listing); $mail_file_data->deliverFile($attachments->rcid(), $file['md5']); } else { - $mail_file_data->deliverAttachmentsAsZip( - $mail_data['m_subject'], - $mail_id, - [], - $type === 'draft' + // Drafts and scheduled mails are served from their collection as well + $mail_file_data->deliverCollectionAsZip( + $attachments->rcid(), + (string) ($mail_data['m_subject'] ?? '') ); } return; @@ -1289,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 53c372ffb994..eba0d2a9497b 100755 --- a/components/ILIAS/Mail/classes/class.ilMailFormGUI.php +++ b/components/ILIAS/Mail/classes/class.ilMailFormGUI.php @@ -247,6 +247,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(); @@ -286,6 +287,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); @@ -361,6 +365,7 @@ public function sendMessage(): void )) { $mailer->autoresponder()->disableAutoresponder(); + $this->umail->saveAttachments($attachments); $this->showSubmissionErrors($errors); $this->showForm($form); @@ -439,6 +444,7 @@ 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->umail->saveAttachments($attachments); $this->request_attachments = $attachments; $this->showSubmissionErrors($errors); $this->showForm($form); @@ -473,6 +479,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); @@ -485,6 +493,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')); @@ -771,7 +784,12 @@ public function showForm(?Form $form = null): void $mail_data['m_subject'] = $this->umail->formatForwardSubject($mail_data['m_subject'] ?? ''); $mail_data['m_message'] = $this->umail->prependSignature($mail_data['m_message'] ?? ''); if ($mail_data['attachments'] instanceof MailAttachments && !$mail_data['attachments']->isEmpty()) { - $stage_attachments = $this->stageAttachmentsFromMailAttachments($mail_data['attachments']); + $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(), '', @@ -886,17 +904,8 @@ public function showForm(?Form $form = null): void } } - if ($this->request_attachments instanceof MailAttachments) { - if ($this->request_attachments->isIrss()) { - $mail_data['attachments'] = $this->FilesFromIRSSToLegacy($this->request_attachments->rcid()); - } elseif ($this->request_attachments->isLegacy()) { - $rcid = $this->fdm->createCollectionFromPoolFilenames( - $this->request_attachments->legacyFilenames() - ); - $mail_data['attachments'] = $rcid !== null - ? $this->FilesFromIRSSToLegacy($rcid) - : []; - } + if ($this->request_attachments instanceof MailAttachments && $this->request_attachments->isIrss()) { + $mail_data['attachments'] = $this->FilesFromIRSSToLegacy($this->request_attachments->rcid()); } break; } @@ -1115,16 +1124,11 @@ protected function buildFormElements(?array $mail_data): array $mail_attachments = $mail_data['attachments'] ?? null; if ($mail_attachments instanceof MailAttachments && !$mail_attachments->isEmpty()) { - if ($mail_attachments->isIrss()) { - $mail_data['attachments'] = $this->FilesFromIRSSToLegacy($mail_attachments->rcid()); - } elseif ($mail_attachments->isLegacy()) { - $rcid = $this->fdm->createCollectionFromPoolFilenames($mail_attachments->legacyFilenames()); - $mail_data['attachments'] = $rcid !== null - ? $this->FilesFromIRSSToLegacy($rcid) - : []; - } else { - $mail_data['attachments'] = []; - } + // 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); diff --git a/components/ILIAS/Mail/classes/class.ilMailFormUploadHandlerGUI.php b/components/ILIAS/Mail/classes/class.ilMailFormUploadHandlerGUI.php index b7101848cc24..5b869170c501 100644 --- a/components/ILIAS/Mail/classes/class.ilMailFormUploadHandlerGUI.php +++ b/components/ILIAS/Mail/classes/class.ilMailFormUploadHandlerGUI.php @@ -33,11 +33,13 @@ class ilMailFormUploadHandlerGUI extends AbstractCtrlAwareUploadHandler private readonly ILIAS\ResourceStorage\Services $storage; private readonly ilMailAttachmentStakeholder $stakeholder; private readonly ilLanguage $lng; + private readonly int $user_id; 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(); @@ -69,7 +71,7 @@ protected function getRemoveResult(string $identifier): HandlerResultInterface { $id = $this->storage->manage()->find($identifier); if ($id !== null) { - $this->storage->manage()->remove($id, $this->stakeholder); + new ilFileDataMail($this->user_id)->removeResourceIfNotInAnyCollection($id); return new BasicHandlerResult($this->getFileIdentifierParameterName(), HandlerResultInterface::STATUS_OK, $identifier, 'file deleted'); } 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/src/Attachments/MailAttachments.php b/components/ILIAS/Mail/src/Attachments/MailAttachments.php index 8499fd933354..b22f4ec76b9f 100644 --- a/components/ILIAS/Mail/src/Attachments/MailAttachments.php +++ b/components/ILIAS/Mail/src/Attachments/MailAttachments.php @@ -62,8 +62,8 @@ public static function fromDb(?string $raw): ?self return null; } - if (str_contains($raw, 'a:')) { - $unserialized = unserialize($raw, ['allowed_classes' => false]); + if (str_starts_with($raw, 'a:')) { + $unserialized = @unserialize($raw, ['allowed_classes' => false]); if (!is_array($unserialized)) { return null; } @@ -82,7 +82,7 @@ public static function fromDb(?string $raw): ?self public static function fromBackgroundTask(string $serialized): self { - $parsed = unserialize($serialized, ['allowed_classes' => false]); + $parsed = @unserialize($serialized, ['allowed_classes' => false]); if (!is_array($parsed)) { return self::empty(); } diff --git a/components/ILIAS/Mail/src/Setup/Database/V12/MailDBUpdateSteps12.php b/components/ILIAS/Mail/src/Setup/Database/V12/MailDBUpdateSteps12.php index f9d42e121cbb..e72b0f2cd749 100644 --- a/components/ILIAS/Mail/src/Setup/Database/V12/MailDBUpdateSteps12.php +++ b/components/ILIAS/Mail/src/Setup/Database/V12/MailDBUpdateSteps12.php @@ -49,4 +49,14 @@ public function step_1(): void ); } } + + /** + * 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'); + } + } } diff --git a/components/ILIAS/Mail/tests/FileDataRCHandlingFormUploadTest.php b/components/ILIAS/Mail/tests/FileDataRCHandlingFormUploadTest.php index 0a6e9a9b6007..de9df2425b91 100644 --- a/components/ILIAS/Mail/tests/FileDataRCHandlingFormUploadTest.php +++ b/components/ILIAS/Mail/tests/FileDataRCHandlingFormUploadTest.php @@ -24,14 +24,18 @@ class FileDataRCHandlingFormUploadTest extends ilMailBaseTestCase { - public function testFormUploadWithoutNewFilesKeepsStageAttachments(): void + /** + * Regression: a mail composed after scheduling or saving a draft must not inherit the stage attachments, + * and removing all files in the form must remove all attachments. + */ + public function testFormUploadWithoutFilesIgnoresStageAttachments(): void { $stage = MailAttachments::fromIrss(new ResourceCollectionIdentification('stage-rcid')); $subject = $this->createSubject(); $result = $subject->fromFormUpload([], $stage); - $this->assertSame($stage, $result); + $this->assertTrue($result->isEmpty()); } public function testFormUploadWithoutNewFilesAndWithoutStageReturnsEmpty(): void diff --git a/components/ILIAS/Mail/tests/MailAttachmentsTest.php b/components/ILIAS/Mail/tests/MailAttachmentsTest.php index b6219a5f049c..5aaa2e29f9c2 100644 --- a/components/ILIAS/Mail/tests/MailAttachmentsTest.php +++ b/components/ILIAS/Mail/tests/MailAttachmentsTest.php @@ -34,6 +34,14 @@ public function testParseRcidString(): void $this->assertSame('rcid-123', $parsed->rcid()->serialize()); } + public function testValueContainingSerializationMarkerLaterIsNotTreatedAsLegacy(): void + { + $parsed = MailAttachments::fromDb('rcid-with-a:-inside'); + + $this->assertTrue($parsed->isIrss()); + $this->assertSame('rcid-with-a:-inside', $parsed->rcid()->serialize()); + } + public function testParseLegacyArray(): void { $parsed = MailAttachments::fromDb(serialize(['file.pdf', 'image.png'])); diff --git a/components/ILIAS/Mail/tests/MailDeletionHandlerAttachmentPathTest.php b/components/ILIAS/Mail/tests/MailDeletionHandlerAttachmentPathTest.php new file mode 100644 index 000000000000..3bb62a53e95d --- /dev/null +++ b/components/ILIAS/Mail/tests/MailDeletionHandlerAttachmentPathTest.php @@ -0,0 +1,74 @@ +createMock(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/ilFileDataMailCollectionReferenceTest.php b/components/ILIAS/Mail/tests/ilFileDataMailCollectionReferenceTest.php new file mode 100644 index 000000000000..8d1caa3ff3bc --- /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->createMock(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..3f55a7477809 --- /dev/null +++ b/components/ILIAS/Mail/tests/ilFileDataMailFileNameTest.php @@ -0,0 +1,70 @@ +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'), ''); + } +} diff --git a/components/ILIAS/Mail/tests/ilFileDataMailSourceReleaseTest.php b/components/ILIAS/Mail/tests/ilFileDataMailSourceReleaseTest.php new file mode 100644 index 000000000000..e905dc435e15 --- /dev/null +++ b/components/ILIAS/Mail/tests/ilFileDataMailSourceReleaseTest.php @@ -0,0 +1,151 @@ +createSut(referenced: true); + $sut->expects($this->never())->method('removeCollection'); + $this->manage->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']); + $sut->expects($this->never())->method('removeCollection'); + $this->manage->expects($this->never())->method('remove'); + + $sut->releaseSourceCollectionAfterDelivery($rcid); + } + + public function testRemovesCollectionAndUnsharedResources(): void + { + $rcid = new ResourceCollectionIdentification('calendar-rcid'); + $sut = $this->createSut(stakeholders: [new ilMailAttachmentStakeholder()], rids: ['ics-rid']); + $this->db->method('numRows')->willReturn(0); + $sut->expects($this->once())->method('removeCollection')->with($rcid); + $this->manage->expects($this->once())->method('remove'); + + $sut->releaseSourceCollectionAfterDelivery($rcid); + } + + public function testResourcesStillInOtherCollectionsSurvive(): void + { + $rcid = new ResourceCollectionIdentification('stage-rcid'); + $sut = $this->createSut(stakeholders: [new ilMailAttachmentStakeholder()], rids: ['pool-rid']); + // The resource is still assigned to e.g. the user's pool + $this->db->method('numRows')->willReturn(1); + $sut->expects($this->once())->method('removeCollection')->with($rcid); + $this->manage->expects($this->never())->method('remove'); + + $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 = [] + ): ilFileDataMail&MockObject { + $this->manage = $this->createMock(Manager::class); + $this->db = $this->createMock(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 foreignStakeholder(): ResourceStakeholder + { + $stakeholder = $this->createStub(ResourceStakeholder::class); + $stakeholder->method('getId')->willReturn('exc_instruction_files'); + + return $stakeholder; + } +} diff --git a/components/ILIAS/Mail/tests/ilMailCopyOnSendTest.php b/components/ILIAS/Mail/tests/ilMailCopyOnSendTest.php index ec8f66e25f75..228330d94980 100644 --- a/components/ILIAS/Mail/tests/ilMailCopyOnSendTest.php +++ b/components/ILIAS/Mail/tests/ilMailCopyOnSendTest.php @@ -49,7 +49,7 @@ public function testDeliveryAttachmentsReturnsEmptyUnchanged(): void $this->assertTrue($result->isEmpty()); } - public function testDeliveryAttachmentsCopiesIrssCollectionPerCall(): void + public function testDeliveryAttachmentsCopiesIrssCollectionPerCallWhenSharingIsDisabled(): void { $mail = $this->createMailInstance(); $source = new ResourceCollectionIdentification('source-rcid'); @@ -62,6 +62,8 @@ public function testDeliveryAttachmentsCopiesIrssCollectionPerCall(): void ->with($source) ->willReturnOnConsecutiveCalls($copy_a, $copy_b); + $mail->setShareAttachments(false); + $first = $this->invokeDeliveryAttachments(MailAttachments::fromIrss($source), $mail); $second = $this->invokeDeliveryAttachments(MailAttachments::fromIrss($source), $mail); @@ -104,14 +106,86 @@ public function testSharedDeliveryAttachmentsReuseSingleCopy(): void $this->assertSame($first, $second); } - private function createMailInstance(): ilMail + public function testDeliveryAttachmentsAreSharedByDefault(): void + { + $mail = $this->createMailInstance(); + $source = new ResourceCollectionIdentification('source-rcid'); + + $this->mail_file_data + ->expects($this->once()) + ->method('copyCollectionForDelivery') + ->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('copyCollectionForDelivery'); + + $result = $this->invokeDeliveryAttachments($source, $mail); + + $this->assertSame('queued-snapshot', $result->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(['copyCollectionForDelivery']) + ->onlyMethods([ + 'copyCollectionForDelivery', + 'releaseSourceCollectionAfterDelivery', + 'isCollectionExclusivelyOwnedByMail', + ]) ->getMock(); + $this->mail_file_data->method('isCollectionExclusivelyOwnedByMail')->willReturn($exclusively_owned); - $mail = (new ReflectionClass(ilMail::class))->newInstanceWithoutConstructor(); + $mail = new ReflectionClass(ilMail::class)->newInstanceWithoutConstructor(); $reflection = new ReflectionClass(ilMail::class); $reflection->getProperty('mail_file_data')->setValue($mail, $this->mail_file_data); @@ -119,6 +193,11 @@ private function createMailInstance(): ilMail return $mail; } + private function invokeReleaseSourceAttachments(ilMail $mail, MailAttachments $source): void + { + new ReflectionClass($mail)->getMethod('releaseSourceAttachments')->invoke($mail, $source); + } + private function invokeDeliveryAttachments(MailAttachments $source, ilMail $mail): MailAttachments { $reflection = new ReflectionClass($mail); diff --git a/components/ILIAS/Mail/tests/ilMailFolderGUIAttachmentHashesTest.php b/components/ILIAS/Mail/tests/ilMailFolderGUIAttachmentHashesTest.php new file mode 100644 index 000000000000..462b56b1b5a9 --- /dev/null +++ b/components/ILIAS/Mail/tests/ilMailFolderGUIAttachmentHashesTest.php @@ -0,0 +1,72 @@ +createMock(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/ilMailTest.php b/components/ILIAS/Mail/tests/ilMailTest.php index d6b9e0809801..319d2a2377f6 100755 --- a/components/ILIAS/Mail/tests/ilMailTest.php +++ b/components/ILIAS/Mail/tests/ilMailTest.php @@ -38,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 { @@ -529,8 +530,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' ]); @@ -629,6 +631,56 @@ 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->getMockBuilder(ilDBStatement::class)->disableOriginalConstructor()->getMock(); + $this->mock_database->method('queryF')->willReturn($mock_statement); + $this->mock_database->method('fetchAssoc')->willReturnOnConsecutiveCalls( + ['attachments' => 'old-stage-rcid'], + ['attachments' => 'new-stage-rcid', 'rcp_to' => ''] + ); + + $this->mock_mail_file_data + ->expects($this->once()) + ->method('releaseCollectionIfUnreferenced') + ->with($this->callback( + static fn(ResourceCollectionIdentification $rcid): bool => $rcid->serialize() === 'old-stage-rcid' + )); + + $instance->persistToStage( + $usr_id, + '', + '', + '', + '', + '', + MailAttachments::fromIrss(new ResourceCollectionIdentification('new-stage-rcid')) + ); + } + + public function testPersistingSameStageCollectionKeepsIt(): void + { + $usr_id = 897; + $instance = $this->create(789, $usr_id); + $mock_statement = $this->getMockBuilder(ilDBStatement::class)->disableOriginalConstructor()->getMock(); + $this->mock_database->method('queryF')->willReturn($mock_statement); + $this->mock_database->method('fetchAssoc')->willReturn(['attachments' => 'stage-rcid', 'rcp_to' => '']); + + $this->mock_mail_file_data->expects($this->never())->method('releaseCollectionIfUnreferenced'); + + $instance->persistToStage( + $usr_id, + '', + '', + '', + '', + '', + MailAttachments::fromIrss(new ResourceCollectionIdentification('stage-rcid')) + ); + } + public function testSaveAttachments(): void { $usr_id = 89; @@ -638,10 +690,10 @@ public function testSaveAttachments(): void $instance = $this->create(789, $usr_id); $mock_statement = $this->getMockBuilder(ilDBStatement::class)->getMock(); - $this->mock_database->expects($this->exactly(2))->method('queryF')->willReturnCallback( + $this->mock_database->expects($this->exactly(3))->method('queryF')->willReturnCallback( $this->queryCallback($mock_statement, ['integer'], [$usr_id]) ); - $this->mock_database->expects($this->exactly(2))->method('fetchAssoc')->with($mock_statement)->willReturn([ + $this->mock_database->expects($this->exactly(3))->method('fetchAssoc')->with($mock_statement)->willReturn([ 'attachments' => null, 'rcp_to' => '', 'rcp_cc' => '', @@ -699,7 +751,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(), From 4d17735f7c302e89014d6586a16b8f1e1d0db55e Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Fri, 9 Oct 2026 12:01:53 +0200 Subject: [PATCH 13/43] =?UTF-8?q?[Fix]=20Mail:=20IRSS-Migration=20nach=20s?= =?UTF-8?q?end=5Ftime,=20nur=20=C3=BCber=20IRSS-Helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Verzeichnisse und Entwürfe der zuletzt gesendeten Mails zuerst (send_time) - Speichern ausschließlich über ilResourceStorageMigrationHelper - Mails mit Anhangsverzeichnis werden nie als Pool-Verweise behandelt, Pool-Dateien werden kopiert und ohne Benutzer-Präfix benannt - Entwürfe erhalten eine mail_attachment-Zeile, geleerte Spalten früherer Läufe werden repariert - Übersprungene Verzeichnisse, Abbrüche und Fortschritt werden über die Setup-Ausgabe gemeldet Lasttest (Kopie der Entwicklungs-DB): 3.798 Dateien / 238 MB in ~11 s, 37.998 Dateien / 593 MB in ~122 s. --- .../MigrateMailAttachmentsToIRSS.php | 422 ++++++++++++++---- .../MigrateMailAttachmentsToIRSSUnitTest.php | 366 ++++++++++----- 2 files changed, 577 insertions(+), 211 deletions(-) diff --git a/components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php b/components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php index 7ccf9dd932fe..a7dcfdac3dc7 100644 --- a/components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php +++ b/components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php @@ -20,20 +20,35 @@ namespace ILIAS\Mail\Setup\Migration; +use Throwable; use ilDBConstants; -use ILIAS\Mail\Attachments\MailAttachments; use ILIAS\Setup\Migration; use ILIAS\Setup\Environment; +use ILIAS\Setup\AdminInteraction; use ilMailAttachmentStakeholder; use ilResourceStorageMigrationHelper; -use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; +use ILIAS\Mail\Attachments\MailAttachments; use ILIAS\ResourceStorage\Identification\ResourceIdentification; +use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; +/** + * Migrates mail attachments to the IRSS, the attachments of the most recently sent mails first, + * because these are the ones users access most (e.g. the first page of their inbox). + */ class MigrateMailAttachmentsToIRSS implements Migration { - private const int PATHS_PER_STEP = 5; - private const int MAILS_PER_STEP = 5; + private const int PATHS_PER_STEP = 25; + private const int MAILS_PER_STEP = 50; + private const int REPAIRS_PER_STEP = 100; + private const string SKIPPED_MARKER = '-'; + private ?ilResourceStorageMigrationHelper $helper = null; + private ?AdminInteraction $io = null; + private bool $repair_done = false; + private int $step_number = 0; + + /** @var array */ + private array $statistics = []; public function getLabel(): string { @@ -52,6 +67,7 @@ public function getPreconditions(Environment $environment): array public function prepare(Environment $environment): void { + $this->io = $this->resolveAdminInteraction($environment); $this->helper = new ilResourceStorageMigrationHelper( new ilMailAttachmentStakeholder(), $environment @@ -60,31 +76,51 @@ public function prepare(Environment $environment): void public function step(Environment $environment): void { - $this->migrateSentAttachmentDirectories(); - $this->migrateSerializedMailAttachments(); + $this->io ??= $this->resolveAdminInteraction($environment); + $this->step_number++; + $this->statistics = []; + $started = microtime(true); + + try { + $this->migrateSentAttachmentDirectories(); + $this->repairAttachmentColumnsOfMigratedDirectories(); + $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 { - $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' - ); - $mail_path = rtrim($this->helper->getClientDataDir(), '/') . '/mail'; - while ($row = $db->fetchObject($res)) { - $relative_path = (string) $row->path; + 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->markPathAsSkipped($relative_path); + $this->skipPath($relative_path, 'Directory does not exist'); continue; } @@ -93,85 +129,184 @@ private function migrateSentAttachmentDirectories(): void $rcid = $this->helper->moveFilesOfPathToCollection( $absolute_path, $owner_id, - $owner_id + $owner_id, + null, + // The revision title is the hash Mail uses to identify a single attachment + static fn(string $file_name): string => md5($file_name) ); if ($rcid === null) { - $this->markPathAsSkipped($relative_path); + $this->skipPath($relative_path, 'No file of the directory could be stored'); continue; } $this->assignRcidToPath($relative_path, $rcid); $this->updateMailAttachmentFields($relative_path, $rcid); + $this->count('directories_migrated'); } } - private function migrateSerializedMailAttachments(): void + /** + * @return list Paths of the most recently sent mails first + */ + private function nextUnmigratedPaths(): array { $db = $this->helper->getDatabase(); - $db->setLimit(self::MAILS_PER_STEP); + $db->setLimit(self::PATHS_PER_STEP); $res = $db->query( - 'SELECT mail_id, user_id, attachments FROM mail - WHERE attachments LIKE ' . $db->quote('a:%', 'text') . ' - ORDER BY send_time DESC, mail_id DESC' + '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' ); - $mail_path = rtrim($this->helper->getClientDataDir(), '/') . '/mail'; + $paths = []; + while ($row = $db->fetchAssoc($res)) { + $paths[] = (string) $row['path']; + } + + return $paths; + } - while ($row = $db->fetchObject($res)) { - $attachments = MailAttachments::fromDb((string) $row->attachments); - if ($attachments === null || !$attachments->isLegacy()) { - $this->clearMailAttachmentsColumn((int) $row->mail_id); + 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), + ]); + } - continue; - } + /** + * @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; + } - $rcid = $this->migratePoolFilenamesToCollection( - $attachments->legacyFilenames(), - (int) $row->user_id, + // ----------------------------------------------------------------------------------------------------------- + // 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 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) . ' + 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 ); + } + } - if ($rcid === null) { - $this->clearMailAttachmentsColumn((int) $row->mail_id); - - continue; + private function migrateSerializedMail(int $mail_id, int $user_id, string $raw_attachments, string $mail_path): 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); - $db->update( - 'mail', - [ - 'attachments' => [ilDBConstants::T_CLOB, $rcid->serialize()], - ], - [ - 'mail_id' => [ilDBConstants::T_INTEGER, (int) $row->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 + 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 (!is_file($absolute_path)) { + 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, - null, - static fn(): string => md5($filename) + static fn(): string => $filename, + static fn(): string => md5($filename), + true ); if ($resource_id instanceof ResourceIdentification) { @@ -203,26 +338,94 @@ private function clearMailAttachmentsColumn(int $mail_id): void ); } + // ----------------------------------------------------------------------------------------------------------- + // Repair of earlier runs + // ----------------------------------------------------------------------------------------------------------- + + /** + * Earlier versions of this migration could empty the `attachments` column of mails whose directory + * was migrated afterwards. The collection is restored from the `mail_attachment` row. + */ + private function repairAttachmentColumnsOfMigratedDirectories(): void + { + if ($this->repair_done) { + return; + } + + $db = $this->helper->getDatabase(); + $db->setLimit(self::REPAIRS_PER_STEP); + $res = $db->query($this->buildRepairQuery('m.mail_id, ma.rcid')); + + $rows = 0; + while ($row = $db->fetchAssoc($res)) { + $rows++; + $db->update( + 'mail', + [ + 'attachments' => [ilDBConstants::T_CLOB, (string) $row['rcid']], + ], + [ + 'mail_id' => [ilDBConstants::T_INTEGER, (int) $row['mail_id']], + ] + ); + } + + if ($rows > 0) { + $this->count('columns_repaired', $rows); + } + + // Directories migrated by this version never empty the column, so nothing new can show up + $this->repair_done = $rows < self::REPAIRS_PER_STEP; + } + + private function buildRepairQuery(string $select): string + { + return 'SELECT ' . $select . ' FROM mail_attachment ma + INNER JOIN mail m ON m.mail_id = ma.mail_id + WHERE ma.rcid IS NOT NULL AND ma.rcid != "" AND ma.rcid != "' . self::SKIPPED_MARKER . '" + AND ma.path IS NOT NULL AND ma.path != "" + AND (m.attachments IS NULL OR m.attachments = "")'; + } + public function getRemainingAmountOfSteps(): int { $db = $this->helper->getDatabase(); - $path_count = (int) $db->fetchObject( + $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; + )['cnt']; - $mail_count = (int) $db->fetchObject( + $mail_count = (int) $db->fetchAssoc( $db->query( - 'SELECT COUNT(mail_id) cnt FROM mail - WHERE attachments LIKE ' . $db->quote('a:%', 'text') + '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; + )['cnt']; + + $repair_count = (int) $db->fetchAssoc( + $db->query($this->buildRepairQuery('COUNT(*) cnt')) + )['cnt']; + + $remaining = max( + (int) ceil($path_count / self::PATHS_PER_STEP), + (int) ceil($mail_count / self::MAILS_PER_STEP), + (int) ceil($repair_count / self::REPAIRS_PER_STEP) + ); + + $this->info('Remaining', [ + 'directories' => $path_count, + 'drafts' => $mail_count, + 'columns_to_repair' => $repair_count, + 'steps' => $remaining, + ]); - return $path_count + $mail_count; + return $remaining; } private function resolveOwnerIdForPath(string $relative_path): int @@ -238,9 +441,9 @@ private function resolveOwnerIdForPath(string $relative_path): int [$relative_path] ); - $row = $db->fetchObject($res); - if ($row !== null && (int) $row->sender_id > 0) { - return (int) $row->sender_id; + $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; @@ -257,33 +460,17 @@ private function assignRcidToPath(string $relative_path, ResourceCollectionIdent private function updateMailAttachmentFields(string $relative_path, ResourceCollectionIdentification $rcid): void { + $mail_ids = $this->mailIdsOfPath($relative_path); + if ($mail_ids === []) { + return; + } + $db = $this->helper->getDatabase(); - $res = $db->queryF( - 'SELECT m.mail_id, m.attachments FROM mail_attachment ma - INNER JOIN mail m ON m.mail_id = ma.mail_id - WHERE ma.path = %s', + $db->manipulateF( + 'UPDATE mail SET attachments = %s WHERE ' . $db->in('mail_id', $mail_ids, false, ilDBConstants::T_INTEGER), [ilDBConstants::T_TEXT], - [$relative_path] + [$rcid->serialize()] ); - - while ($row = $db->fetchObject($res)) { - if (!is_string($row->attachments) || $row->attachments === '') { - continue; - } - if (!str_contains($row->attachments, 'a:')) { - continue; - } - - $db->update( - 'mail', - [ - 'attachments' => [ilDBConstants::T_CLOB, $rcid->serialize()], - ], - [ - 'mail_id' => [ilDBConstants::T_INTEGER, (int) $row->mail_id], - ] - ); - } } private function markPathAsSkipped(string $relative_path): void @@ -291,7 +478,64 @@ 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], - ['-', $relative_path] + [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/MigrateMailAttachmentsToIRSSUnitTest.php b/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php index 370df092e22f..a5d2388914d1 100644 --- a/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php +++ b/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php @@ -18,6 +18,8 @@ declare(strict_types=1); +use ILIAS\Setup\Environment; +use ILIAS\Setup\AdminInteraction; use PHPUnit\Framework\MockObject\MockObject; use ILIAS\ResourceStorage\Collection\CollectionBuilder; use ILIAS\ResourceStorage\Collection\ResourceCollection; @@ -30,6 +32,9 @@ class MigrateMailAttachmentsToIRSSUnitTest extends ilMailBaseTestCase private MigrateMailAttachmentsToIRSS $migration; private MockObject&ilResourceStorageMigrationHelper $helper; private MockObject&ilDBInterface $db; + /** @var list */ + private array $messages = []; + private string $tmp_dir; protected function setUp(): void { @@ -39,7 +44,14 @@ protected function setUp(): void 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->createMock(ilDBInterface::class); + $this->db->method('quote')->willReturnCallback(static fn(mixed $value): string => "'" . $value . "'"); + $this->db->method('supportsTransactions')->willReturn(true); + $this->helper = $this->getMockBuilder(ilResourceStorageMigrationHelper::class) ->disableOriginalConstructor() ->onlyMethods([ @@ -50,180 +62,292 @@ protected function setUp(): void 'getCollectionBuilder', ]) ->getMock(); - $this->db = $this->createMock(ilDBInterface::class); - $this->helper->method('getDatabase')->willReturn($this->db); - $this->helper->method('getClientDataDir')->willReturn('/tmp/client'); + $this->helper->method('getClientDataDir')->willReturn($this->tmp_dir); + + $io = $this->createMock(AdminInteraction::class); + $io->method('inform')->willReturnCallback(function (string $message): void { + $this->messages[] = $message; + }); $reflection = new ReflectionClass($this->migration); - $property = $reflection->getProperty('helper'); - $property->setValue($this->migration, $this->helper); + $reflection->getProperty('helper')->setValue($this->migration, $this->helper); + $reflection->getProperty('io')->setValue($this->migration, $io); } - public function testResolveOwnerIdForPathUsesSenderId(): void + protected function tearDown(): void { - $statement = $this->createMock(ilDBStatement::class); - $this->db->expects($this->once()) - ->method('setLimit') - ->with(1, 0); - $this->db->expects($this->once()) - ->method('queryF') - ->willReturn($statement); - $this->db->expects($this->once()) - ->method('fetchObject') - ->with($statement) - ->willReturn((object) ['sender_id' => 42]); - - $owner_id = $this->invokePrivate('resolveOwnerIdForPath', ['migtest/s1']); - - $this->assertSame(42, $owner_id); + exec('chmod -R u+rwx ' . escapeshellarg($this->tmp_dir) . ' && rm -rf ' . escapeshellarg($this->tmp_dir)); + parent::tearDown(); } - public function testResolveOwnerIdForPathFallsBackToSystemUser(): void + // --------------------------------------------------------------------------------------------------------- + // Order: most recently sent mails first + // --------------------------------------------------------------------------------------------------------- + + // --------------------------------------------------------------------------------------------------------- + // Directories + // --------------------------------------------------------------------------------------------------------- + + // --------------------------------------------------------------------------------------------------------- + // Drafts + // --------------------------------------------------------------------------------------------------------- + + public function testPathsOfTheMostRecentlySentMailsAreMigratedFirst(): void { - $statement = $this->createMock(ilDBStatement::class); - $this->db->method('queryF')->willReturn($statement); - $this->db->method('fetchObject')->willReturn((object) ['sender_id' => 0]); + $captured_sql = ''; + $this->db->expects($this->once())->method('setLimit')->with(25); + $this->db->method('query')->willReturnCallback( + function (string $sql) use (&$captured_sql): ilDBStatement { + $captured_sql = $sql; - $owner_id = $this->invokePrivate('resolveOwnerIdForPath', ['migtest/s5']); + return $this->createStub(ilDBStatement::class); + } + ); + $this->db->method('fetchAssoc')->willReturnOnConsecutiveCalls(['path' => 'newest'], ['path' => 'older'], null); - $this->assertSame(SYSTEM_USER_ID, $owner_id); + $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); } - public function testMigratePoolFilenamesToCollectionSkipsMissingFiles(): void + /** + * Regression: mails with a not yet migrated directory must never be treated as pool references + */ + public function testDraftsOfTheMostRecentlySentMailsAreMigratedFirstAndNeverMailsWithDirectory(): void { - $dir = sys_get_temp_dir() . '/mail-migration-unit-' . uniqid('', true); - $mail_path = $dir . '/mail'; - mkdir($mail_path, 0775, true); - file_put_contents($mail_path . '/6_present.svg', ''); + $captured_sql = ''; + $this->db->expects($this->once())->method('setLimit')->with(50); + $this->db->method('query')->willReturnCallback( + function (string $sql) use (&$captured_sql): ilDBStatement { + $captured_sql = $sql; - $this->helper->method('getClientDataDir')->willReturn($dir); + return $this->createStub(ilDBStatement::class); + } + ); + $this->db->method('fetchAssoc')->willReturn(null); - $collection = $this->createMock(ResourceCollection::class); - $collection->method('count')->willReturn(1); - $collection->expects($this->once())->method('add'); - $collection->method('getIdentification')->willReturn( - new ResourceCollectionIdentification('migrated-rcid') + $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('ORDER BY m.send_time DESC, m.mail_id DESC', $captured_sql); + } + + public function testDirectoryIsMigratedWithTheIrssHelper(): void + { + $dir = $this->createDirectory('6_100', ['a.pdf' => '1234']); + $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 + ); + $this->helper->expects($this->once()) + ->method('moveFilesOfPathToCollection') + ->willReturnCallback(function ( + string $path, + int $resource_owner, + int $collection_owner, + ?Closure $file_name, + Closure $revision_title + ) use ($dir): ResourceCollectionIdentification { + $this->assertSame($dir, $path); + $this->assertSame(6, $resource_owner); + $this->assertSame(md5('a.pdf'), $revision_title('a.pdf')); + + return new ResourceCollectionIdentification('dir-rcid'); + }); + $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]); + } + + public function testMissingDirectoryIsSkippedAndReported(): void + { + $this->db->method('query')->willReturn($this->createStub(ilDBStatement::class)); + $this->db->method('queryF')->willReturn($this->createStub(ilDBStatement::class)); + $this->db->method('fetchAssoc')->willReturnOnConsecutiveCalls( + ['path' => '6_104'], + null, + ['mail_id' => 1], + null ); + $this->helper->expects($this->never())->method('moveFilesOfPathToCollection'); + $this->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'); + $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->createMock(CollectionBuilder::class); $collection_builder->method('new')->willReturn($collection); $collection_builder->method('store')->willReturn(true); - $this->helper->method('getCollectionBuilder')->willReturn($collection_builder); $this->helper->expects($this->once()) ->method('movePathToStorage') - ->willReturn(new ResourceIdentification('rid-present')); - - $rcid = $this->invokePrivate('migratePoolFilenamesToCollection', [ - ['present.svg', 'missing.svg'], - 6, - $mail_path, - ]); + ->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->assertInstanceOf(ResourceCollectionIdentification::class, $rcid); - $this->assertSame('migrated-rcid', $rcid->serialize()); + $rcid = $this->invokePrivate( + 'migratePoolFilenamesToCollection', + [['Übung 1.pdf', 'gone.pdf'], 6, $this->tmp_dir . '/mail', 42] + ); - unlink($mail_path . '/6_present.svg'); - rmdir($mail_path); - rmdir($dir); + $this->assertSame('draft-rcid', $rcid->serialize()); + $this->assertFileExists($this->tmp_dir . '/mail/6_Übung 1.pdf'); + $this->assertReported('WARNING', 'gone.pdf'); } - public function testMigratePoolFilenamesToCollectionReturnsNullWhenNoFilesExist(): void + public function testDraftGetsItsCollectionAndAMailAttachmentRow(): void { - $dir = sys_get_temp_dir() . '/mail-migration-unit-empty-' . uniqid('', true); - $mail_path = $dir . '/mail'; - mkdir($mail_path, 0775, true); - + file_put_contents($this->tmp_dir . '/mail/6_exists.pdf', '%PDF'); $collection = $this->createMock(ResourceCollection::class); - $collection->method('count')->willReturn(0); - + $collection->method('count')->willReturn(1); + $collection->method('getIdentification')->willReturn(new ResourceCollectionIdentification('draft-rcid')); $collection_builder = $this->createMock(CollectionBuilder::class); $collection_builder->method('new')->willReturn($collection); - + $collection_builder->method('store')->willReturn(true); $this->helper->method('getCollectionBuilder')->willReturn($collection_builder); - $this->helper->expects($this->never())->method('movePathToStorage'); + $this->helper->method('movePathToStorage')->willReturn(new ResourceIdentification('rid')); + $this->db->expects($this->once())->method('manipulateF')->with( + 'INSERT INTO mail_attachment (mail_id, path, rcid) VALUES (%s, %s, %s)', + $this->anything(), + [42, '', 'draft-rcid'] + ); + $this->db->expects($this->once())->method('update')->with( + 'mail', + ['attachments' => [ilDBConstants::T_CLOB, 'draft-rcid']], + ['mail_id' => [ilDBConstants::T_INTEGER, 42]] + ); - $rcid = $this->invokePrivate('migratePoolFilenamesToCollection', [ - ['missing.svg'], - 6, - $mail_path, - ]); + $this->invokePrivate('migrateSerializedMail', [42, 6, serialize(['exists.pdf']), $this->tmp_dir . '/mail']); + } - $this->assertNull($rcid); + public function testBrokenDraftValueIsClearedAndReported(): void + { + $this->db->expects($this->once())->method('update')->with( + 'mail', + ['attachments' => [ilDBConstants::T_CLOB, '']], + ['mail_id' => [ilDBConstants::T_INTEGER, 43]] + ); - rmdir($mail_path); - rmdir($dir); + $this->invokePrivate('migrateSerializedMail', [43, 6, 'a:1:{broken', $this->tmp_dir . '/mail']); + + $this->assertReported('WARNING', 'could not be read'); } - public function testUpdateMailAttachmentFieldsSkipsNonSerializedAttachments(): void + // --------------------------------------------------------------------------------------------------------- + // Repair, abort, reporting + // --------------------------------------------------------------------------------------------------------- + + public function testRepairRestoresEmptiedAttachmentColumns(): void { - $statement = $this->createMock(ilDBStatement::class); - $this->db->expects($this->once())->method('queryF')->willReturn($statement); - $this->db->method('fetchObject')->willReturnOnConsecutiveCalls( - (object) ['mail_id' => 900011, 'attachments' => '657497dc-5079-4f95-b19d-aecdaf81ff1a'], - null + $this->db->expects($this->once())->method('setLimit')->with(100); + $this->db->method('query')->willReturn($this->createStub(ilDBStatement::class)); + $this->db->method('fetchAssoc')->willReturnOnConsecutiveCalls(['mail_id' => 7, 'rcid' => 'dir-rcid'], null); + $this->db->expects($this->once())->method('update')->with( + 'mail', + ['attachments' => [ilDBConstants::T_CLOB, 'dir-rcid']], + ['mail_id' => [ilDBConstants::T_INTEGER, 7]] ); - $this->db->expects($this->never())->method('update'); - $this->invokePrivate('updateMailAttachmentFields', [ - 'migtest/s11', - new ResourceCollectionIdentification('new-rcid'), - ]); + $this->invokePrivate('repairAttachmentColumnsOfMigratedDirectories', []); + // nothing left: the repair query is not executed again + $this->invokePrivate('repairAttachmentColumnsOfMigratedDirectories', []); } - public function testMigrateSerializedMailAttachmentsSelectsNewestMailsFirst(): void + public function testAbortIsReportedWithItsCauseAndRethrown(): void { - $captured_sql = ''; - $statement = $this->createMock(ilDBStatement::class); - $this->db->method('quote')->willReturnCallback(static fn(string $value): string => "'" . $value . "'"); - $this->db->expects($this->once())->method('setLimit')->with(5); - $this->db->expects($this->once()) - ->method('query') - ->willReturnCallback(function (string $sql) use ($statement, &$captured_sql): ilDBStatement { - $captured_sql = $sql; + $this->db->method('query')->willThrowException(new RuntimeException('MySQL server has gone away')); - return $statement; - }); - $this->db->method('fetchObject')->willReturn(null); - - $this->invokePrivate('migrateSerializedMailAttachments', []); + 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->assertStringContainsString('ORDER BY send_time DESC', $captured_sql); - $this->assertStringNotContainsString('LIMIT', $captured_sql); + $this->assertReported('ERROR', 'MySQL server has gone away'); } - public function testMigrateSentAttachmentDirectoriesSelectsPathsOfNewestMailsFirst(): void + public function testResolveOwnerIdForPathUsesSenderIdAndFallsBackToSystemUser(): void { - $captured_sql = ''; - $statement = $this->createMock(ilDBStatement::class); - $this->db->expects($this->once())->method('setLimit')->with(5); - $this->db->expects($this->once()) - ->method('query') - ->willReturnCallback(function (string $sql) use ($statement, &$captured_sql): ilDBStatement { - $captured_sql = $sql; + $this->db->method('queryF')->willReturn($this->createStub(ilDBStatement::class)); + $this->db->method('fetchAssoc')->willReturnOnConsecutiveCalls(['sender_id' => 42], ['sender_id' => 0]); - return $statement; - }); - $this->db->method('fetchObject')->willReturn(null); + $this->assertSame(42, $this->invokePrivate('resolveOwnerIdForPath', ['6_1'])); + $this->assertSame(SYSTEM_USER_ID, $this->invokePrivate('resolveOwnerIdForPath', ['6_2'])); + } - $this->invokePrivate('migrateSentAttachmentDirectories', []); + // --------------------------------------------------------------------------------------------------------- - $this->assertStringContainsString('ORDER BY MAX(m.send_time) DESC', $captured_sql); - $this->assertStringNotContainsString('LIMIT', $captured_sql); + /** + * @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; } - public function testMarkPathAsSkippedWritesDashMarker(): void + private function assertReported(string $level, string $needle): void { - $this->db->expects($this->once()) - ->method('manipulateF') - ->with( - 'UPDATE mail_attachment SET rcid = %s WHERE path = %s', - [ilDBConstants::T_TEXT, ilDBConstants::T_TEXT], - ['-', 'migtest/missing'] - ); - - $this->invokePrivate('markPathAsSkipped', ['migtest/missing']); + 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))); } /** @@ -231,8 +355,6 @@ public function testMarkPathAsSkippedWritesDashMarker(): void */ private function invokePrivate(string $method, array $arguments): mixed { - $reflection = new ReflectionClass($this->migration); - - return $reflection->getMethod($method)->invoke($this->migration, ...$arguments); + return new ReflectionClass($this->migration)->getMethod($method)->invoke($this->migration, ...$arguments); } } From 52553b56a1f05571f35ba482bb05b5e3e4830765 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Mon, 12 Oct 2026 13:02:11 +0200 Subject: [PATCH 14/43] [Feature] Mail: Mark IRSS attachments as confidential Mail attachments implement ConfidentialStakeholder so new resources are redacted in the resource overview. The migration sets the flag explicitly because the IRSS helper stores resources through the resource builder. --- .../class.ilMailAttachmentStakeholder.php | 8 ++++- .../MigrateMailAttachmentsToIRSS.php | 14 +++++++++ .../MigrateMailAttachmentsToIRSSUnitTest.php | 30 +++++++++++++++++-- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php b/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php index dde2a070e3c2..ea120077e82b 100644 --- a/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php +++ b/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php @@ -20,14 +20,20 @@ use ILIAS\ResourceStorage\Identification\ResourceIdentification; use ILIAS\ResourceStorage\Stakeholder\AbstractResourceStakeholder; +use ILIAS\ResourceStorage\Stakeholder\ConfidentialStakeholder; -class ilMailAttachmentStakeholder extends AbstractResourceStakeholder +class ilMailAttachmentStakeholder extends AbstractResourceStakeholder implements ConfidentialStakeholder { public function getId(): string { return 'mail_attachments'; } + public function areNewResourcesConfidential(): bool + { + return true; + } + public function getOwnerOfNewResources(): int { return $this->default_owner; diff --git a/components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php b/components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php index a7dcfdac3dc7..bd0866d1feac 100644 --- a/components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php +++ b/components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php @@ -141,6 +141,7 @@ private function migrateSentAttachmentDirectories(): void continue; } + $this->markCollectionResourcesConfidential($rcid); $this->assignRcidToPath($relative_path, $rcid); $this->updateMailAttachmentFields($relative_path, $rcid); $this->count('directories_migrated'); @@ -310,6 +311,7 @@ private function migratePoolFilenamesToCollection( ); if ($resource_id instanceof ResourceIdentification) { + $this->markConfidential($resource_id); $collection->add($resource_id); } } @@ -325,6 +327,18 @@ private function migratePoolFilenamesToCollection( 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( diff --git a/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php b/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php index a5d2388914d1..6ac0b19a2dd2 100644 --- a/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php +++ b/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php @@ -24,8 +24,9 @@ use ILIAS\ResourceStorage\Collection\CollectionBuilder; use ILIAS\ResourceStorage\Collection\ResourceCollection; use ILIAS\Mail\Setup\Migration\MigrateMailAttachmentsToIRSS; -use ILIAS\ResourceStorage\Identification\ResourceIdentification; use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; +use ILIAS\ResourceStorage\Identification\ResourceIdentification; +use ILIAS\ResourceStorage\Manager\Manager; class MigrateMailAttachmentsToIRSSUnitTest extends ilMailBaseTestCase { @@ -60,6 +61,7 @@ protected function setUp(): void 'moveFilesOfPathToCollection', 'movePathToStorage', 'getCollectionBuilder', + 'getManager', ]) ->getMock(); $this->helper->method('getDatabase')->willReturn($this->db); @@ -152,6 +154,14 @@ public function testDirectoryIsMigratedWithTheIrssHelper(): void ['mail_id' => 2], null ); + $rcid = new ResourceCollectionIdentification('dir-rcid'); + $resource_id = new ResourceIdentification('dir-rid'); + $collection = $this->createMock(ResourceCollection::class); + $collection->method('getResourceIdentifications')->willReturn([$resource_id]); + $collection_builder = $this->createMock(CollectionBuilder::class); + $collection_builder->expects($this->once())->method('get')->with($rcid)->willReturn($collection); + $this->helper->method('getCollectionBuilder')->willReturn($collection_builder); + $this->expectConfidential($resource_id); $this->helper->expects($this->once()) ->method('moveFilesOfPathToCollection') ->willReturnCallback(function ( @@ -160,12 +170,12 @@ public function testDirectoryIsMigratedWithTheIrssHelper(): void int $collection_owner, ?Closure $file_name, Closure $revision_title - ) use ($dir): ResourceCollectionIdentification { + ) use ($dir, $rcid): ResourceCollectionIdentification { $this->assertSame($dir, $path); $this->assertSame(6, $resource_owner); $this->assertSame(md5('a.pdf'), $revision_title('a.pdf')); - return new ResourceCollectionIdentification('dir-rcid'); + return $rcid; }); $statements = []; $this->db->method('manipulateF')->willReturnCallback( @@ -229,6 +239,7 @@ public function testPoolFilesAreCopiedWithTheirNameWithoutTheUserPrefix(): void return new ResourceIdentification('rid'); }); + $this->expectConfidential(new ResourceIdentification('rid')); $rcid = $this->invokePrivate( 'migratePoolFilenamesToCollection', @@ -251,6 +262,7 @@ public function testDraftGetsItsCollectionAndAMailAttachmentRow(): void $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')); $this->db->expects($this->once())->method('manipulateF')->with( 'INSERT INTO mail_attachment (mail_id, path, rcid) VALUES (%s, %s, %s)', $this->anything(), @@ -312,6 +324,11 @@ public function testAbortIsReportedWithItsCauseAndRethrown(): void $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)); @@ -337,6 +354,13 @@ private function createDirectory(string $relative_path, array $files): string 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) { From dde8682bd67e3287fc22f908efebd407db7f15ae Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Wed, 21 Oct 2026 10:40:41 +0200 Subject: [PATCH 15/43] [Fix] Mail: Resolve the attachment pool user from the sender Build ilFileDataMail from getSender() when a notification attaches files. Test and calendar notifications use that factory, so a changed sender no longer reads the anonymous pool. --- .../Calendar/classes/class.ilCalendarMailNotification.php | 2 +- components/ILIAS/Mail/classes/class.ilMailNotification.php | 5 +++++ .../ILIAS/Test/classes/class.ilTestMailNotification.php | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/components/ILIAS/Calendar/classes/class.ilCalendarMailNotification.php b/components/ILIAS/Calendar/classes/class.ilCalendarMailNotification.php index e10d7e90bb82..3983f4c5def0 100755 --- a/components/ILIAS/Calendar/classes/class.ilCalendarMailNotification.php +++ b/components/ILIAS/Calendar/classes/class.ilCalendarMailNotification.php @@ -461,7 +461,7 @@ protected function addAttachment(): void $ics_filename = 'appointment_' . (new DateTimeImmutable('now'))->format('Ymd_His_u') . '.ics'; - $attachment = new ilFileDataMail($this->getSender()); + $attachment = $this->initFileDataMail(); $rcid = $attachment->createCollectionFromContent( $ics_filename, $export->getExportString() diff --git a/components/ILIAS/Mail/classes/class.ilMailNotification.php b/components/ILIAS/Mail/classes/class.ilMailNotification.php index b7fffae61980..e562d9b44ed4 100755 --- a/components/ILIAS/Mail/classes/class.ilMailNotification.php +++ b/components/ILIAS/Mail/classes/class.ilMailNotification.php @@ -254,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/Test/classes/class.ilTestMailNotification.php b/components/ILIAS/Test/classes/class.ilTestMailNotification.php index 94678e4e01a8..2c088538e564 100755 --- a/components/ILIAS/Test/classes/class.ilTestMailNotification.php +++ b/components/ILIAS/Test/classes/class.ilTestMailNotification.php @@ -104,7 +104,7 @@ public function sendAdvancedNotification($owner_id, $title, $usr_data, $file_nam $attachments = MailAttachments::empty(); if ($file_names !== []) { - $file_data_mail = new ilFileDataMail(ANONYMOUS_USER_ID); + $file_data_mail = $this->initFileDataMail(); $rcid = $file_data_mail->createCollectionFromPoolFilenames($file_names); if ($rcid !== null) { $attachments = MailAttachments::fromIrss($rcid); From 5040bd4b3604d2c2791b7dcb0fbd486a9d200046 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Wed, 21 Oct 2026 10:40:42 +0200 Subject: [PATCH 16/43] [Fix] Mail: Skip IRSS attachments whose bytes cannot be read Read the stream with getContents() instead of a string cast. A failed read used to become an empty attachment for the external recipient. --- .../Transport/class.ilMailMimeTransportBase.php | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/components/ILIAS/Mail/classes/Mime/Transport/class.ilMailMimeTransportBase.php b/components/ILIAS/Mail/classes/Mime/Transport/class.ilMailMimeTransportBase.php index f29817b49dd9..81853ea0e7a0 100755 --- a/components/ILIAS/Mail/classes/Mime/Transport/class.ilMailMimeTransportBase.php +++ b/components/ILIAS/Mail/classes/Mime/Transport/class.ilMailMimeTransportBase.php @@ -220,10 +220,22 @@ private function addResourceAttachment(MailMimeAttachment $attachment): bool return false; } - $stream = $this->irss->consume()->stream($resource_identification)->getStream(); + 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( - (string) $stream, + $bytes, $attachment->getDisplayName() !== '' ? $attachment->getDisplayName() : $resource_identification->serialize(), From cbce0b55a9a9dadc764bf90b726cbc62244e68bc Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Wed, 21 Oct 2026 10:40:43 +0200 Subject: [PATCH 17/43] [Fix] Mail: Index attachment listings by resource id Equal display titles no longer collapse two files into one row, so both download hashes stay available in the UI. --- .../Mail/classes/class.ilFileDataMail.php | 4 +- .../Mail/tests/ilFileDataMailFileNameTest.php | 49 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/components/ILIAS/Mail/classes/class.ilFileDataMail.php b/components/ILIAS/Mail/classes/class.ilFileDataMail.php index 0c13fb8a1cdc..c786e046b92b 100755 --- a/components/ILIAS/Mail/classes/class.ilFileDataMail.php +++ b/components/ILIAS/Mail/classes/class.ilFileDataMail.php @@ -1498,6 +1498,8 @@ public function collectionContainsResources( } /** + * Keyed by resource id. Equal display titles must not collapse two download hashes. + * * @return array */ public function getAttachmentListing(ResourceCollectionIdentification $rcid): array @@ -1512,7 +1514,7 @@ public function getAttachmentListing(ResourceCollectionIdentification $rcid): ar $revision = $this->irss->manage()->getCurrentRevision($resource_identification); $info = $revision->getInformation(); $file_title = $info->getTitle(); - $files[$file_title] = [ + $files[$resource_identification->serialize()] = [ 'md5' => $revision->getTitle(), 'name' => $file_title, 'size' => $info->getSize(), diff --git a/components/ILIAS/Mail/tests/ilFileDataMailFileNameTest.php b/components/ILIAS/Mail/tests/ilFileDataMailFileNameTest.php index 3f55a7477809..9be72e5100fc 100644 --- a/components/ILIAS/Mail/tests/ilFileDataMailFileNameTest.php +++ b/components/ILIAS/Mail/tests/ilFileDataMailFileNameTest.php @@ -23,6 +23,8 @@ use ILIAS\ResourceStorage\Revision\Revision; use ILIAS\ResourceStorage\Information\FileInformation; use ILIAS\ResourceStorage\Identification\ResourceIdentification; +use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; +use ILIAS\ResourceStorage\Resource\Repository\CollectionDBRepository; class ilFileDataMailFileNameTest extends ilMailBaseTestCase { @@ -67,4 +69,51 @@ public function testApplyResourceFileNameIgnoresEmptyName(): void $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('hash-a'); + $revision_b = $this->createStub(Revision::class); + $revision_b->method('getInformation')->willReturn($info_b); + $revision_b->method('getTitle')->willReturn('hash-b'); + + $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); + + $db = $this->createStub(ilDBInterface::class); + $db->method('queryF')->willReturn($this->createStub(ilDBStatement::class)); + $db->method('fetchAssoc')->willReturnOnConsecutiveCalls( + [CollectionDBRepository::R_IDENTIFICATION => 'rid-a'], + [CollectionDBRepository::R_IDENTIFICATION => 'rid-b'], + null + ); + + $reflection = new ReflectionClass(ilFileDataMail::class); + $sut = $reflection->newInstanceWithoutConstructor(); + $reflection->getProperty('irss')->setValue($sut, $irss); + $reflection->getProperty('db')->setValue($sut, $db); + + $listing = $sut->getAttachmentListing(new ResourceCollectionIdentification('rcid')); + + $this->assertSame(['rid-a', 'rid-b'], array_keys($listing)); + $this->assertSame(['hash-a', 'hash-b'], array_column($listing, 'md5')); + $this->assertSame(['report.pdf', 'report.pdf'], array_column($listing, 'name')); + } } From 29991cda1a8b0c6b12ca1ee7de109c0519fac6e8 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Wed, 21 Oct 2026 10:40:44 +0200 Subject: [PATCH 18/43] [Fix] Mail: Ignore non-string legacy attachment filenames unserialize() can return mixed arrays. Drop those entries instead of aborting the migration step with a TypeError. --- .../Mail/src/Attachments/MailAttachments.php | 16 +++++++++++++--- .../ILIAS/Mail/tests/MailAttachmentsTest.php | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/components/ILIAS/Mail/src/Attachments/MailAttachments.php b/components/ILIAS/Mail/src/Attachments/MailAttachments.php index b22f4ec76b9f..1994f60c6e18 100644 --- a/components/ILIAS/Mail/src/Attachments/MailAttachments.php +++ b/components/ILIAS/Mail/src/Attachments/MailAttachments.php @@ -45,15 +45,25 @@ public static function fromIrss(ResourceCollectionIdentification $rcid): self } /** - * @param list $filenames + * 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 { - if ($filenames === []) { + $names = []; + foreach ($filenames as $filename) { + if (is_string($filename)) { + $names[] = $filename; + } + } + + if ($names === []) { return self::empty(); } - return new self(null, array_values($filenames)); + return new self(null, $names); } public static function fromDb(?string $raw): ?self diff --git a/components/ILIAS/Mail/tests/MailAttachmentsTest.php b/components/ILIAS/Mail/tests/MailAttachmentsTest.php index 5aaa2e29f9c2..fcdb6dac25e4 100644 --- a/components/ILIAS/Mail/tests/MailAttachmentsTest.php +++ b/components/ILIAS/Mail/tests/MailAttachmentsTest.php @@ -96,4 +96,21 @@ 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()); + } } From cb81cf66cf235c0f47268dbb03d74d94ea8f6c85 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Wed, 21 Oct 2026 10:40:45 +0200 Subject: [PATCH 19/43] [Test] Mail: Replace expectation-free mocks with stubs PHPUnit 13 notices mocks that never configure an expectation. The new mail tests now use stubs unless they actually assert a call. --- .../FileDataRCHandlingAttachmentsSizeTest.php | 8 +- .../FileDataRCHandlingFormUploadTest.php | 8 +- .../MailDeletionHandlerAttachmentPathTest.php | 2 +- .../MigrateMailAttachmentsToIRSSUnitTest.php | 154 ++++++++++++------ .../ilFileDataMailCollectionReferenceTest.php | 2 +- .../Mail/tests/ilFileDataMailPoolTest.php | 50 +++--- .../tests/ilFileDataMailSourceReleaseTest.php | 38 +++-- .../ilMailFolderGUIAttachmentHashesTest.php | 2 +- 8 files changed, 167 insertions(+), 97 deletions(-) diff --git a/components/ILIAS/Mail/tests/FileDataRCHandlingAttachmentsSizeTest.php b/components/ILIAS/Mail/tests/FileDataRCHandlingAttachmentsSizeTest.php index b456354695b8..6905c7af6b11 100644 --- a/components/ILIAS/Mail/tests/FileDataRCHandlingAttachmentsSizeTest.php +++ b/components/ILIAS/Mail/tests/FileDataRCHandlingAttachmentsSizeTest.php @@ -95,7 +95,7 @@ private function createSubject( $infos = []; $resource_identifications = []; 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']); @@ -125,15 +125,15 @@ private function createSubject( $manager->expects($this->never())->method('find'); } - $storage = $this->createMock(Services::class); + $storage = $this->createStub(Services::class); $storage->method('manage')->willReturn($manager); - $upload_handler = $this->createMock(ilMailFormUploadHandlerGUI::class); + $upload_handler = $this->createStub(ilMailFormUploadHandlerGUI::class); $upload_handler->method('getInfoResult')->willReturnCallback( static fn(string $id): FileInfoResult => $infos[$id] ); - $lng = $this->createMock(ilLanguage::class); + $lng = $this->createStub(ilLanguage::class); $lng->method('txt')->willReturnArgument(0); $this->setGlobalVariable('lng', $lng); diff --git a/components/ILIAS/Mail/tests/FileDataRCHandlingFormUploadTest.php b/components/ILIAS/Mail/tests/FileDataRCHandlingFormUploadTest.php index de9df2425b91..44e3991e3ac4 100644 --- a/components/ILIAS/Mail/tests/FileDataRCHandlingFormUploadTest.php +++ b/components/ILIAS/Mail/tests/FileDataRCHandlingFormUploadTest.php @@ -50,10 +50,10 @@ public function testFormUploadWithoutNewFilesAndWithoutStageReturnsEmpty(): void private function createSubject(): FileDataRCHandlingFormUploadTestSubject { return new FileDataRCHandlingFormUploadTestSubject( - $this->createMock(ilFileDataMail::class), - $this->createMock(ilMailFormUploadHandlerGUI::class), - $this->createMock(ilLanguage::class), - $this->createMock(Services::class) + $this->createStub(ilFileDataMail::class), + $this->createStub(ilMailFormUploadHandlerGUI::class), + $this->createStub(ilLanguage::class), + $this->createStub(Services::class) ); } } diff --git a/components/ILIAS/Mail/tests/MailDeletionHandlerAttachmentPathTest.php b/components/ILIAS/Mail/tests/MailDeletionHandlerAttachmentPathTest.php index 3bb62a53e95d..a07f9c271fe9 100644 --- a/components/ILIAS/Mail/tests/MailDeletionHandlerAttachmentPathTest.php +++ b/components/ILIAS/Mail/tests/MailDeletionHandlerAttachmentPathTest.php @@ -30,7 +30,7 @@ class MailDeletionHandlerAttachmentPathTest extends ilMailBaseTestCase public function testEmptyPathsAreNeverDeletable(): void { $captured_sql = ''; - $db = $this->createMock(ilDBInterface::class); + $db = $this->createStub(ilDBInterface::class); $db->method('prepare')->willReturn($this->createStub(ilDBStatement::class)); $db->method('in')->willReturn('mail_id IN (1,2)'); $db->method('query')->willReturnCallback( diff --git a/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php b/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php index 6ac0b19a2dd2..4c23dd22b542 100644 --- a/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php +++ b/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php @@ -31,8 +31,8 @@ class MigrateMailAttachmentsToIRSSUnitTest extends ilMailBaseTestCase { private MigrateMailAttachmentsToIRSS $migration; - private MockObject&ilResourceStorageMigrationHelper $helper; - private MockObject&ilDBInterface $db; + private ilResourceStorageMigrationHelper $helper; + private ilDBInterface $db; /** @var list */ private array $messages = []; private string $tmp_dir; @@ -49,31 +49,80 @@ protected function setUp(): void mkdir($this->tmp_dir . '/mail', 0775, true); $this->migration = new MigrateMailAttachmentsToIRSS(); - $this->db = $this->createMock(ilDBInterface::class); - $this->db->method('quote')->willReturnCallback(static fn(mixed $value): string => "'" . $value . "'"); - $this->db->method('supportsTransactions')->willReturn(true); - - $this->helper = $this->getMockBuilder(ilResourceStorageMigrationHelper::class) - ->disableOriginalConstructor() - ->onlyMethods([ - 'getDatabase', - 'getClientDataDir', - 'moveFilesOfPathToCollection', - 'movePathToStorage', - 'getCollectionBuilder', - 'getManager', - ]) - ->getMock(); - $this->helper->method('getDatabase')->willReturn($this->db); - $this->helper->method('getClientDataDir')->willReturn($this->tmp_dir); - - $io = $this->createMock(AdminInteraction::class); + $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', + ]; + 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, $this->helper); + $reflection->getProperty('helper')->setValue($this->migration, $helper); $reflection->getProperty('io')->setValue($this->migration, $io); } @@ -98,15 +147,16 @@ protected function tearDown(): void public function testPathsOfTheMostRecentlySentMailsAreMigratedFirst(): void { $captured_sql = ''; - $this->db->expects($this->once())->method('setLimit')->with(25); - $this->db->method('query')->willReturnCallback( + $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); } ); - $this->db->method('fetchAssoc')->willReturnOnConsecutiveCalls(['path' => 'newest'], ['path' => 'older'], null); + $db->method('fetchAssoc')->willReturnOnConsecutiveCalls(['path' => 'newest'], ['path' => 'older'], null); $paths = $this->invokePrivate('nextUnmigratedPaths', []); @@ -121,15 +171,16 @@ function (string $sql) use (&$captured_sql): ilDBStatement { public function testDraftsOfTheMostRecentlySentMailsAreMigratedFirstAndNeverMailsWithDirectory(): void { $captured_sql = ''; - $this->db->expects($this->once())->method('setLimit')->with(50); - $this->db->method('query')->willReturnCallback( + $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); } ); - $this->db->method('fetchAssoc')->willReturn(null); + $db->method('fetchAssoc')->willReturn(null); $this->invokePrivate('migrateSerializedMailAttachments', []); @@ -141,6 +192,7 @@ function (string $sql) use (&$captured_sql): ilDBStatement { 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)'); @@ -156,13 +208,13 @@ public function testDirectoryIsMigratedWithTheIrssHelper(): void ); $rcid = new ResourceCollectionIdentification('dir-rcid'); $resource_id = new ResourceIdentification('dir-rid'); - $collection = $this->createMock(ResourceCollection::class); + $collection = $this->createStub(ResourceCollection::class); $collection->method('getResourceIdentifications')->willReturn([$resource_id]); $collection_builder = $this->createMock(CollectionBuilder::class); $collection_builder->expects($this->once())->method('get')->with($rcid)->willReturn($collection); - $this->helper->method('getCollectionBuilder')->willReturn($collection_builder); + $helper->method('getCollectionBuilder')->willReturn($collection_builder); $this->expectConfidential($resource_id); - $this->helper->expects($this->once()) + $helper->expects($this->once()) ->method('moveFilesOfPathToCollection') ->willReturnCallback(function ( string $path, @@ -193,16 +245,18 @@ function (string $sql, array $types, array $values) use (&$statements): int { public function testMissingDirectoryIsSkippedAndReported(): void { - $this->db->method('query')->willReturn($this->createStub(ilDBStatement::class)); - $this->db->method('queryF')->willReturn($this->createStub(ilDBStatement::class)); - $this->db->method('fetchAssoc')->willReturnOnConsecutiveCalls( + $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 ); - $this->helper->expects($this->never())->method('moveFilesOfPathToCollection'); - $this->db->expects($this->once())->method('manipulateF')->with( + $helper->expects($this->never())->method('moveFilesOfPathToCollection'); + $db->expects($this->once())->method('manipulateF')->with( 'UPDATE mail_attachment SET rcid = %s WHERE path = %s', [ilDBConstants::T_TEXT, ilDBConstants::T_TEXT], ['-', '6_104'] @@ -216,15 +270,16 @@ public function testMissingDirectoryIsSkippedAndReported(): void 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->createMock(CollectionBuilder::class); + $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->expects($this->once()) + $helper->method('getCollectionBuilder')->willReturn($collection_builder); + $helper->expects($this->once()) ->method('movePathToStorage') ->willReturnCallback(function ( string $path, @@ -254,21 +309,22 @@ public function testPoolFilesAreCopiedWithTheirNameWithoutTheUserPrefix(): void public function testDraftGetsItsCollectionAndAMailAttachmentRow(): void { file_put_contents($this->tmp_dir . '/mail/6_exists.pdf', '%PDF'); - $collection = $this->createMock(ResourceCollection::class); + $db = $this->useDatabaseMock(); + $collection = $this->createStub(ResourceCollection::class); $collection->method('count')->willReturn(1); $collection->method('getIdentification')->willReturn(new ResourceCollectionIdentification('draft-rcid')); - $collection_builder = $this->createMock(CollectionBuilder::class); + $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')); - $this->db->expects($this->once())->method('manipulateF')->with( + $db->expects($this->once())->method('manipulateF')->with( 'INSERT INTO mail_attachment (mail_id, path, rcid) VALUES (%s, %s, %s)', $this->anything(), [42, '', 'draft-rcid'] ); - $this->db->expects($this->once())->method('update')->with( + $db->expects($this->once())->method('update')->with( 'mail', ['attachments' => [ilDBConstants::T_CLOB, 'draft-rcid']], ['mail_id' => [ilDBConstants::T_INTEGER, 42]] @@ -279,7 +335,8 @@ public function testDraftGetsItsCollectionAndAMailAttachmentRow(): void public function testBrokenDraftValueIsClearedAndReported(): void { - $this->db->expects($this->once())->method('update')->with( + $db = $this->useDatabaseMock(); + $db->expects($this->once())->method('update')->with( 'mail', ['attachments' => [ilDBConstants::T_CLOB, '']], ['mail_id' => [ilDBConstants::T_INTEGER, 43]] @@ -296,10 +353,11 @@ public function testBrokenDraftValueIsClearedAndReported(): void public function testRepairRestoresEmptiedAttachmentColumns(): void { - $this->db->expects($this->once())->method('setLimit')->with(100); - $this->db->method('query')->willReturn($this->createStub(ilDBStatement::class)); - $this->db->method('fetchAssoc')->willReturnOnConsecutiveCalls(['mail_id' => 7, 'rcid' => 'dir-rcid'], null); - $this->db->expects($this->once())->method('update')->with( + $db = $this->useDatabaseMock(); + $db->expects($this->once())->method('setLimit')->with(100); + $db->method('query')->willReturn($this->createStub(ilDBStatement::class)); + $db->method('fetchAssoc')->willReturnOnConsecutiveCalls(['mail_id' => 7, 'rcid' => 'dir-rcid'], null); + $db->expects($this->once())->method('update')->with( 'mail', ['attachments' => [ilDBConstants::T_CLOB, 'dir-rcid']], ['mail_id' => [ilDBConstants::T_INTEGER, 7]] diff --git a/components/ILIAS/Mail/tests/ilFileDataMailCollectionReferenceTest.php b/components/ILIAS/Mail/tests/ilFileDataMailCollectionReferenceTest.php index 8d1caa3ff3bc..9bbe5ffb777e 100644 --- a/components/ILIAS/Mail/tests/ilFileDataMailCollectionReferenceTest.php +++ b/components/ILIAS/Mail/tests/ilFileDataMailCollectionReferenceTest.php @@ -40,7 +40,7 @@ public function testUserWithoutPoolDoesNotReferenceTheCollection(): void private function isReferenced(?string $pool_rcid, string $rcid): bool { $queries = []; - $db = $this->createMock(ilDBInterface::class); + $db = $this->createStub(ilDBInterface::class); $db->method('queryF')->willReturnCallback( function (string $sql, array $types, array $values) use (&$queries): ilDBStatement { $queries[] = [$sql, $values]; diff --git a/components/ILIAS/Mail/tests/ilFileDataMailPoolTest.php b/components/ILIAS/Mail/tests/ilFileDataMailPoolTest.php index bcc813b81ea6..e8b1ba8092a2 100644 --- a/components/ILIAS/Mail/tests/ilFileDataMailPoolTest.php +++ b/components/ILIAS/Mail/tests/ilFileDataMailPoolTest.php @@ -21,38 +21,29 @@ use ILIAS\ResourceStorage\Manager\Manager; use ILIAS\Mail\Attachments\MailAttachments; use ILIAS\ResourceStorage\Services as IRSS; -use PHPUnit\Framework\MockObject\MockObject; use ILIAS\ResourceStorage\Identification\ResourceIdentification; class ilFileDataMailPoolTest extends ilMailBaseTestCase { - private MockObject&ilFileDataMail $file_data_mail; - - protected function setUp(): void - { - parent::setUp(); - - $this->file_data_mail = $this->getMockBuilder(ilFileDataMail::class) - ->disableOriginalConstructor() - ->onlyMethods(['migrateLegacyPoolFilenameToResource']) - ->getMock(); - } - public function testLegacyPoolItemIdentifier(): void { + $file_data_mail = $this->newFileDataMail(); + $this->assertTrue( - $this->file_data_mail->isLegacyPoolItemIdentifier('legacy:chef-man-cap-svgrepo-com.svg') + $file_data_mail->isLegacyPoolItemIdentifier('legacy:chef-man-cap-svgrepo-com.svg') ); $this->assertFalse( - $this->file_data_mail->isLegacyPoolItemIdentifier('657497dc-5079-4f95-b19d-aecdaf81ff1a') + $file_data_mail->isLegacyPoolItemIdentifier('657497dc-5079-4f95-b19d-aecdaf81ff1a') ); } public function testLegacyPoolFilenameFromIdentifier(): void { + $file_data_mail = $this->newFileDataMail(); + $this->assertSame( 'pepper-svgrepo-com.svg', - $this->file_data_mail->legacyPoolFilenameFromIdentifier('legacy:pepper-svgrepo-com.svg') + $file_data_mail->legacyPoolFilenameFromIdentifier('legacy:pepper-svgrepo-com.svg') ); } @@ -61,18 +52,22 @@ public function testResolvePoolIdentifiersMigratesLegacyEntries(): void $legacy_rid = new ResourceIdentification('legacy-rid'); $irss_rid = new ResourceIdentification('irss-rid'); - $this->file_data_mail + $file_data_mail = $this->getMockBuilder(ilFileDataMail::class) + ->disableOriginalConstructor() + ->onlyMethods(['migrateLegacyPoolFilenameToResource']) + ->getMock(); + $file_data_mail ->expects($this->once()) ->method('migrateLegacyPoolFilenameToResource') ->with('old.svg') ->willReturn($legacy_rid); - $irss = $this->createIrssMock([ + $irss = $this->createIrssStub([ 'irss-rid' => $irss_rid, ]); - $this->injectIrss($irss); + $this->injectIrss($irss, $file_data_mail); - $resolved = $this->file_data_mail->resolvePoolIdentifiersToResources([ + $resolved = $file_data_mail->resolvePoolIdentifiersToResources([ 'legacy:old.svg', 'irss-rid', 'unknown-rid', @@ -123,25 +118,30 @@ public function testMigrateLegacyPoolAttachmentsReturnsNullWhenFilesMissing(): v /** * @param array $known_rids */ - private function createIrssMock(array $known_rids): IRSS&MockObject + private function createIrssStub(array $known_rids): IRSS { - $manage = $this->createMock(Manager::class); + $manage = $this->createStub(Manager::class); $manage->method('find')->willReturnCallback( static function (string $rid) use ($known_rids): ?ResourceIdentification { return $known_rids[$rid] ?? null; } ); - $irss = $this->getMockBuilder(IRSS::class)->disableOriginalConstructor()->getMock(); + $irss = $this->createStub(IRSS::class); $irss->method('manage')->willReturn($manage); return $irss; } - private function injectIrss(IRSS $irss): void + 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($this->file_data_mail, $irss); + $property->setValue($file_data_mail, $irss); } } diff --git a/components/ILIAS/Mail/tests/ilFileDataMailSourceReleaseTest.php b/components/ILIAS/Mail/tests/ilFileDataMailSourceReleaseTest.php index e905dc435e15..24c29e2d6d56 100644 --- a/components/ILIAS/Mail/tests/ilFileDataMailSourceReleaseTest.php +++ b/components/ILIAS/Mail/tests/ilFileDataMailSourceReleaseTest.php @@ -29,15 +29,15 @@ class ilFileDataMailSourceReleaseTest extends ilMailBaseTestCase { - private Manager&MockObject $manage; - private ilDBInterface&MockObject $db; + private Manager $manage; + private ilDBInterface $db; public function testKeepsReferencedCollection(): void { $rcid = new ResourceCollectionIdentification('assigned-rcid'); - $sut = $this->createSut(referenced: true); + $sut = $this->createSut(referenced: true, mock_manager: true); $sut->expects($this->never())->method('removeCollection'); - $this->manage->expects($this->never())->method('remove'); + $this->managerMock()->expects($this->never())->method('remove'); $sut->releaseSourceCollectionAfterDelivery($rcid); } @@ -54,9 +54,9 @@ public function testKeepsUnknownCollection(): void public function testKeepsForeignStakeholderCollection(): void { $rcid = new ResourceCollectionIdentification('exercise-rcid'); - $sut = $this->createSut(stakeholders: [$this->foreignStakeholder()], rids: ['exercise-rid']); + $sut = $this->createSut(stakeholders: [$this->foreignStakeholder()], rids: ['exercise-rid'], mock_manager: true); $sut->expects($this->never())->method('removeCollection'); - $this->manage->expects($this->never())->method('remove'); + $this->managerMock()->expects($this->never())->method('remove'); $sut->releaseSourceCollectionAfterDelivery($rcid); } @@ -64,10 +64,10 @@ public function testKeepsForeignStakeholderCollection(): void public function testRemovesCollectionAndUnsharedResources(): void { $rcid = new ResourceCollectionIdentification('calendar-rcid'); - $sut = $this->createSut(stakeholders: [new ilMailAttachmentStakeholder()], rids: ['ics-rid']); + $sut = $this->createSut(stakeholders: [new ilMailAttachmentStakeholder()], rids: ['ics-rid'], mock_manager: true); $this->db->method('numRows')->willReturn(0); $sut->expects($this->once())->method('removeCollection')->with($rcid); - $this->manage->expects($this->once())->method('remove'); + $this->managerMock()->expects($this->once())->method('remove'); $sut->releaseSourceCollectionAfterDelivery($rcid); } @@ -75,11 +75,11 @@ public function testRemovesCollectionAndUnsharedResources(): void public function testResourcesStillInOtherCollectionsSurvive(): void { $rcid = new ResourceCollectionIdentification('stage-rcid'); - $sut = $this->createSut(stakeholders: [new ilMailAttachmentStakeholder()], rids: ['pool-rid']); + $sut = $this->createSut(stakeholders: [new ilMailAttachmentStakeholder()], rids: ['pool-rid'], mock_manager: true); // The resource is still assigned to e.g. the user's pool $this->db->method('numRows')->willReturn(1); $sut->expects($this->once())->method('removeCollection')->with($rcid); - $this->manage->expects($this->never())->method('remove'); + $this->managerMock()->expects($this->never())->method('remove'); $sut->releaseSourceCollectionAfterDelivery($rcid); } @@ -101,10 +101,13 @@ private function createSut( bool $collection_exists = true, array $stakeholders = [], bool $referenced = false, - array $rids = [] + array $rids = [], + bool $mock_manager = false ): ilFileDataMail&MockObject { - $this->manage = $this->createMock(Manager::class); - $this->db = $this->createMock(ilDBInterface::class); + $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); @@ -141,6 +144,15 @@ private function createSut( 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); diff --git a/components/ILIAS/Mail/tests/ilMailFolderGUIAttachmentHashesTest.php b/components/ILIAS/Mail/tests/ilMailFolderGUIAttachmentHashesTest.php index 462b56b1b5a9..71754faac798 100644 --- a/components/ILIAS/Mail/tests/ilMailFolderGUIAttachmentHashesTest.php +++ b/components/ILIAS/Mail/tests/ilMailFolderGUIAttachmentHashesTest.php @@ -29,7 +29,7 @@ class ilMailFolderGUIAttachmentHashesTest extends ilMailBaseTestCase { public function testIrssAttachmentHashesAreTakenFromTheListing(): void { - $mail_file_data = $this->createMock(ilFileDataMail::class); + $mail_file_data = $this->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' => ''], From 7a5275d8f20987e72c94dd220dbf911010a4b360 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Wed, 21 Oct 2026 12:30:39 +0200 Subject: [PATCH 20/43] [Fix] Mail: Store legacy pool files under their unprefixed name The pool path is {userId}_{name}. Use the name the user knows as the revision title and the display title, and leave the disk file in place. --- .../Mail/classes/class.ilFileDataMail.php | 7 ++- .../Mail/tests/ilFileDataMailFileNameTest.php | 54 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/components/ILIAS/Mail/classes/class.ilFileDataMail.php b/components/ILIAS/Mail/classes/class.ilFileDataMail.php index c786e046b92b..14d80f73a3a4 100755 --- a/components/ILIAS/Mail/classes/class.ilFileDataMail.php +++ b/components/ILIAS/Mail/classes/class.ilFileDataMail.php @@ -354,6 +354,7 @@ public function migrateLegacyPoolFilenameToResource(string $filename): ?Resource } $rid = $this->streamFromPath($path, md5($filename)); + $this->applyResourceFileName($rid, $filename); try { $rid = $this->addResourceToPool($rid); } catch (RuntimeException) { @@ -1107,12 +1108,14 @@ public function adoptPoolFilenamesToCollection( continue; } - $hash = md5(basename($path)); + $hash = md5($filename); if ($this->resourceIdByHashInCollection($collection, $hash) !== null) { continue; } - $collection->add($this->streamFromPath($path, $hash)); + $resource_identification = $this->streamFromPath($path, $hash); + $this->applyResourceFileName($resource_identification, $filename); + $collection->add($resource_identification); $added = true; } diff --git a/components/ILIAS/Mail/tests/ilFileDataMailFileNameTest.php b/components/ILIAS/Mail/tests/ilFileDataMailFileNameTest.php index 9be72e5100fc..6a80c041235e 100644 --- a/components/ILIAS/Mail/tests/ilFileDataMailFileNameTest.php +++ b/components/ILIAS/Mail/tests/ilFileDataMailFileNameTest.php @@ -23,6 +23,8 @@ use ILIAS\ResourceStorage\Revision\Revision; use ILIAS\ResourceStorage\Information\FileInformation; use ILIAS\ResourceStorage\Identification\ResourceIdentification; +use ILIAS\ResourceStorage\Collection\Collections; +use ILIAS\ResourceStorage\Collection\ResourceCollection; use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; use ILIAS\ResourceStorage\Resource\Repository\CollectionDBRepository; @@ -116,4 +118,56 @@ public function testAttachmentListingKeepsEqualDisplayTitles(): void $this->assertSame(['hash-a', 'hash-b'], array_column($listing, 'md5')); $this->assertSame(['report.pdf', 'report.pdf'], array_column($listing, 'name')); } + + 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); + } } From 49151a79cf6d589011f169bd3c192e05df7e8111 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Wed, 21 Oct 2026 12:30:41 +0200 Subject: [PATCH 21/43] [Fix] Mail: Allow owners to open unsent attachment resources Mailbox rows still grant access to recipients. The revision owner and the user's pool or compose stage do as well, so previews of unsent files work again. --- .../Mail/classes/class.ilFileDataMail.php | 2 +- .../class.ilMailAttachmentStakeholder.php | 56 ++++++++++++++++++- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/components/ILIAS/Mail/classes/class.ilFileDataMail.php b/components/ILIAS/Mail/classes/class.ilFileDataMail.php index 14d80f73a3a4..b0e120e9cb93 100755 --- a/components/ILIAS/Mail/classes/class.ilFileDataMail.php +++ b/components/ILIAS/Mail/classes/class.ilFileDataMail.php @@ -32,7 +32,7 @@ class ilFileDataMail extends ilFileData { - private const string POOL_RCID_PREF_KEY = 'mail_attachment_pool_rcid'; + public const string POOL_RCID_PREF_KEY = 'mail_attachment_pool_rcid'; public const string LEGACY_POOL_ITEM_PREFIX = 'legacy:'; public string $mail_path; diff --git a/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php b/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php index ea120077e82b..e276d0e15018 100644 --- a/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php +++ b/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php @@ -19,6 +19,7 @@ declare(strict_types=1); use ILIAS\ResourceStorage\Identification\ResourceIdentification; +use ILIAS\ResourceStorage\Resource\Repository\CollectionDBRepository; use ILIAS\ResourceStorage\Stakeholder\AbstractResourceStakeholder; use ILIAS\ResourceStorage\Stakeholder\ConfidentialStakeholder; @@ -56,16 +57,65 @@ public function canBeAccessedByCurrentUser(ResourceIdentification $identificatio $db = $DIC->database(); $rid = $identification->serialize(); + if ($this->mailboxContainsResource($db, $rid, $user_id)) { + return true; + } + + if ($DIC->isDependencyAvailable('resourceStorage')) { + try { + $owner_id = $DIC->resourceStorage()->manage()->getCurrentRevision($identification)->getOwnerId(); + if ($owner_id === $user_id) { + return true; + } + } catch (Throwable) { + } + } + + return $this->poolOrStageContainsResource($db, $rid, $user_id); + } + + private function mailboxContainsResource(ilDBInterface $db, string $rid, int $user_id): bool + { $db->setLimit(1, 0); $res = $db->queryF( - 'SELECT 1 FROM il_resource_rca rca - INNER JOIN mail_attachment ma ON ma.rcid = rca.rcid + 'SELECT 1 FROM ' . CollectionDBRepository::COLLECTION_ASSIGNMENT_TABLE_NAME . ' rca + INNER JOIN mail_attachment ma ON ma.rcid = rca.' . CollectionDBRepository::C_IDENTIFICATION . ' INNER JOIN mail m ON m.mail_id = ma.mail_id - WHERE rca.rid = %s AND m.user_id = %s', + WHERE rca.' . CollectionDBRepository::R_IDENTIFICATION . ' = %s AND m.user_id = %s', [ilDBConstants::T_TEXT, ilDBConstants::T_INTEGER], [$rid, $user_id] ); return $db->numRows($res) > 0; } + + private function poolOrStageContainsResource(ilDBInterface $db, string $rid, int $user_id): bool + { + $assignment = CollectionDBRepository::COLLECTION_ASSIGNMENT_TABLE_NAME; + $resource_column = CollectionDBRepository::R_IDENTIFICATION; + $collection_column = CollectionDBRepository::C_IDENTIFICATION; + + $db->setLimit(1, 0); + $res = $db->queryF( + 'SELECT 1 FROM usr_pref p + INNER JOIN ' . $assignment . ' rca ON rca.' . $collection_column . ' = p.value + WHERE p.usr_id = %s AND p.keyword = %s AND rca.' . $resource_column . ' = %s', + [ilDBConstants::T_INTEGER, ilDBConstants::T_TEXT, ilDBConstants::T_TEXT], + [$user_id, ilFileDataMail::POOL_RCID_PREF_KEY, $rid] + ); + if ($db->numRows($res) > 0) { + return true; + } + + $db->setLimit(1, 0); + $res = $db->queryF( + 'SELECT 1 FROM mail_saved ms + INNER JOIN ' . $assignment . ' rca ON rca.' . $collection_column . ' = ms.attachments + WHERE ms.user_id = %s AND rca.' . $resource_column . ' = %s', + [ilDBConstants::T_INTEGER, ilDBConstants::T_TEXT], + [$user_id, $rid] + ); + + return $db->numRows($res) > 0; + } } From 7bd9dc53f432194aeb1aad795ec651baa1e86d3d Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Wed, 21 Oct 2026 12:30:43 +0200 Subject: [PATCH 22/43] [Fix] Mail: Attach only the current user's compose resources Accept a resource id from the request only when it is in the user's pool, the compose stage, or a mail-owned upload of that user. --- .../Attachments/FileDataRCHandling.php | 4 +- .../Mail/classes/class.ilFileDataMail.php | 52 +++++++++++++++++++ .../class.ilMailFormUploadHandlerGUI.php | 38 +++++++++++--- .../FileDataRCHandlingAttachmentsSizeTest.php | 1 + 4 files changed, 87 insertions(+), 8 deletions(-) diff --git a/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php b/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php index 0d901ae3eadd..1fa48b627fd2 100644 --- a/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php +++ b/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php @@ -62,7 +62,7 @@ protected function attachmentsFromFormUpload( $resource_identifications = []; foreach ($form_attachment_rids as $attachment) { $found = $this->storage->manage()->find($attachment); - if ($found === null) { + if ($found === null || !$this->fdm->canUseResourceInCompose($found)) { continue; } $resource_identifications[] = $found; @@ -112,7 +112,7 @@ protected function handleAttachments(array $attachments): ResourceCollectionIden continue; } $found = $this->storage->manage()->find($attachment); - if ($found === null) { + if ($found === null || !$this->fdm->canUseResourceInCompose($found)) { throw new Exception("File '" . $info->getName() . "' could not be found in IRSS"); } $resource_identifications[] = $found; diff --git a/components/ILIAS/Mail/classes/class.ilFileDataMail.php b/components/ILIAS/Mail/classes/class.ilFileDataMail.php index b0e120e9cb93..a647ee5d45f1 100755 --- a/components/ILIAS/Mail/classes/class.ilFileDataMail.php +++ b/components/ILIAS/Mail/classes/class.ilFileDataMail.php @@ -286,6 +286,37 @@ private function resolveUserPoolRcid(): ?ResourceCollectionIdentification return $rcid; } + private function resolveStageRcid(): ?ResourceCollectionIdentification + { + $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; + } + + return $stage->rcid(); + } + + private function collectionContainsResource( + ?ResourceCollectionIdentification $rcid, + ResourceIdentification $identification + ): bool { + if ($rcid === null) { + return false; + } + + return in_array($identification->serialize(), $this->getAssignedRidStrings($rcid), true); + } + /** * @return \Generator */ @@ -1023,6 +1054,27 @@ public function getStakeholder(): ilMailAttachmentStakeholder return $this->stakeholder; } + /** + * A compose form may only attach the user's pool, the compose stage, or a file + * this user just stored with the mail stakeholder. + */ + public function canUseResourceInCompose(ResourceIdentification $identification): bool + { + if ($this->collectionContainsResource($this->resolveUserPoolRcid(), $identification) + || $this->collectionContainsResource($this->resolveStageRcid(), $identification)) { + return true; + } + + try { + $revision = $this->irss->manage()->getCurrentRevision($identification); + } catch (Throwable) { + return false; + } + + return $revision->getOwnerId() === $this->user_id + && $this->resourceHasOnlyMailStakeholder($identification); + } + public function streamFromPath(string $absolute_path, ?string $revision_title = null): ResourceIdentification { $stream = Streams::ofResource(fopen($absolute_path, 'rb')); diff --git a/components/ILIAS/Mail/classes/class.ilMailFormUploadHandlerGUI.php b/components/ILIAS/Mail/classes/class.ilMailFormUploadHandlerGUI.php index 5b869170c501..87e9922a1bd1 100644 --- a/components/ILIAS/Mail/classes/class.ilMailFormUploadHandlerGUI.php +++ b/components/ILIAS/Mail/classes/class.ilMailFormUploadHandlerGUI.php @@ -34,6 +34,7 @@ class ilMailFormUploadHandlerGUI extends AbstractCtrlAwareUploadHandler private readonly ilMailAttachmentStakeholder $stakeholder; private readonly ilLanguage $lng; private readonly int $user_id; + private ?ilFileDataMail $mail_files = null; public function __construct() { @@ -69,9 +70,9 @@ protected function getUploadResult(): HandlerResultInterface protected function getRemoveResult(string $identifier): HandlerResultInterface { - $id = $this->storage->manage()->find($identifier); + $id = $this->acceptedComposeResource($identifier); if ($id !== null) { - new ilFileDataMail($this->user_id)->removeResourceIfNotInAnyCollection($id); + $this->mailFiles()->removeResourceIfNotInAnyCollection($id); return new BasicHandlerResult($this->getFileIdentifierParameterName(), HandlerResultInterface::STATUS_OK, $identifier, 'file deleted'); } @@ -80,7 +81,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'); } @@ -93,7 +94,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; } @@ -107,7 +108,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 @@ -117,6 +118,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/tests/FileDataRCHandlingAttachmentsSizeTest.php b/components/ILIAS/Mail/tests/FileDataRCHandlingAttachmentsSizeTest.php index 6905c7af6b11..90fab9884339 100644 --- a/components/ILIAS/Mail/tests/FileDataRCHandlingAttachmentsSizeTest.php +++ b/components/ILIAS/Mail/tests/FileDataRCHandlingAttachmentsSizeTest.php @@ -105,6 +105,7 @@ private function createSubject( $fdm = $this->createMock(ilFileDataMail::class); $fdm->method('getAttachmentsTotalSizeLimit')->willReturn($limit); + $fdm->method('canUseResourceInCompose')->willReturn(true); if ($expect_collection) { $fdm->expects($this->once()) ->method('createCollectionFromResourceIdentifications') From e7b3ff34e9875a157469ae37da9a76f933c4a3b9 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Wed, 21 Oct 2026 12:30:45 +0200 Subject: [PATCH 23/43] [Fix] Mail: Abort delivery when attachment copies cannot be created A failed queue snapshot or delivery copy no longer becomes an empty attachment, and the source collection stays until a copy exists. --- .../ILIAS/Mail/classes/class.ilMail.php | 47 ++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/components/ILIAS/Mail/classes/class.ilMail.php b/components/ILIAS/Mail/classes/class.ilMail.php index 1c0799c24f8f..6454d933a2d3 100755 --- a/components/ILIAS/Mail/classes/class.ilMail.php +++ b/components/ILIAS/Mail/classes/class.ilMail.php @@ -47,6 +47,7 @@ class ilMail private bool $append_installation_signature = false; private bool $append_user_signature = false; private bool $share_attachments = true; + private bool $keep_attachment_source = false; private ?MailAttachments $shared_delivery_attachments = null; private ?string $context_id = null; private array $context_parameters = []; @@ -190,10 +191,18 @@ private function createDeliveryAttachments(MailAttachments $source): MailAttachm } $copied_rcid = $this->mail_file_data->copyCollectionForDelivery($source->rcid()); + if ($copied_rcid === null) { + $this->keep_attachment_source = true; - return $copied_rcid !== null - ? MailAttachments::fromIrss($copied_rcid) - : MailAttachments::empty(); + 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 @@ -831,6 +840,11 @@ private function sendChanneledMails( $recipient_inbox_id = $mbox->getInboxFolder(); $delivery_attachments = $this->getDeliveryAttachments($mail_data->getAttachments()); + if ($this->keep_attachment_source) { + $this->logger->error('Could not copy mail attachments for delivery'); + + break; + } if ($external_attachments === null) { $external_attachments = $delivery_attachments; } @@ -861,7 +875,7 @@ private function sendChanneledMails( ); } - if ($usr_id_to_external_email_addresses_map === []) { + if ($usr_id_to_external_email_addresses_map === [] || $this->keep_attachment_source) { return; } @@ -1181,6 +1195,11 @@ public function enqueue( // The queued mail gets its own collection, so callers may release their stage or draft right away $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')]; + } $this->releaseSourceAttachments($source_attachment); $task_factory = $DIC->backgroundTasks()->taskFactory(); @@ -1238,10 +1257,16 @@ public function sendMail( } $mail_data = $mail_data->withAttachments($normalized['attachments']); $this->shared_delivery_attachments = null; + $this->keep_attachment_source = false; $source_attachments = $mail_data->getAttachments(); try { - $sentbox_attachments = $this->getDeliveryAttachments($mail_data->getAttachments()); + $sentbox_attachments = $this->getDeliveryAttachments($source_attachments); + if ($this->keep_attachment_source) { + $this->logger->error('Could not copy mail attachments for delivery'); + + return [new ilMailError('mail_error_reading_attachment')]; + } $internal_message_id = $this->saveInSentbox( $sentbox_attachments, $mail_data->getTo(), @@ -1295,12 +1320,17 @@ public function sendMail( if (!$this->distributeMail($mail_data)) { $errors['mail_send_error'] = new ilMailError('mail_send_error'); } + if ($this->keep_attachment_source) { + $errors['mail_error_reading_attachment'] = new ilMailError('mail_error_reading_attachment'); + } if (!$this->getSaveInSentbox()) { $this->deleteMails([$internal_message_id]); } } finally { - $this->releaseSourceAttachments($source_attachments); + if (!$this->keep_attachment_source) { + $this->releaseSourceAttachments($source_attachments); + } } if ($this->isSystemMail()) { @@ -1323,8 +1353,11 @@ private function createQueueSnapshot(MailAttachments $attachments): MailAttachme } $snapshot_rcid = $this->mail_file_data->createCollectionReferencingResourcesOf($attachments->rcid()); + if ($snapshot_rcid === null) { + return MailAttachments::empty(); + } - return $snapshot_rcid !== null ? MailAttachments::fromIrss($snapshot_rcid) : MailAttachments::empty(); + return MailAttachments::fromIrss($snapshot_rcid); } private function releaseSourceAttachments(MailAttachments $source): void From 6311c94d840daba1b5a57d7e430e9639e03209bb Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Wed, 21 Oct 2026 12:30:47 +0200 Subject: [PATCH 24/43] [Fix] Mail: Copy attachment directories before marking them migrated The directory is removed only after every file is in the collection and the mail rows point at it. A partial run leaves the files for the next run. --- .../MigrateMailAttachmentsToIRSS.php | 84 ++++++++++++++++--- .../MigrateMailAttachmentsToIRSSUnitTest.php | 62 +++++++++++--- 2 files changed, 126 insertions(+), 20 deletions(-) diff --git a/components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php b/components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php index bd0866d1feac..3a9f61c1ea82 100644 --- a/components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php +++ b/components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php @@ -125,18 +125,20 @@ private function migrateSentAttachmentDirectories(): void continue; } - $owner_id = $this->resolveOwnerIdForPath($relative_path); - $rcid = $this->helper->moveFilesOfPathToCollection( - $absolute_path, - $owner_id, - $owner_id, - null, - // The revision title is the hash Mail uses to identify a single attachment - static fn(string $file_name): string => md5($file_name) - ); + $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->skipPath($relative_path, 'No file of the directory could be stored'); + $this->warning('Directory was not migrated because not every file could be stored', [ + 'path' => $relative_path, + 'files' => count($filenames), + ]); continue; } @@ -144,10 +146,72 @@ private function migrateSentAttachmentDirectories(): void $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); + 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) { + return null; + } + $collection->add($resource_id); + } + + if ($collection->count() !== count($filenames)) { + return null; + } + if (!$this->helper->getCollectionBuilder()->store($collection)) { + return null; + } + + return $collection->getIdentification(); + } + + /** + * @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 */ diff --git a/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php b/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php index 4c23dd22b542..07c7ff77174b 100644 --- a/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php +++ b/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php @@ -208,26 +208,33 @@ public function testDirectoryIsMigratedWithTheIrssHelper(): void ); $rcid = new ResourceCollectionIdentification('dir-rcid'); $resource_id = new ResourceIdentification('dir-rid'); - $collection = $this->createStub(ResourceCollection::class); + $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('moveFilesOfPathToCollection') + ->method('movePathToStorage') ->willReturnCallback(function ( string $path, - int $resource_owner, - int $collection_owner, + int $owner, ?Closure $file_name, - Closure $revision_title - ) use ($dir, $rcid): ResourceCollectionIdentification { - $this->assertSame($dir, $path); - $this->assertSame(6, $resource_owner); + 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 $rcid; + return $resource_id; }); $statements = []; $this->db->method('manipulateF')->willReturnCallback( @@ -241,6 +248,41 @@ function (string $sql, array $types, array $values) use (&$statements): int { $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; + }); + + $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 @@ -255,7 +297,7 @@ public function testMissingDirectoryIsSkippedAndReported(): void ['mail_id' => 1], null ); - $helper->expects($this->never())->method('moveFilesOfPathToCollection'); + $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], From 0bb74fdb276b600122d3d89d26b93901037aab78 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Wed, 21 Oct 2026 15:00:48 +0200 Subject: [PATCH 25/43] [Fix] Mail: Accept only the user's pool resources in the attachment pool - resolvePoolIdentifiersToResources() dropped the pool membership check for IRSS ids, so any known rid could be put into the compose stage and sent - Pool upload handler info and the delete confirmation no longer reveal metadata of resources outside the pool --- .../Mail/classes/class.ilFileDataMail.php | 8 +++++ .../classes/class.ilMailAttachmentGUI.php | 4 +-- .../Mail/tests/ilFileDataMailPoolTest.php | 29 ++++++++++++++++++- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/components/ILIAS/Mail/classes/class.ilFileDataMail.php b/components/ILIAS/Mail/classes/class.ilFileDataMail.php index a647ee5d45f1..32148bfd9a4c 100755 --- a/components/ILIAS/Mail/classes/class.ilFileDataMail.php +++ b/components/ILIAS/Mail/classes/class.ilFileDataMail.php @@ -412,6 +412,9 @@ public function resolvePoolIdentifiersToResources(array $identifiers): array ); } else { $resource_identification = $this->irss->manage()->find($identifier); + if ($resource_identification !== null && !$this->isInUserPool($resource_identification)) { + $resource_identification = null; + } } if ($resource_identification !== null) { @@ -422,6 +425,11 @@ public function resolvePoolIdentifiersToResources(array $identifiers): array return $resource_identifications; } + public function isInUserPool(ResourceIdentification $identification): bool + { + return $this->collectionContainsResource($this->resolveUserPoolRcid(), $identification); + } + /** * @param list $resource_identifications */ diff --git a/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php b/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php index 27d01a81fcca..86a4d5a83929 100644 --- a/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php +++ b/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php @@ -209,7 +209,7 @@ private function confirmDeleteAttachments(): void foreach ($files as $rid_string) { $title = (string) $rid_string; $rid = $this->storage->manage()->find($title); - if ($rid !== null) { + if ($rid !== null && $this->fdm->isInUserPool($rid)) { $title = $this->storage->manage()->getCurrentRevision($rid)->getInformation()->getTitle(); } @@ -407,7 +407,7 @@ protected function getRemoveResult(string $identifier): HandlerResult public function getInfoResult(string $identifier): ?FileInfoResult { $rid = $this->storage->manage()->find($identifier); - if ($rid === null) { + if ($rid === null || !$this->fdm->isInUserPool($rid)) { return new BasicFileInfoResult($this->getFileIdentifierParameterName(), 'unknown', 'unknown', 0, 'unknown'); } diff --git a/components/ILIAS/Mail/tests/ilFileDataMailPoolTest.php b/components/ILIAS/Mail/tests/ilFileDataMailPoolTest.php index e8b1ba8092a2..1b4538e57907 100644 --- a/components/ILIAS/Mail/tests/ilFileDataMailPoolTest.php +++ b/components/ILIAS/Mail/tests/ilFileDataMailPoolTest.php @@ -54,13 +54,18 @@ public function testResolvePoolIdentifiersMigratesLegacyEntries(): void $file_data_mail = $this->getMockBuilder(ilFileDataMail::class) ->disableOriginalConstructor() - ->onlyMethods(['migrateLegacyPoolFilenameToResource']) + ->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, @@ -78,6 +83,28 @@ public function testResolvePoolIdentifiersMigratesLegacyEntries(): void $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( From d376a7f389e7d8599d97e63d2098f298b46281f1 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Wed, 21 Oct 2026 15:00:50 +0200 Subject: [PATCH 26/43] [Fix] Wiki: Pass MailAttachments to change notification mails - ilMailValueObject expects MailAttachments, the empty array caused a TypeError on page changes with subscribers --- components/ILIAS/Wiki/Notification/NotificationGUI.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 ); From b871aaf208f6fb89b5b6bad17038992ae8854649 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Wed, 21 Oct 2026 15:00:52 +0200 Subject: [PATCH 27/43] [Fix] Forum: Attach the post collection only if it contains files - Posts keep an rcid of an empty collection, which made the mail system reject the notification --- .../class.ilForumCronNotificationDataProvider.php | 4 ++++ .../Notification/class.ilForumNotificationDataProvider.php | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/components/ILIAS/Forum/classes/Notification/class.ilForumCronNotificationDataProvider.php b/components/ILIAS/Forum/classes/Notification/class.ilForumCronNotificationDataProvider.php index 47b900239245..7f9a38185eb8 100755 --- a/components/ILIAS/Forum/classes/Notification/class.ilForumCronNotificationDataProvider.php +++ b/components/ILIAS/Forum/classes/Notification/class.ilForumCronNotificationDataProvider.php @@ -118,6 +118,10 @@ private function readAttachments(): void $this->attachments[] = $attachment['name']; } + if ($this->attachments === []) { + return; + } + $post = new ilForumPost($this->getPostId()); $rcid_string = $post->getRCID(); if ($rcid_string === '' || $rcid_string === ilForumPost::NO_RCID) { diff --git a/components/ILIAS/Forum/classes/Notification/class.ilForumNotificationDataProvider.php b/components/ILIAS/Forum/classes/Notification/class.ilForumNotificationDataProvider.php index 092ef784639d..d78e7cf650f3 100755 --- a/components/ILIAS/Forum/classes/Notification/class.ilForumNotificationDataProvider.php +++ b/components/ILIAS/Forum/classes/Notification/class.ilForumNotificationDataProvider.php @@ -332,6 +332,10 @@ private function readAttachments(): void $this->attachments[$attachment['path']] = $attachment['name']; } + if ($this->attachments === []) { + return; + } + $rcid_string = $this->objPost->getRCID(); if ($rcid_string === '' || $rcid_string === ilForumPost::NO_RCID) { return; From 0b331cb16000ed3216cb342fcfd74416daf7af2d Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Thu, 22 Oct 2026 11:30:36 +0200 Subject: [PATCH 28/43] [Fix] Mail: Resolve legacy pool references only for drafts and outbox mails - Release 12 step 3 clears the empty serialized lists of mails without attachments, so the migration no longer visits every mail - The migration and ilMail::fetchMailData() no longer attach files of the owner's pool to sent or received mails without a directory - Forwarding such a mail no longer resolves the names in the forwarding user's pool - The migration requires the Release 12 database steps --- .../Mail/classes/class.ilFileDataMail.php | 5 +-- .../ILIAS/Mail/classes/class.ilMail.php | 11 ++++++ .../Database/V12/MailDBUpdateSteps12.php | 13 +++++++ .../MigrateMailAttachmentsToIRSS.php | 38 ++++++++++++++++--- .../MigrateMailAttachmentsToIRSSUnitTest.php | 24 +++++++++++- 5 files changed, 81 insertions(+), 10 deletions(-) diff --git a/components/ILIAS/Mail/classes/class.ilFileDataMail.php b/components/ILIAS/Mail/classes/class.ilFileDataMail.php index 32148bfd9a4c..7104dd061b21 100755 --- a/components/ILIAS/Mail/classes/class.ilFileDataMail.php +++ b/components/ILIAS/Mail/classes/class.ilFileDataMail.php @@ -1215,10 +1215,9 @@ public function createCollectionFromPaths(array $absolute_paths): ?ResourceColle */ 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)) { - $rcid = $this->createCollectionFromPoolFilenames($filenames); - - return $rcid !== null ? MailAttachments::fromIrss($rcid) : MailAttachments::empty(); + return MailAttachments::empty(); } $resource_identifications = []; diff --git a/components/ILIAS/Mail/classes/class.ilMail.php b/components/ILIAS/Mail/classes/class.ilMail.php index 6454d933a2d3..b82e190dfaae 100755 --- a/components/ILIAS/Mail/classes/class.ilMail.php +++ b/components/ILIAS/Mail/classes/class.ilMail.php @@ -457,6 +457,10 @@ public function fetchMailData(?array $row): ?array 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; @@ -477,6 +481,13 @@ public function fetchMailData(?array $row): ?array return $row; } + 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 { $next_id = $this->db->nextId($this->table_mail); diff --git a/components/ILIAS/Mail/src/Setup/Database/V12/MailDBUpdateSteps12.php b/components/ILIAS/Mail/src/Setup/Database/V12/MailDBUpdateSteps12.php index e72b0f2cd749..19d4e66e2918 100644 --- a/components/ILIAS/Mail/src/Setup/Database/V12/MailDBUpdateSteps12.php +++ b/components/ILIAS/Mail/src/Setup/Database/V12/MailDBUpdateSteps12.php @@ -59,4 +59,17 @@ public function step_2(): void $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 index 3a9f61c1ea82..6c1a13423e73 100644 --- a/components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php +++ b/components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php @@ -27,7 +27,10 @@ use ILIAS\Setup\AdminInteraction; use ilMailAttachmentStakeholder; use ilResourceStorageMigrationHelper; +use ilDatabaseUpdateStepsExecutedObjective; +use ILIAS\Mail\Folder\MailFolderType; use ILIAS\Mail\Attachments\MailAttachments; +use ILIAS\Mail\Setup\Database\V12\MailDBUpdateSteps12; use ILIAS\ResourceStorage\Identification\ResourceIdentification; use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; @@ -62,7 +65,10 @@ public function getDefaultAmountOfStepsPerRun(): int public function getPreconditions(Environment $environment): array { - return ilResourceStorageMigrationHelper::getPreconditions(); + return array_merge( + ilResourceStorageMigrationHelper::getPreconditions(), + [new ilDatabaseUpdateStepsExecutedObjective(new MailDBUpdateSteps12())] + ); } public function prepare(Environment $environment): void @@ -282,8 +288,9 @@ 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 FROM mail m + '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' @@ -295,13 +302,23 @@ private function migrateSerializedMailAttachments(): void (int) $row['mail_id'], (int) $row['user_id'], (string) $row['attachments'], - $mail_path + $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): void - { + 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) { @@ -315,6 +332,17 @@ private function migrateSerializedMail(int $mail_id, int $user_id, string $raw_a 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', [ diff --git a/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php b/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php index 07c7ff77174b..461f20db2d35 100644 --- a/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php +++ b/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php @@ -186,6 +186,7 @@ function (string $sql) use (&$captured_sql): ilDBStatement { $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); } @@ -372,7 +373,26 @@ public function testDraftGetsItsCollectionAndAMailAttachmentRow(): void ['mail_id' => [ilDBConstants::T_INTEGER, 42]] ); - $this->invokePrivate('migrateSerializedMail', [42, 6, serialize(['exists.pdf']), $this->tmp_dir . '/mail']); + $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 @@ -384,7 +404,7 @@ public function testBrokenDraftValueIsClearedAndReported(): void ['mail_id' => [ilDBConstants::T_INTEGER, 43]] ); - $this->invokePrivate('migrateSerializedMail', [43, 6, 'a:1:{broken', $this->tmp_dir . '/mail']); + $this->invokePrivate('migrateSerializedMail', [43, 6, 'a:1:{broken', $this->tmp_dir . '/mail', true]); $this->assertReported('WARNING', 'could not be read'); } From d25c563664275f927f58b44da606fa3d41264f11 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Thu, 22 Oct 2026 11:31:58 +0200 Subject: [PATCH 29/43] [Fix] Mail: Address IRSS attachments by resource instead of file name - Equal file names in one collection produced the same download hash, the second file could not be downloaded on its own - The hashed revision title is still accepted for mails whose column lists legacy filenames - The attachment pool preselects stage files by resource id --- .../Mail/classes/class.ilFileDataMail.php | 22 +++++-- .../classes/class.ilMailAttachmentGUI.php | 8 +-- .../Mail/tests/ilFileDataMailFileNameTest.php | 63 ++++++++++++++++++- 3 files changed, 81 insertions(+), 12 deletions(-) diff --git a/components/ILIAS/Mail/classes/class.ilFileDataMail.php b/components/ILIAS/Mail/classes/class.ilFileDataMail.php index 7104dd061b21..0550cfa72056 100755 --- a/components/ILIAS/Mail/classes/class.ilFileDataMail.php +++ b/components/ILIAS/Mail/classes/class.ilFileDataMail.php @@ -1490,19 +1490,33 @@ public function getResourceIdByHash( 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; } - $revision = $this->irss->manage()->getCurrentRevision($resource_identification); - if ($revision->getTitle() === $hash) { + 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 null; + 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 @@ -1577,7 +1591,7 @@ public function getAttachmentListing(ResourceCollectionIdentification $rcid): ar $info = $revision->getInformation(); $file_title = $info->getTitle(); $files[$resource_identification->serialize()] = [ - 'md5' => $revision->getTitle(), + 'md5' => $this->attachmentHash($resource_identification), 'name' => $file_title, 'size' => $info->getSize(), 'ctime' => $info->getCreationDate()->format('Y-m-d H:i:s'), diff --git a/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php b/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php index 86a4d5a83929..b4bc515ed509 100644 --- a/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php +++ b/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php @@ -296,18 +296,16 @@ private function showAttachmentsCommand(): void $mail_data = $this->umail->retrieveFromStage(); $stage_attachments = $mail_data['attachments'] ?? null; - $adopted_pool_hashes = []; + $adopted_pool_rids = []; if ($stage_attachments instanceof MailAttachments && $stage_attachments->isIrss()) { - foreach ($this->fdm->getAttachmentListing($stage_attachments->rcid()) as $file) { - $adopted_pool_hashes[$file['md5']] = true; - } + $adopted_pool_rids = $this->fdm->getAttachmentListing($stage_attachments->rcid()); } $files = $this->fdm->getUserPoolFilesData(); $records = []; $checked_items = []; foreach ($files as $file) { - if (isset($adopted_pool_hashes[$file['md5']])) { + if (isset($adopted_pool_rids[$file['rid']])) { $checked_items[] = $file['rid']; } diff --git a/components/ILIAS/Mail/tests/ilFileDataMailFileNameTest.php b/components/ILIAS/Mail/tests/ilFileDataMailFileNameTest.php index 6a80c041235e..4c43d66aa9cb 100644 --- a/components/ILIAS/Mail/tests/ilFileDataMailFileNameTest.php +++ b/components/ILIAS/Mail/tests/ilFileDataMailFileNameTest.php @@ -81,10 +81,10 @@ public function testAttachmentListingKeepsEqualDisplayTitles(): void $revision_a = $this->createStub(Revision::class); $revision_a->method('getInformation')->willReturn($info_a); - $revision_a->method('getTitle')->willReturn('hash-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('hash-b'); + $revision_b->method('getTitle')->willReturn(md5('report.pdf')); $manage = $this->createStub(Manager::class); $manage->method('find')->willReturnCallback( @@ -115,10 +115,67 @@ public function testAttachmentListingKeepsEqualDisplayTitles(): void $listing = $sut->getAttachmentListing(new ResourceCollectionIdentification('rcid')); $this->assertSame(['rid-a', 'rid-b'], array_keys($listing)); - $this->assertSame(['hash-a', 'hash-b'], array_column($listing, 'md5')); + $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); + + $collections = $this->createStub(Collections::class); + $collections->method('exists')->willReturn(true); + + $irss = $this->createStub(IRSS::class); + $irss->method('manage')->willReturn($manage); + $irss->method('collection')->willReturn($collections); + + $rows = array_map( + static fn(string $rid): array => [CollectionDBRepository::R_IDENTIFICATION => $rid], + $rids + ); + $db = $this->createStub(ilDBInterface::class); + $db->method('queryF')->willReturn($this->createStub(ilDBStatement::class)); + $db->method('fetchAssoc')->willReturnOnConsecutiveCalls(...[...$rows, null]); + + $reflection = new ReflectionClass(ilFileDataMail::class); + $sut = $reflection->newInstanceWithoutConstructor(); + $reflection->getProperty('irss')->setValue($sut, $irss); + $reflection->getProperty('db')->setValue($sut, $db); + + return $sut; + } + public function testAdoptedPoolFileKeepsTheNameWithoutTheUserPrefix(): void { $dir = sys_get_temp_dir() . '/mail-pool-name-' . uniqid('', true); From c2ec27e295bbd442a69257b2ddd7b20b5bdd9ef0 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Thu, 22 Oct 2026 11:33:12 +0200 Subject: [PATCH 30/43] [Fix] Mail: Edit only own drafts and scheduled mails - The compose form stored a draft or outbox id from the request without checking the owner - updateDraft() changed any mail by id and released its attachment collection - scheduledMail() overwrote a foreign outbox mail instead of creating a new one --- .../ILIAS/Mail/classes/class.ilMail.php | 19 ++++++++++++++-- .../Mail/classes/class.ilMailFormGUI.php | 22 +++++++++++++++++-- components/ILIAS/Mail/tests/ilMailTest.php | 16 ++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/components/ILIAS/Mail/classes/class.ilMail.php b/components/ILIAS/Mail/classes/class.ilMail.php index b82e190dfaae..d1ab8c9ff6eb 100755 --- a/components/ILIAS/Mail/classes/class.ilMail.php +++ b/components/ILIAS/Mail/classes/class.ilMail.php @@ -481,6 +481,17 @@ public function fetchMailData(?array $row): ?array 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); @@ -515,6 +526,10 @@ 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, [ @@ -579,12 +594,12 @@ 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], ]); diff --git a/components/ILIAS/Mail/classes/class.ilMailFormGUI.php b/components/ILIAS/Mail/classes/class.ilMailFormGUI.php index eba0d2a9497b..52a9352f2000 100755 --- a/components/ILIAS/Mail/classes/class.ilMailFormGUI.php +++ b/components/ILIAS/Mail/classes/class.ilMailFormGUI.php @@ -725,8 +725,8 @@ 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 ($mail_data['attachments'] instanceof MailAttachments && !$mail_data['attachments']->isEmpty()) { $stage_attachments = $this->stageAttachmentsFromMailAttachments($mail_data['attachments']); @@ -752,8 +752,8 @@ 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']); @@ -938,6 +938,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( diff --git a/components/ILIAS/Mail/tests/ilMailTest.php b/components/ILIAS/Mail/tests/ilMailTest.php index 319d2a2377f6..2bb856e37bd7 100755 --- a/components/ILIAS/Mail/tests/ilMailTest.php +++ b/components/ILIAS/Mail/tests/ilMailTest.php @@ -462,6 +462,8 @@ 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' => [ilDBConstants::T_CLOB, serialize([])], @@ -500,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; From 86cc6e5db1746d5c006444fe8628427328b8a8a5 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Thu, 22 Oct 2026 11:34:17 +0200 Subject: [PATCH 31/43] [Docs] Mail: Describe IRSS based attachments - Replace the removed pool file API in the examples - Explain which collections the mail system copies, releases or leaves untouched - Add AttachResource() to the ilMimeMail example --- components/ILIAS/Mail/README.md | 77 ++++++++++++++++++++++++--------- 1 file changed, 56 insertions(+), 21 deletions(-) diff --git a/components/ILIAS/Mail/README.md b/components/ILIAS/Mail/README.md index 101b094c31e1..bde089948871 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(); @@ -360,24 +363,41 @@ 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 is released + as soon as the message has been queued (in cron context: delivered). 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`. ```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 +405,26 @@ $mail->enqueue( $bc, $subject, $message, - MailAttachments::fromLegacyFilenames([ - $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 +476,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)) From 993da3f173cd0ed0f7a95a2a1755aecd17edfdb7 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Thu, 22 Oct 2026 13:07:49 +0200 Subject: [PATCH 32/43] [Refactoring] Mail: Keep every attachment resource in exactly one collection - Opening a draft, scheduled or forwarded mail, queueing and choosing pool files copy the files instead of sharing resources between collections - Compose uploads are added to the stage directly, removing a file in the form deletes it from the stage - Releasing an unreferenced collection removes its resources through the IRSS, sent mails and their received copies keep sharing one collection - No direct queries on the IRSS tables anymore, the stakeholder checks the user's collections through the IRSS API --- components/ILIAS/Mail/README.md | 11 +- .../Attachments/FileDataRCHandling.php | 79 +--- .../Mail/classes/class.ilFileDataMail.php | 400 ++++++++---------- .../ILIAS/Mail/classes/class.ilMail.php | 12 +- .../classes/class.ilMailAttachmentGUI.php | 12 +- .../class.ilMailAttachmentStakeholder.php | 119 ++++-- .../Mail/classes/class.ilMailFormGUI.php | 22 +- .../class.ilMailFormUploadHandlerGUI.php | 7 +- .../FileDataRCHandlingAttachmentsSizeTest.php | 41 +- .../FileDataRCHandlingFormUploadTest.php | 41 +- .../Mail/tests/ilFileDataMailFileNameTest.php | 46 +- .../tests/ilFileDataMailSourceReleaseTest.php | 20 +- .../Mail/tests/ilFileDataMailStageTest.php | 136 ++++++ .../ILIAS/Mail/tests/ilMailCopyOnSendTest.php | 45 +- 14 files changed, 518 insertions(+), 473 deletions(-) create mode 100644 components/ILIAS/Mail/tests/ilFileDataMailStageTest.php diff --git a/components/ILIAS/Mail/README.md b/components/ILIAS/Mail/README.md index bde089948871..e191a53cb3ae 100755 --- a/components/ILIAS/Mail/README.md +++ b/components/ILIAS/Mail/README.md @@ -380,8 +380,9 @@ There are two ways to attach files: 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 is released - as soon as the message has been queued (in cron context: delivered). It MUST NOT be passed + 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. @@ -389,6 +390,12 @@ 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 use ILIAS\Mail\Attachments\MailAttachments; diff --git a/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php b/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php index 1fa48b627fd2..714b26bd6e6a 100644 --- a/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php +++ b/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php @@ -20,7 +20,6 @@ use ILIAS\Mail\Attachments\MailAttachments; use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; -use ILIAS\ResourceStorage\Identification\ResourceIdentification; trait FileDataRCHandling { @@ -33,14 +32,14 @@ public function FilesFromIRSSToLegacy(ResourceCollectionIdentification $identifi } /** + * 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 */ - protected function attachmentsFromFormUpload( - array $form_attachment_rids, - ?MailAttachments $stage_attachments = null - ): MailAttachments { - // The submitted form is the single source of truth: no files in the form means no attachments + protected function attachmentsFromFormUpload(array $form_attachment_rids): MailAttachments + { if ($form_attachment_rids === []) { return MailAttachments::empty(); } @@ -59,72 +58,16 @@ protected function attachmentsFromFormUpload( ); } - $resource_identifications = []; - foreach ($form_attachment_rids as $attachment) { - $found = $this->storage->manage()->find($attachment); - if ($found === null || !$this->fdm->canUseResourceInCompose($found)) { - continue; - } - $resource_identifications[] = $found; - } - - if ($resource_identifications === []) { - return MailAttachments::empty(); - } - - $stage_rcid = ($stage_attachments instanceof MailAttachments && $stage_attachments->isIrss()) - ? $stage_attachments->rcid() - : null; - - if ($stage_rcid !== null && $this->fdm->collectionContainsResources($stage_rcid, $resource_identifications)) { - return MailAttachments::fromIrss($stage_rcid); - } + $stage_rcid = $this->fdm->restrictStageTo($form_attachment_rids); - return MailAttachments::fromIrss( - $this->fdm->createCollectionFromResourceIdentifications($resource_identifications) - ); + return $stage_rcid !== null + ? MailAttachments::fromIrss($stage_rcid) + : MailAttachments::empty(); } /** - * @param array $attachments - * @throws ilMailAttachmentsTotalSizeLimitExceededException + * Drafts, scheduled and forwarded mails keep their collection, the compose stage works on a copy */ - protected function handleAttachments(array $attachments): ResourceCollectionIdentification - { - $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) - ); - } - - $resource_identifications = []; - foreach ($attachments as $attachment) { - $info = $this->upload_handler->getInfoResult($attachment); - if ($info->getFileIdentifier() === 'unknown') { - continue; - } - $found = $this->storage->manage()->find($attachment); - if ($found === null || !$this->fdm->canUseResourceInCompose($found)) { - throw new Exception("File '" . $info->getName() . "' could not be found in IRSS"); - } - $resource_identifications[] = $found; - } - - if ($resource_identifications === []) { - throw new Exception('No attachments could be stored'); - } - - return $this->fdm->createCollectionFromResourceIdentifications($resource_identifications); - } - protected function stageAttachmentsFromMailAttachments(MailAttachments $attachments): MailAttachments { if ($attachments->isEmpty()) { @@ -132,7 +75,7 @@ protected function stageAttachmentsFromMailAttachments(MailAttachments $attachme } if ($attachments->isIrss()) { - $stage_rcid = $this->fdm->createCollectionReferencingResourcesOf($attachments->rcid()); + $stage_rcid = $this->fdm->copyCollection($attachments->rcid()); return $stage_rcid !== null ? MailAttachments::fromIrss($stage_rcid) diff --git a/components/ILIAS/Mail/classes/class.ilFileDataMail.php b/components/ILIAS/Mail/classes/class.ilFileDataMail.php index 0550cfa72056..534659b47a83 100755 --- a/components/ILIAS/Mail/classes/class.ilFileDataMail.php +++ b/components/ILIAS/Mail/classes/class.ilFileDataMail.php @@ -27,9 +27,13 @@ use ILIAS\ResourceStorage\Collection\ResourceCollection; use ILIAS\ResourceStorage\Information\FileInformation; use ILIAS\ResourceStorage\Identification\ResourceIdentification; -use ILIAS\ResourceStorage\Resource\Repository\CollectionDBRepository; 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'; @@ -248,7 +252,7 @@ private function addResourceToPool(ResourceIdentification $rid): ResourceIdentif $updated_rcid = $this->adoptPoolResourcesToCollection($pool_rcid, [$rid]); if ($updated_rcid === null) { - $this->removeResourceIfUnreferenced($rid); + $this->deleteResource($rid); throw new RuntimeException('Could not store mail attachment in pool.'); } @@ -281,8 +285,6 @@ private function resolveUserPoolRcid(): ?ResourceCollectionIdentification return null; } - $this->repairCollectionHeaderIfNeeded($rcid); - return $rcid; } @@ -322,8 +324,6 @@ private function collectionContainsResource( */ private function iteratePoolResourceIdentifications(ResourceCollectionIdentification $pool_rcid): Generator { - $this->repairCollectionHeaderIfNeeded($pool_rcid); - foreach ($this->getAssignedRidStrings($pool_rcid) as $rid_string) { $resource_identification = $this->irss->manage()->find($rid_string); if ($resource_identification !== null) { @@ -478,7 +478,7 @@ public function removeFromPool(ResourceIdentification $rid): void $collection->remove($rid); $this->irss->collection()->store($collection); - $this->removeResourceIfUnreferenced($rid); + $this->deleteResource($rid); } public function poolResourceHash(ResourceIdentification $rid): string @@ -539,8 +539,7 @@ public function isCollectionReferenced(ResourceCollectionIdentification $rcid): } /** - * Removes a Mail collection nobody references anymore. Its resources are only deleted if - * no other collection (pool, stage, other mails) still contains them. + * 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 @@ -554,55 +553,25 @@ public function releaseCollectionIfUnreferenced(ResourceCollectionIdentification return; } - $resource_identifications = []; foreach ($this->getRidsFromCollection($rcid) as $rid_string) { $resource_identification = $this->irss->manage()->find($rid_string); - if ($resource_identification === null) { - continue; - } - - if (!$this->resourceHasOnlyMailStakeholder($resource_identification)) { + if ($resource_identification !== null + && !$this->resourceHasOnlyMailStakeholder($resource_identification)) { return; } - - $resource_identifications[] = $resource_identification; } - $this->removeCollection($rcid); - - foreach ($resource_identifications as $resource_identification) { - $this->removeResourceIfUnreferenced($resource_identification); - } + $this->removeCollection($rcid, true); } - public function removeResourceIfNotInAnyCollection(ResourceIdentification $rid): void + private function deleteResource(ResourceIdentification $rid): void { - $this->removeResourceIfUnreferenced($rid); - } - - private function removeResourceIfUnreferenced(ResourceIdentification $rid): void - { - $this->db->setLimit(1, 0); - $res = $this->db->queryF( - 'SELECT 1 FROM ' . CollectionDBRepository::COLLECTION_ASSIGNMENT_TABLE_NAME . - ' WHERE ' . CollectionDBRepository::R_IDENTIFICATION . ' = %s', - [ilDBConstants::T_TEXT], - [$rid->serialize()] - ); - if ($this->db->numRows($res) > 0) { - return; - } - - if (!$this->resourceHasOnlyMailStakeholder($rid)) { - return; - } - $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, nobody references it and none of its resources is shared. + * it is Mail-owned and nobody references it. */ public function isCollectionExclusivelyOwnedByMail(ResourceCollectionIdentification $rcid): bool { @@ -617,9 +586,8 @@ public function isCollectionExclusivelyOwnedByMail(ResourceCollectionIdentificat foreach ($rid_strings as $rid_string) { $resource_identification = $this->irss->manage()->find($rid_string); - if ($resource_identification === null || - !$this->resourceHasOnlyMailStakeholder($resource_identification) || - $this->isResourceReferencedOutsideCollection($resource_identification, $rcid)) { + if ($resource_identification === null + || !$this->resourceHasOnlyMailStakeholder($resource_identification)) { return false; } } @@ -627,25 +595,6 @@ public function isCollectionExclusivelyOwnedByMail(ResourceCollectionIdentificat return true; } - private function isResourceReferencedOutsideCollection( - ResourceIdentification $rid, - ResourceCollectionIdentification $collection_identification - ): bool { - $res = $this->db->queryF( - 'SELECT rcid FROM il_resource_rca WHERE rid = %s', - [ilDBConstants::T_TEXT], - [$rid->serialize()] - ); - - while ($row = $this->db->fetchObject($res)) { - if ($row->rcid !== $collection_identification->serialize()) { - return true; - } - } - - return false; - } - public function unlinkFile(string $a_filename): bool { if (!$this->isValidAttachmentPoolFilename($a_filename)) { @@ -1063,24 +1012,75 @@ public function getStakeholder(): ilMailAttachmentStakeholder } /** - * A compose form may only attach the user's pool, the compose stage, or a file - * this user just stored with the mail stakeholder. + * The compose form only shows files of the user's compose stage */ public function canUseResourceInCompose(ResourceIdentification $identification): bool { - if ($this->collectionContainsResource($this->resolveUserPoolRcid(), $identification) - || $this->collectionContainsResource($this->resolveStageRcid(), $identification)) { - return true; - } + return $this->collectionContainsResource($this->resolveStageRcid(), $identification); + } - try { - $revision = $this->irss->manage()->getCurrentRevision($identification); - } catch (Throwable) { + /** + * 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; } - return $revision->getOwnerId() === $this->user_id - && $this->resourceHasOnlyMailStakeholder($identification); + $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 @@ -1104,9 +1104,9 @@ public function uploadToIrss(UploadResult $result): ResourceIdentification } /** - * @param list $resource_identifications + * @param list $resource_identifications Resources not yet part of any collection */ - public function createCollectionFromResourceIdentifications( + private function createCollectionFromResourceIdentifications( array $resource_identifications ): ResourceCollectionIdentification { $rcid = $this->irss->collection()->id(null, $this->user_id); @@ -1229,7 +1229,7 @@ public function createStageFromLegacyMailAttachments(int $mail_id, array $filena } if (isset($file['rcid']) && $file['rcid'] instanceof ResourceCollectionIdentification) { - $rcid = $this->createCollectionReferencingResourcesOf($file['rcid']); + $rcid = $this->copyCollection($file['rcid']); return $rcid !== null ? MailAttachments::fromIrss($rcid) : MailAttachments::empty(); } @@ -1273,139 +1273,125 @@ public function createCollectionFromContent(string $name, string $content): ?Res } /** - * Creates a new collection header for the resources of the passed collection. - * Mail-owned resources are shared (no file copy), foreign resources are copied. + * 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 createCollectionReferencingResourcesOf( - ResourceCollectionIdentification $source - ): ?ResourceCollectionIdentification { + public function copyCollection(ResourceCollectionIdentification $source): ?ResourceCollectionIdentification + { if (!$this->collectionIsKnown($source)) { return null; } - $mail_resources = []; - $foreign_resources = []; - foreach ($this->getRidsFromCollection($source) as $rid_string) { - $resource_identification = $this->irss->manage()->find($rid_string); - if ($resource_identification === null) { - continue; - } - - if ($this->resourceHasOnlyMailStakeholder($resource_identification)) { - $mail_resources[] = $resource_identification; - } else { - $foreign_resources[] = $resource_identification; - } - } - - if ($foreign_resources !== []) { - $copied_rcid = $this->createCollectionFromForeignResources($foreign_resources); - if ($copied_rcid !== null) { - foreach ($this->getRidsFromCollection($copied_rcid) as $rid_string) { - $copied = $this->irss->manage()->find($rid_string); - if ($copied !== null) { - $mail_resources[] = $copied; - } - } - $this->removeCollection($copied_rcid); - } - } - - if ($mail_resources === []) { + $copies = $this->copyResources(array_values($this->getCollection($source)->getResourceIdentifications())); + if ($copies === []) { return null; } - return $this->createCollectionFromResourceIdentifications($mail_resources); + return $this->createCollectionFromResourceIdentifications($copies); } /** - * Builds the stage collection after a selection in the attachment pool: Non-pool attachments of the - * current stage are kept, pool files are exactly the selected ones. + * 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 createStageCollectionWithPoolSelection( - ?ResourceCollectionIdentification $stage_rcid, - array $selected_pool_resources - ): ?ResourceCollectionIdentification { - $pool_rids = []; - $pool_rcid = $this->resolveUserPoolRcid(); - if ($pool_rcid !== null) { - foreach ($this->getAssignedRidStrings($pool_rcid) as $rid_string) { - $pool_rids[$rid_string] = true; - } + public function applyPoolSelectionToStage(array $selected_pool_resources): ?ResourceCollectionIdentification + { + $pool_hashes = []; + foreach ($this->getUserPoolResources() as $pool_resource) { + $pool_hashes[$this->poolResourceHash($pool_resource)] = true; } - $resources = []; - $hashes = []; - if ($stage_rcid !== null && $this->collectionIsKnown($stage_rcid)) { - foreach ($this->getRidsFromCollection($stage_rcid) as $rid_string) { - if (isset($pool_rids[$rid_string])) { - continue; - } - $resource_identification = $this->irss->manage()->find($rid_string); - if ($resource_identification !== null) { - $resources[$rid_string] = $resource_identification; - $hashes[$this->poolResourceHash($resource_identification)] = true; - } - } + $selected = []; + foreach ($selected_pool_resources as $pool_resource) { + $selected[$this->poolResourceHash($pool_resource)] = $pool_resource; } - foreach ($selected_pool_resources as $resource_identification) { - $hash = $this->poolResourceHash($resource_identification); - if (isset($hashes[$hash])) { + $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; } - $resources[$resource_identification->serialize()] = $resource_identification; - $hashes[$hash] = true; + unset($selected[$hash]); } - if ($resources === []) { - return null; + foreach ($this->copyResources(array_values($selected)) as $copy) { + $collection->add($copy); + } + + $this->irss->collection()->store($collection); + foreach ($removed as $identification) { + $this->deleteResource($identification); } - return $this->createCollectionFromResourceIdentifications(array_values($resources)); + return $collection->count() > 0 ? $collection->getIdentification() : null; } - public function copyCollectionForDelivery( - ResourceCollectionIdentification $source - ): ?ResourceCollectionIdentification { - if (!$this->collectionIsKnown($source)) { - return 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; + } } - $resource_identifications = iterator_to_array( - $this->getCollection($source)->getResourceIdentifications(), - false - ); + $pool_resources = []; + foreach ($this->getUserPoolResources() as $pool_resource) { + if (isset($stage_hashes[$this->poolResourceHash($pool_resource)])) { + $pool_resources[] = $pool_resource->serialize(); + } + } - return $this->createCollectionFromForeignResources($resource_identifications); + return $pool_resources; } /** - * @param list $resource_identifications + * @return list */ - public function createCollectionFromForeignResources( - array $resource_identifications - ): ?ResourceCollectionIdentification { - if ($resource_identifications === []) { - return null; + private function getUserPoolResources(): array + { + $pool_rcid = $this->resolveUserPoolRcid(); + if ($pool_rcid === null) { + return []; } - $mail_resources = []; + 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); - $copied = $this->irss->manage()->stream( + $copy = $this->irss->manage()->stream( $stream->getStream(), $this->stakeholder, $revision->getTitle() ); - $this->applyResourceFileName($copied, $revision->getInformation()->getTitle()); - $mail_resources[] = $copied; + $this->applyResourceFileName($copy, $revision->getInformation()->getTitle()); + $copies[] = $copy; } - return $this->createCollectionFromResourceIdentifications($mail_resources); + return $copies; } public function getCollection(ResourceCollectionIdentification $rcid): ResourceCollection @@ -1416,7 +1402,6 @@ public function getCollection(ResourceCollectionIdentification $rcid): ResourceC ); } - $this->repairCollectionHeaderIfNeeded($rcid); $this->irss->preloadCollections([$rcid->serialize()]); return $this->irss->collection()->get($rcid, $this->user_id); @@ -1425,7 +1410,6 @@ public function getCollection(ResourceCollectionIdentification $rcid): ResourceC private function loadPoolCollectionForMutation(?ResourceCollectionIdentification $pool_rcid): ResourceCollection { if ($pool_rcid !== null && $this->collectionIsKnown($pool_rcid)) { - $this->repairCollectionHeaderIfNeeded($pool_rcid); $this->irss->preloadCollections([$pool_rcid->serialize()]); return $this->irss->collection()->get($pool_rcid, $this->user_id); @@ -1441,45 +1425,19 @@ private function loadPoolCollectionForMutation(?ResourceCollectionIdentification */ private function getAssignedRidStrings(ResourceCollectionIdentification $rcid): array { - $res = $this->db->queryF( - 'SELECT ' . CollectionDBRepository::R_IDENTIFICATION . - ' FROM ' . CollectionDBRepository::COLLECTION_ASSIGNMENT_TABLE_NAME . - ' WHERE ' . CollectionDBRepository::C_IDENTIFICATION . ' = %s ORDER BY position ASC', - [ilDBConstants::T_TEXT], - [$rcid->serialize()] - ); - - $rids = []; - while ($row = $this->db->fetchAssoc($res)) { - $rids[] = (string) $row[CollectionDBRepository::R_IDENTIFICATION]; + if (!$this->collectionIsKnown($rcid)) { + return []; } - return $rids; + 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()) || - $this->getAssignedRidStrings($rcid) !== []; - } - - private function repairCollectionHeaderIfNeeded(ResourceCollectionIdentification $rcid): void - { - if ($this->irss->collection()->exists($rcid->serialize()) || - $this->getAssignedRidStrings($rcid) === []) { - return; - } - - $this->db->replace( - CollectionDBRepository::COLLECTION_TABLE_NAME, - [ - CollectionDBRepository::C_IDENTIFICATION => [ilDBConstants::T_TEXT, $rcid->serialize()], - ], - [ - 'title' => [ilDBConstants::T_TEXT, ''], - 'owner_id' => [ilDBConstants::T_INTEGER, $this->user_id], - ] - ); + return $this->irss->collection()->exists($rcid->serialize()); } public function getResourceIdByHash( @@ -1546,33 +1504,6 @@ public function getRidsFromCollection(ResourceCollectionIdentification $rcid): a return $rids; } - /** - * @param list $resource_identifications - */ - public function collectionContainsResources( - ResourceCollectionIdentification $rcid, - array $resource_identifications - ): bool { - if ($resource_identifications === []) { - return true; - } - - $collection_serials = []; - foreach ($this->getCollection($rcid)->getResourceIdentifications() as $resource_identification) { - $collection_serials[] = $resource_identification->serialize(); - } - - $expected_serials = []; - foreach ($resource_identifications as $resource_identification) { - $expected_serials[] = $resource_identification->serialize(); - } - - sort($collection_serials); - sort($expected_serials); - - return $collection_serials === $expected_serials; - } - /** * Keyed by resource id. Equal display titles must not collapse two download hashes. * @@ -1622,12 +1553,11 @@ public function deliverCollectionAsZip(ResourceCollectionIdentification $rcid, s } /** - * Removes the collection header and its assignments. Resources are only deleted if - * explicitly requested; prefer releaseCollectionIfUnreferenced() which respects shared resources. + * 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 { - $this->repairCollectionHeaderIfNeeded($rcid); if (!$this->irss->collection()->exists($rcid->serialize())) { return; } @@ -1642,7 +1572,7 @@ public function removeCollection(ResourceCollectionIdentification $rcid, bool $d /** * 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. Shared resources survive. + * by mails or a compose stage stay untouched. */ public function releaseSourceCollectionAfterDelivery(ResourceCollectionIdentification $rcid): void { diff --git a/components/ILIAS/Mail/classes/class.ilMail.php b/components/ILIAS/Mail/classes/class.ilMail.php index d1ab8c9ff6eb..405cef4fdf8e 100755 --- a/components/ILIAS/Mail/classes/class.ilMail.php +++ b/components/ILIAS/Mail/classes/class.ilMail.php @@ -190,7 +190,7 @@ private function createDeliveryAttachments(MailAttachments $source): MailAttachm return $source; } - $copied_rcid = $this->mail_file_data->copyCollectionForDelivery($source->rcid()); + $copied_rcid = $this->mail_file_data->copyCollection($source->rcid()); if ($copied_rcid === null) { $this->keep_attachment_source = true; @@ -1218,7 +1218,8 @@ public function enqueue( return $this->sendMail($mail_data); } - // The queued mail gets its own collection, so callers may release their stage or draft right away + // 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)) { @@ -1226,7 +1227,6 @@ public function enqueue( return [new ilMailError('mail_error_reading_attachment')]; } - $this->releaseSourceAttachments($source_attachment); $task_factory = $DIC->backgroundTasks()->taskFactory(); $task_manager = $DIC->backgroundTasks()->taskManager(); @@ -1378,7 +1378,11 @@ private function createQueueSnapshot(MailAttachments $attachments): MailAttachme return $attachments; } - $snapshot_rcid = $this->mail_file_data->createCollectionReferencingResourcesOf($attachments->rcid()); + 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(); } diff --git a/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php b/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php index b4bc515ed509..616aca51ada6 100644 --- a/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php +++ b/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php @@ -150,15 +150,7 @@ private function saveAttachments(): void } try { - $mail_data = $this->umail->retrieveFromStage(); - $stage_attachments = $mail_data['attachments'] ?? null; - $existing_rcid = ($stage_attachments instanceof MailAttachments && $stage_attachments->isIrss()) - ? $stage_attachments->rcid() - : null; - - // A new collection is built instead of mutating the current stage collection, - // because the stage may share it with a draft or a scheduled mail - $rcid = $this->fdm->createStageCollectionWithPoolSelection($existing_rcid, $resource_identifications); + $rcid = $this->fdm->applyPoolSelectionToStage($resource_identifications); if ($rcid === null) { throw new RuntimeException($this->lng->txt('mail_error_reading_attachment')); } @@ -298,7 +290,7 @@ private function showAttachmentsCommand(): void $stage_attachments = $mail_data['attachments'] ?? null; $adopted_pool_rids = []; if ($stage_attachments instanceof MailAttachments && $stage_attachments->isIrss()) { - $adopted_pool_rids = $this->fdm->getAttachmentListing($stage_attachments->rcid()); + $adopted_pool_rids = array_flip($this->fdm->getPoolResourcesInStage($stage_attachments->rcid())); } $files = $this->fdm->getUserPoolFilesData(); diff --git a/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php b/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php index e276d0e15018..f5fa01b0dc90 100644 --- a/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php +++ b/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php @@ -18,8 +18,10 @@ declare(strict_types=1); +use ILIAS\Mail\Attachments\MailAttachments; +use ILIAS\ResourceStorage\Services as ResourceStorage; use ILIAS\ResourceStorage\Identification\ResourceIdentification; -use ILIAS\ResourceStorage\Resource\Repository\CollectionDBRepository; +use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; use ILIAS\ResourceStorage\Stakeholder\AbstractResourceStakeholder; use ILIAS\ResourceStorage\Stakeholder\ConfidentialStakeholder; @@ -53,69 +55,94 @@ public function canBeAccessedByCurrentUser(ResourceIdentification $identificatio return false; } - $user_id = $DIC->user()->getId(); - $db = $DIC->database(); - $rid = $identification->serialize(); - - if ($this->mailboxContainsResource($db, $rid, $user_id)) { - return true; + if (!$DIC->isDependencyAvailable('resourceStorage')) { + return false; } - if ($DIC->isDependencyAvailable('resourceStorage')) { - try { - $owner_id = $DIC->resourceStorage()->manage()->getCurrentRevision($identification)->getOwnerId(); - if ($owner_id === $user_id) { - return true; - } - } catch (Throwable) { + $user_id = $DIC->user()->getId(); + $irss = $DIC->resourceStorage(); + + try { + if ($irss->manage()->getCurrentRevision($identification)->getOwnerId() === $user_id) { + return true; } + } catch (Throwable) { } - return $this->poolOrStageContainsResource($db, $rid, $user_id); + return $this->anyCollectionContains( + $irss, + $this->collectionsOfUser($DIC->database(), $user_id), + $identification + ); } - private function mailboxContainsResource(ilDBInterface $db, string $rid, int $user_id): bool + /** + * @return list Collections of the user's mails, attachment pool and compose stage + */ + private function collectionsOfUser(ilDBInterface $db, int $user_id): array { - $db->setLimit(1, 0); + $rcids = []; + $res = $db->queryF( - 'SELECT 1 FROM ' . CollectionDBRepository::COLLECTION_ASSIGNMENT_TABLE_NAME . ' rca - INNER JOIN mail_attachment ma ON ma.rcid = rca.' . CollectionDBRepository::C_IDENTIFICATION . ' + 'SELECT DISTINCT ma.rcid FROM mail_attachment ma INNER JOIN mail m ON m.mail_id = ma.mail_id - WHERE rca.' . CollectionDBRepository::R_IDENTIFICATION . ' = %s AND m.user_id = %s', - [ilDBConstants::T_TEXT, ilDBConstants::T_INTEGER], - [$rid, $user_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']; + } - return $db->numRows($res) > 0; - } - - private function poolOrStageContainsResource(ilDBInterface $db, string $rid, int $user_id): bool - { - $assignment = CollectionDBRepository::COLLECTION_ASSIGNMENT_TABLE_NAME; - $resource_column = CollectionDBRepository::R_IDENTIFICATION; - $collection_column = CollectionDBRepository::C_IDENTIFICATION; - - $db->setLimit(1, 0); $res = $db->queryF( - 'SELECT 1 FROM usr_pref p - INNER JOIN ' . $assignment . ' rca ON rca.' . $collection_column . ' = p.value - WHERE p.usr_id = %s AND p.keyword = %s AND rca.' . $resource_column . ' = %s', - [ilDBConstants::T_INTEGER, ilDBConstants::T_TEXT, ilDBConstants::T_TEXT], - [$user_id, ilFileDataMail::POOL_RCID_PREF_KEY, $rid] + '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] ); - if ($db->numRows($res) > 0) { - return true; + while ($row = $db->fetchAssoc($res)) { + $rcids[] = (string) $row['value']; } - $db->setLimit(1, 0); $res = $db->queryF( - 'SELECT 1 FROM mail_saved ms - INNER JOIN ' . $assignment . ' rca ON rca.' . $collection_column . ' = ms.attachments - WHERE ms.user_id = %s AND rca.' . $resource_column . ' = %s', - [ilDBConstants::T_INTEGER, ilDBConstants::T_TEXT], - [$user_id, $rid] + '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 $db->numRows($res) > 0; + return false; } } diff --git a/components/ILIAS/Mail/classes/class.ilMailFormGUI.php b/components/ILIAS/Mail/classes/class.ilMailFormGUI.php index 52a9352f2000..5b4100156c77 100755 --- a/components/ILIAS/Mail/classes/class.ilMailFormGUI.php +++ b/components/ILIAS/Mail/classes/class.ilMailFormGUI.php @@ -216,12 +216,8 @@ public function executeCommand(): void public function saveMessageToOutbox(array $form_values, Form $form): void { - $stage = $this->umail->retrieveFromStage(); - $stage_attachments = ($stage['attachments'] ?? null) instanceof MailAttachments - ? $stage['attachments'] - : null; try { - $attachments = $this->attachmentsFromFormUpload($form_values['attachments'], $stage_attachments); + $attachments = $this->attachmentsFromFormUpload($form_values['attachments']); } catch (ilMailAttachmentsTotalSizeLimitExceededException $e) { $this->tpl->setOnScreenMessage(ilGlobalTemplateInterface::MESSAGE_TYPE_FAILURE, $e->getMessage()); $this->showForm($form); @@ -321,12 +317,8 @@ public function sendMessage(): void return; } - $stage = $this->umail->retrieveFromStage(); - $stage_attachments = ($stage['attachments'] ?? null) instanceof MailAttachments - ? $stage['attachments'] - : null; try { - $attachments = $this->attachmentsFromFormUpload($value['attachments'], $stage_attachments); + $attachments = $this->attachmentsFromFormUpload($value['attachments']); } catch (ilMailAttachmentsTotalSizeLimitExceededException $e) { $this->tpl->setOnScreenMessage(ilGlobalTemplateInterface::MESSAGE_TYPE_FAILURE, $e->getMessage()); $this->showForm($form); @@ -425,12 +417,8 @@ public function saveDraft(): void if ($value['m_subject'] === '') { $value['m_subject'] = $this->lng->txt('mail_no_subject'); } - $stage = $this->umail->retrieveFromStage(); - $stage_attachments = ($stage['attachments'] ?? null) instanceof MailAttachments - ? $stage['attachments'] - : null; try { - $attachments = $this->attachmentsFromFormUpload($value['attachments'], $stage_attachments); + $attachments = $this->attachmentsFromFormUpload($value['attachments']); } catch (ilMailAttachmentsTotalSizeLimitExceededException $e) { $this->tpl->setOnScreenMessage(ilGlobalTemplateInterface::MESSAGE_TYPE_FAILURE, $e->getMessage()); $this->showForm($form); @@ -1053,12 +1041,12 @@ public function searchMailingListsTo(): void } /** - * @param array $attachments + * @param list $attachments */ protected function tryHandleAttachments(array $attachments, ?Form $form = null): ?MailAttachments { try { - return MailAttachments::fromIrss($this->handleAttachments($attachments)); + return $this->attachmentsFromFormUpload($attachments); } catch (ilMailAttachmentsTotalSizeLimitExceededException $e) { $this->tpl->setOnScreenMessage(ilGlobalTemplateInterface::MESSAGE_TYPE_FAILURE, $e->getMessage()); $this->showForm($form); diff --git a/components/ILIAS/Mail/classes/class.ilMailFormUploadHandlerGUI.php b/components/ILIAS/Mail/classes/class.ilMailFormUploadHandlerGUI.php index 87e9922a1bd1..f48ca586053c 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; @@ -56,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'); @@ -71,8 +75,7 @@ protected function getUploadResult(): HandlerResultInterface protected function getRemoveResult(string $identifier): HandlerResultInterface { $id = $this->acceptedComposeResource($identifier); - if ($id !== null) { - $this->mailFiles()->removeResourceIfNotInAnyCollection($id); + if ($id !== null && $this->mailFiles()->removeFromStage($id)) { return new BasicHandlerResult($this->getFileIdentifierParameterName(), HandlerResultInterface::STATUS_OK, $identifier, 'file deleted'); } diff --git a/components/ILIAS/Mail/tests/FileDataRCHandlingAttachmentsSizeTest.php b/components/ILIAS/Mail/tests/FileDataRCHandlingAttachmentsSizeTest.php index 90fab9884339..0b5eac833b6f 100644 --- a/components/ILIAS/Mail/tests/FileDataRCHandlingAttachmentsSizeTest.php +++ b/components/ILIAS/Mail/tests/FileDataRCHandlingAttachmentsSizeTest.php @@ -19,10 +19,8 @@ declare(strict_types=1); use ILIAS\ResourceStorage\Services; -use ILIAS\ResourceStorage\Manager\Manager; use ILIAS\FileUpload\Handler\FileInfoResult; use PHPUnit\Framework\Attributes\DataProvider; -use ILIAS\ResourceStorage\Identification\ResourceIdentification; use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; class FileDataRCHandlingAttachmentsSizeTest extends ilMailBaseTestCase @@ -31,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_collection: false); + $subject = $this->createSubject($limit, $files, expect_stage: false); $this->expectException(ilMailAttachmentsTotalSizeLimitExceededException::class); $subject->handle(array_column($files, 'id')); @@ -45,16 +43,16 @@ public function testHandleAttachmentsRejectsWhenTotalSizeExceedsLimit( * @param list $files */ #[DataProvider('acceptedLimitProvider')] - public function testHandleAttachmentsStoresWhenTotalSizeIsWithinLimit( + public function testFormUploadKeepsTheStageWhenTotalSizeIsWithinLimit( ?float $limit, array $files ): void { - $expected_rcid = new ResourceCollectionIdentification('mail-rcid'); - $subject = $this->createSubject($limit, $files, expect_collection: true, expected_rcid: $expected_rcid); + $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($expected_rcid, $result); + $this->assertSame($expected_rcid, $result->rcid()); } /** @@ -89,45 +87,30 @@ public static function acceptedLimitProvider(): array private function createSubject( ?float $limit, array $files, - bool $expect_collection, + bool $expect_stage, ?ResourceCollectionIdentification $expected_rcid = null ): FileDataRCHandlingAttachmentsSizeTestSubject { $infos = []; - $resource_identifications = []; foreach ($files as $file) { $info = $this->createStub(FileInfoResult::class); $info->method('getFileIdentifier')->willReturn($file['id']); $info->method('getSize')->willReturn($file['size']); $info->method('getName')->willReturn($file['id']); $infos[$file['id']] = $info; - $resource_identifications[$file['id']] = new ResourceIdentification($file['id']); } $fdm = $this->createMock(ilFileDataMail::class); $fdm->method('getAttachmentsTotalSizeLimit')->willReturn($limit); - $fdm->method('canUseResourceInCompose')->willReturn(true); - if ($expect_collection) { + if ($expect_stage) { $fdm->expects($this->once()) - ->method('createCollectionFromResourceIdentifications') - ->with(array_values($resource_identifications)) + ->method('restrictStageTo') + ->with(array_column($files, 'id')) ->willReturn($expected_rcid); } else { - $fdm->expects($this->never())->method('createCollectionFromResourceIdentifications'); - } - - $manager = $this->createMock(Manager::class); - if ($expect_collection) { - $manager->expects($this->exactly(count($files))) - ->method('find') - ->willReturnCallback( - static fn(string $id): ResourceIdentification => $resource_identifications[$id] - ); - } else { - $manager->expects($this->never())->method('find'); + $fdm->expects($this->never())->method('restrictStageTo'); } $storage = $this->createStub(Services::class); - $storage->method('manage')->willReturn($manager); $upload_handler = $this->createStub(ilMailFormUploadHandlerGUI::class); $upload_handler->method('getInfoResult')->willReturnCallback( @@ -145,7 +128,7 @@ private function createSubject( class FileDataRCHandlingAttachmentsSizeTestSubject { use FileDataRCHandling { - handleAttachments as public handle; + attachmentsFromFormUpload as public handle; } public function __construct( diff --git a/components/ILIAS/Mail/tests/FileDataRCHandlingFormUploadTest.php b/components/ILIAS/Mail/tests/FileDataRCHandlingFormUploadTest.php index 44e3991e3ac4..ed971de0e8bd 100644 --- a/components/ILIAS/Mail/tests/FileDataRCHandlingFormUploadTest.php +++ b/components/ILIAS/Mail/tests/FileDataRCHandlingFormUploadTest.php @@ -19,7 +19,7 @@ declare(strict_types=1); use ILIAS\ResourceStorage\Services; -use ILIAS\Mail\Attachments\MailAttachments; +use ILIAS\FileUpload\Handler\FileInfoResult; use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; class FileDataRCHandlingFormUploadTest extends ilMailBaseTestCase @@ -30,28 +30,45 @@ class FileDataRCHandlingFormUploadTest extends ilMailBaseTestCase */ public function testFormUploadWithoutFilesIgnoresStageAttachments(): void { - $stage = MailAttachments::fromIrss(new ResourceCollectionIdentification('stage-rcid')); - $subject = $this->createSubject(); + $fdm = $this->createMock(ilFileDataMail::class); + $fdm->expects($this->never())->method('restrictStageTo'); - $result = $subject->fromFormUpload([], $stage); + $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($result->isEmpty()); + $this->assertTrue($this->createSubject($fdm)->fromFormUpload(['gone-rid'])->isEmpty()); } - public function testFormUploadWithoutNewFilesAndWithoutStageReturnsEmpty(): void + public function testFormUploadKeepsTheRestrictedStage(): void { - $subject = $this->createSubject(); + $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 = $subject->fromFormUpload([], null); + $result = $this->createSubject($fdm)->fromFormUpload(['rid-a']); - $this->assertTrue($result->isEmpty()); + $this->assertSame('stage-rcid', $result->rcid()->serialize()); } - private function createSubject(): FileDataRCHandlingFormUploadTestSubject + 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( - $this->createStub(ilFileDataMail::class), - $this->createStub(ilMailFormUploadHandlerGUI::class), + $fdm, + $upload_handler, $this->createStub(ilLanguage::class), $this->createStub(Services::class) ); diff --git a/components/ILIAS/Mail/tests/ilFileDataMailFileNameTest.php b/components/ILIAS/Mail/tests/ilFileDataMailFileNameTest.php index 4c43d66aa9cb..32b485dc4b49 100644 --- a/components/ILIAS/Mail/tests/ilFileDataMailFileNameTest.php +++ b/components/ILIAS/Mail/tests/ilFileDataMailFileNameTest.php @@ -26,7 +26,6 @@ use ILIAS\ResourceStorage\Collection\Collections; use ILIAS\ResourceStorage\Collection\ResourceCollection; use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; -use ILIAS\ResourceStorage\Resource\Repository\CollectionDBRepository; class ilFileDataMailFileNameTest extends ilMailBaseTestCase { @@ -98,19 +97,11 @@ public function testAttachmentListingKeepsEqualDisplayTitles(): void $irss = $this->createStub(IRSS::class); $irss->method('manage')->willReturn($manage); - - $db = $this->createStub(ilDBInterface::class); - $db->method('queryF')->willReturn($this->createStub(ilDBStatement::class)); - $db->method('fetchAssoc')->willReturnOnConsecutiveCalls( - [CollectionDBRepository::R_IDENTIFICATION => 'rid-a'], - [CollectionDBRepository::R_IDENTIFICATION => 'rid-b'], - null - ); + $irss->method('collection')->willReturn($this->collectionsContaining(['rid-a', 'rid-b'])); $reflection = new ReflectionClass(ilFileDataMail::class); $sut = $reflection->newInstanceWithoutConstructor(); $reflection->getProperty('irss')->setValue($sut, $irss); - $reflection->getProperty('db')->setValue($sut, $db); $listing = $sut->getAttachmentListing(new ResourceCollectionIdentification('rcid')); @@ -153,29 +144,38 @@ private function createSutWithEqualTitles(array $rids, string $revision_title): ); $manage->method('getCurrentRevision')->willReturn($revision); - $collections = $this->createStub(Collections::class); - $collections->method('exists')->willReturn(true); - $irss = $this->createStub(IRSS::class); $irss->method('manage')->willReturn($manage); - $irss->method('collection')->willReturn($collections); - - $rows = array_map( - static fn(string $rid): array => [CollectionDBRepository::R_IDENTIFICATION => $rid], - $rids - ); - $db = $this->createStub(ilDBInterface::class); - $db->method('queryF')->willReturn($this->createStub(ilDBStatement::class)); - $db->method('fetchAssoc')->willReturnOnConsecutiveCalls(...[...$rows, null]); + $irss->method('collection')->willReturn($this->collectionsContaining($rids)); $reflection = new ReflectionClass(ilFileDataMail::class); $sut = $reflection->newInstanceWithoutConstructor(); $reflection->getProperty('irss')->setValue($sut, $irss); - $reflection->getProperty('db')->setValue($sut, $db); 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); diff --git a/components/ILIAS/Mail/tests/ilFileDataMailSourceReleaseTest.php b/components/ILIAS/Mail/tests/ilFileDataMailSourceReleaseTest.php index 24c29e2d6d56..d7fbd063a9f9 100644 --- a/components/ILIAS/Mail/tests/ilFileDataMailSourceReleaseTest.php +++ b/components/ILIAS/Mail/tests/ilFileDataMailSourceReleaseTest.php @@ -61,25 +61,11 @@ public function testKeepsForeignStakeholderCollection(): void $sut->releaseSourceCollectionAfterDelivery($rcid); } - public function testRemovesCollectionAndUnsharedResources(): void + public function testRemovesCollectionTogetherWithItsResources(): void { $rcid = new ResourceCollectionIdentification('calendar-rcid'); - $sut = $this->createSut(stakeholders: [new ilMailAttachmentStakeholder()], rids: ['ics-rid'], mock_manager: true); - $this->db->method('numRows')->willReturn(0); - $sut->expects($this->once())->method('removeCollection')->with($rcid); - $this->managerMock()->expects($this->once())->method('remove'); - - $sut->releaseSourceCollectionAfterDelivery($rcid); - } - - public function testResourcesStillInOtherCollectionsSurvive(): void - { - $rcid = new ResourceCollectionIdentification('stage-rcid'); - $sut = $this->createSut(stakeholders: [new ilMailAttachmentStakeholder()], rids: ['pool-rid'], mock_manager: true); - // The resource is still assigned to e.g. the user's pool - $this->db->method('numRows')->willReturn(1); - $sut->expects($this->once())->method('removeCollection')->with($rcid); - $this->managerMock()->expects($this->never())->method('remove'); + $sut = $this->createSut(stakeholders: [new ilMailAttachmentStakeholder()], rids: ['ics-rid']); + $sut->expects($this->once())->method('removeCollection')->with($rcid, true); $sut->releaseSourceCollectionAfterDelivery($rcid); } diff --git a/components/ILIAS/Mail/tests/ilFileDataMailStageTest.php b/components/ILIAS/Mail/tests/ilFileDataMailStageTest.php new file mode 100644 index 000000000000..c8dc741f288d --- /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('stage-rcid', $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('stage-rcid', $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('stage-rcid'), 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' => 'stage-rcid'] : 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 index 228330d94980..5848ce266d4e 100644 --- a/components/ILIAS/Mail/tests/ilMailCopyOnSendTest.php +++ b/components/ILIAS/Mail/tests/ilMailCopyOnSendTest.php @@ -30,7 +30,7 @@ class ilMailCopyOnSendTest extends TestCase public function testDeliveryAttachmentsReturnsLegacyUnchanged(): void { $mail = $this->createMailInstance(); - $this->mail_file_data->expects($this->never())->method('copyCollectionForDelivery'); + $this->mail_file_data->expects($this->never())->method('copyCollection'); $legacy = MailAttachments::fromLegacyFilenames(['file.pdf']); $result = $this->invokeDeliveryAttachments($legacy, $mail); @@ -41,7 +41,7 @@ public function testDeliveryAttachmentsReturnsLegacyUnchanged(): void public function testDeliveryAttachmentsReturnsEmptyUnchanged(): void { $mail = $this->createMailInstance(); - $this->mail_file_data->expects($this->never())->method('copyCollectionForDelivery'); + $this->mail_file_data->expects($this->never())->method('copyCollection'); $empty = MailAttachments::empty(); $result = $this->invokeDeliveryAttachments($empty, $mail); @@ -58,7 +58,7 @@ public function testDeliveryAttachmentsCopiesIrssCollectionPerCallWhenSharingIsD $this->mail_file_data ->expects($this->exactly(2)) - ->method('copyCollectionForDelivery') + ->method('copyCollection') ->with($source) ->willReturnOnConsecutiveCalls($copy_a, $copy_b); @@ -77,7 +77,7 @@ public function testDeliveryAttachmentsReturnsEmptyWhenCopyFails(): void $source = new ResourceCollectionIdentification('source-rcid'); $this->mail_file_data ->expects($this->once()) - ->method('copyCollectionForDelivery') + ->method('copyCollection') ->willReturn(null); $result = $this->invokeDeliveryAttachments(MailAttachments::fromIrss($source), $mail); @@ -93,7 +93,7 @@ public function testSharedDeliveryAttachmentsReuseSingleCopy(): void $this->mail_file_data ->expects($this->once()) - ->method('copyCollectionForDelivery') + ->method('copyCollection') ->with($source) ->willReturn($shared); @@ -113,7 +113,7 @@ public function testDeliveryAttachmentsAreSharedByDefault(): void $this->mail_file_data ->expects($this->once()) - ->method('copyCollectionForDelivery') + ->method('copyCollection') ->willReturn(new ResourceCollectionIdentification('shared-copy')); $first = $this->invokeDeliveryAttachments(MailAttachments::fromIrss($source), $mail); @@ -127,13 +127,37 @@ 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('copyCollectionForDelivery'); + $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(); @@ -178,7 +202,7 @@ private function createMailInstance(bool $exclusively_owned = false): ilMail $this->mail_file_data = $this->getMockBuilder(ilFileDataMail::class) ->disableOriginalConstructor() ->onlyMethods([ - 'copyCollectionForDelivery', + 'copyCollection', 'releaseSourceCollectionAfterDelivery', 'isCollectionExclusivelyOwnedByMail', ]) @@ -198,6 +222,11 @@ private function invokeReleaseSourceAttachments(ilMail $mail, MailAttachments $s 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); From d2e7c8aacab882c07f3cbfbd55e8653376c69ba5 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Thu, 22 Oct 2026 13:32:24 +0200 Subject: [PATCH 33/43] [Fix] Mail: Release attachment collections of rejected and failed deliveries - enqueue() released a collection handed over by the consumer only after queueing, a rejected message kept it forever - A delivery whose attachment copy failed kept its queued snapshot, which nobody references - The migration removes the copies of a directory or draft that could not be stored completely --- .../ILIAS/Mail/classes/class.ilMail.php | 49 +++++++++---------- .../MigrateMailAttachmentsToIRSS.php | 26 ++++++++-- .../MigrateMailAttachmentsToIRSSUnitTest.php | 9 ++++ 3 files changed, 54 insertions(+), 30 deletions(-) diff --git a/components/ILIAS/Mail/classes/class.ilMail.php b/components/ILIAS/Mail/classes/class.ilMail.php index 405cef4fdf8e..b3332d15e7c8 100755 --- a/components/ILIAS/Mail/classes/class.ilMail.php +++ b/components/ILIAS/Mail/classes/class.ilMail.php @@ -47,7 +47,7 @@ class ilMail private bool $append_installation_signature = false; private bool $append_user_signature = false; private bool $share_attachments = true; - private bool $keep_attachment_source = false; + private bool $attachment_copy_failed = false; private ?MailAttachments $shared_delivery_attachments = null; private ?string $context_id = null; private array $context_parameters = []; @@ -192,7 +192,7 @@ private function createDeliveryAttachments(MailAttachments $source): MailAttachm $copied_rcid = $this->mail_file_data->copyCollection($source->rcid()); if ($copied_rcid === null) { - $this->keep_attachment_source = true; + $this->attachment_copy_failed = true; return MailAttachments::empty(); } @@ -866,7 +866,7 @@ private function sendChanneledMails( $recipient_inbox_id = $mbox->getInboxFolder(); $delivery_attachments = $this->getDeliveryAttachments($mail_data->getAttachments()); - if ($this->keep_attachment_source) { + if ($this->attachment_copy_failed) { $this->logger->error('Could not copy mail attachments for delivery'); break; @@ -901,7 +901,7 @@ private function sendChanneledMails( ); } - if ($usr_id_to_external_email_addresses_map === [] || $this->keep_attachment_source) { + if ($usr_id_to_external_email_addresses_map === [] || $this->attachment_copy_failed) { return; } @@ -1177,27 +1177,26 @@ public function enqueue( } $a_attachment = $normalized['attachments']; - $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; - } - $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()) { @@ -1283,12 +1282,12 @@ public function sendMail( } $mail_data = $mail_data->withAttachments($normalized['attachments']); $this->shared_delivery_attachments = null; - $this->keep_attachment_source = false; + $this->attachment_copy_failed = false; $source_attachments = $mail_data->getAttachments(); try { $sentbox_attachments = $this->getDeliveryAttachments($source_attachments); - if ($this->keep_attachment_source) { + if ($this->attachment_copy_failed) { $this->logger->error('Could not copy mail attachments for delivery'); return [new ilMailError('mail_error_reading_attachment')]; @@ -1346,7 +1345,7 @@ public function sendMail( if (!$this->distributeMail($mail_data)) { $errors['mail_send_error'] = new ilMailError('mail_send_error'); } - if ($this->keep_attachment_source) { + if ($this->attachment_copy_failed) { $errors['mail_error_reading_attachment'] = new ilMailError('mail_error_reading_attachment'); } @@ -1354,9 +1353,7 @@ public function sendMail( $this->deleteMails([$internal_message_id]); } } finally { - if (!$this->keep_attachment_source) { - $this->releaseSourceAttachments($source_attachments); - } + $this->releaseSourceAttachments($source_attachments); } if ($this->isSystemMail()) { diff --git a/components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php b/components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php index 6c1a13423e73..93139aadb59b 100644 --- a/components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php +++ b/components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php @@ -169,6 +169,7 @@ private function copyDirectoryToCollection( 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, @@ -178,21 +179,36 @@ private function copyDirectoryToCollection( true ); if (!$resource_id instanceof ResourceIdentification) { + $this->discardResources($copies); + return null; } + $copies[] = $resource_id; $collection->add($resource_id); } - if ($collection->count() !== count($filenames)) { - return null; - } - if (!$this->helper->getCollectionBuilder()->store($collection)) { + 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 */ @@ -413,6 +429,8 @@ private function migratePoolFilenamesToCollection( } if (!$this->helper->getCollectionBuilder()->store($collection)) { + $this->discardResources(array_values($collection->getResourceIdentifications())); + return null; } diff --git a/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php b/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php index 461f20db2d35..3de26da9e811 100644 --- a/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php +++ b/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php @@ -89,6 +89,7 @@ private function createHelperDouble(bool $mock): ilResourceStorageMigrationHelpe 'movePathToStorage', 'getCollectionBuilder', 'getManager', + 'getStakeholder', ]; if ($mock) { $helper = $this->getMockBuilder(ilResourceStorageMigrationHelper::class) @@ -278,6 +279,14 @@ public function testIncompleteDirectoryCopyIsNotMarkedMigratedAndKeepsTheFiles() 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', []); From c23df25b8bad8273febdd98983a0d4f096762dd4 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Thu, 22 Oct 2026 14:01:40 +0200 Subject: [PATCH 34/43] [Fix] Mail: Send the new account mail attachment as attachment - The IRSS based attachment used the inline disposition, the file based one was sent as attachment --- components/ILIAS/Mail/classes/class.ilAccountMail.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/ILIAS/Mail/classes/class.ilAccountMail.php b/components/ILIAS/Mail/classes/class.ilAccountMail.php index cde79bc41f0d..b288f3bfa47d 100755 --- a/components/ILIAS/Mail/classes/class.ilAccountMail.php +++ b/components/ILIAS/Mail/classes/class.ilAccountMail.php @@ -174,7 +174,7 @@ public function send(): bool $rid = $this->irss->manage()->find($amail->getAttachmentRid()); if ($rid instanceof ResourceIdentification) { $filename = $this->irss->manage()->getCurrentRevision($rid)->getInformation()->getTitle(); - $mmail->AttachResource($rid, $filename); + $mmail->AttachResource($rid, $filename, '', 'attachment'); } } else { $attachment = $amail->getAttachment($this->irss); From 5729ec38ecee88f386532a8c54936d3ace193c12 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Thu, 22 Oct 2026 14:01:50 +0200 Subject: [PATCH 35/43] [Fix] Mail: Deny attachment access for the anonymous user - Notifications are sent by the anonymous user, so the owner check granted visitors access to their attachments --- .../ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php b/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php index f5fa01b0dc90..928e31647044 100644 --- a/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php +++ b/components/ILIAS/Mail/classes/class.ilMailAttachmentStakeholder.php @@ -60,6 +60,10 @@ public function canBeAccessedByCurrentUser(ResourceIdentification $identificatio } $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 { From 0c405c8f12aa469c3a20b1a3cf5db2b542849c1e Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Thu, 22 Oct 2026 14:02:50 +0200 Subject: [PATCH 36/43] [Refactoring] Mail: Remove unused attachment helpers - getUserFilesData(), storeUploadedFile(), countMailsReferencingRcid() and createCollectionFromPaths() have no callers anymore - ilMail does not use the form attachment trait - Mass mails share their attachments by default --- .../class.ilMassMailDeliveryJob.php | 1 - .../Mail/classes/class.ilFileDataMail.php | 47 ------------------- .../ILIAS/Mail/classes/class.ilMail.php | 2 - 3 files changed, 50 deletions(-) diff --git a/components/ILIAS/Mail/classes/BackgroundTask/class.ilMassMailDeliveryJob.php b/components/ILIAS/Mail/classes/BackgroundTask/class.ilMassMailDeliveryJob.php index 962d21f76f2f..d97d5ddbe707 100755 --- a/components/ILIAS/Mail/classes/BackgroundTask/class.ilMassMailDeliveryJob.php +++ b/components/ILIAS/Mail/classes/BackgroundTask/class.ilMassMailDeliveryJob.php @@ -48,7 +48,6 @@ public function run(array $input, Observer $observer): Value $mail = new ilMail((int) $input[0]->getValue()); $mail->setSaveInSentbox($value_object->shouldSaveInSentBox()); - $mail->setShareAttachments(true); $context_id = $input[2]->getValue(); $mail = $mail ->withContextId((string) $context_id) diff --git a/components/ILIAS/Mail/classes/class.ilFileDataMail.php b/components/ILIAS/Mail/classes/class.ilFileDataMail.php index 534659b47a83..62ba9983e0ae 100755 --- a/components/ILIAS/Mail/classes/class.ilFileDataMail.php +++ b/components/ILIAS/Mail/classes/class.ilFileDataMail.php @@ -183,14 +183,6 @@ public function checkReadWrite(): bool return false; } - /** - * @return list - */ - public function getUserFilesData(): array - { - return $this->getUnsentFiles(); - } - /** * @return list */ @@ -218,14 +210,6 @@ private function getUnsentFiles(): array return $files; } - /** - * @deprecated legacy pool write; use uploadToPool() instead - */ - public function storeUploadedFile(UploadResult $result): string - { - return $this->uploadToPool($result)->serialize(); - } - public function uploadToPool(UploadResult $result): ResourceIdentification { $rid = $this->uploadToIrss($result); @@ -708,17 +692,6 @@ public function getRcidForMail(int $mail_id): ?ResourceCollectionIdentification return new ResourceCollectionIdentification($rcid); } - public function countMailsReferencingRcid(string $rcid): int - { - $res = $this->db->queryF( - 'SELECT COUNT(mail_id) cnt FROM mail_attachment WHERE rcid = %s', - ['text'], - [$rcid] - ); - - return (int) $this->db->fetchObject($res)->cnt; - } - public function deassignAttachmentFromDirectory(int $a_mail_id): bool { $res = $this->db->query( @@ -1188,26 +1161,6 @@ public function adoptPoolFilenamesToCollection( return $collection->getIdentification(); } - /** - * @param list $absolute_paths - */ - public function createCollectionFromPaths(array $absolute_paths): ?ResourceCollectionIdentification - { - $resource_identifications = []; - foreach ($absolute_paths as $absolute_path) { - if (!is_file($absolute_path)) { - continue; - } - $resource_identifications[] = $this->streamFromPath($absolute_path); - } - - if ($resource_identifications === []) { - return null; - } - - return $this->createCollectionFromResourceIdentifications($resource_identifications); - } - /** * Creates a stage collection from a not yet migrated (directory based) mail, e.g. when forwarding it. * diff --git a/components/ILIAS/Mail/classes/class.ilMail.php b/components/ILIAS/Mail/classes/class.ilMail.php index b3332d15e7c8..4f8217e005b4 100755 --- a/components/ILIAS/Mail/classes/class.ilMail.php +++ b/components/ILIAS/Mail/classes/class.ilMail.php @@ -32,8 +32,6 @@ class ilMail { - use FileDataRCHandling; - public const string ILIAS_HOST = 'ilias'; public const string PROP_CONTEXT_SUBJECT_PREFIX = 'subject_prefix'; private MailSignatureService $signature_service; From 39e68b5aa29096f05a2611a06c44119e54016b97 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Thu, 22 Oct 2026 14:03:29 +0200 Subject: [PATCH 37/43] [Refactoring] Mail: Remove the repair of earlier attachment migration runs - Only development systems that ran earlier versions of this unreleased migration were affected --- .../MigrateMailAttachmentsToIRSS.php | 60 +------------------ .../MigrateMailAttachmentsToIRSSUnitTest.php | 19 +----- 2 files changed, 2 insertions(+), 77 deletions(-) diff --git a/components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php b/components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php index 93139aadb59b..8da9955b55c9 100644 --- a/components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php +++ b/components/ILIAS/Mail/src/Setup/Migration/MigrateMailAttachmentsToIRSS.php @@ -42,12 +42,10 @@ class MigrateMailAttachmentsToIRSS implements Migration { private const int PATHS_PER_STEP = 25; private const int MAILS_PER_STEP = 50; - private const int REPAIRS_PER_STEP = 100; private const string SKIPPED_MARKER = '-'; private ?ilResourceStorageMigrationHelper $helper = null; private ?AdminInteraction $io = null; - private bool $repair_done = false; private int $step_number = 0; /** @var array */ @@ -89,7 +87,6 @@ public function step(Environment $environment): void try { $this->migrateSentAttachmentDirectories(); - $this->repairAttachmentColumnsOfMigratedDirectories(); $this->migrateSerializedMailAttachments(); } catch (Throwable $e) { $this->error('Migration aborted', ['step' => $this->step_number], $e); @@ -462,55 +459,6 @@ private function clearMailAttachmentsColumn(int $mail_id): void ); } - // ----------------------------------------------------------------------------------------------------------- - // Repair of earlier runs - // ----------------------------------------------------------------------------------------------------------- - - /** - * Earlier versions of this migration could empty the `attachments` column of mails whose directory - * was migrated afterwards. The collection is restored from the `mail_attachment` row. - */ - private function repairAttachmentColumnsOfMigratedDirectories(): void - { - if ($this->repair_done) { - return; - } - - $db = $this->helper->getDatabase(); - $db->setLimit(self::REPAIRS_PER_STEP); - $res = $db->query($this->buildRepairQuery('m.mail_id, ma.rcid')); - - $rows = 0; - while ($row = $db->fetchAssoc($res)) { - $rows++; - $db->update( - 'mail', - [ - 'attachments' => [ilDBConstants::T_CLOB, (string) $row['rcid']], - ], - [ - 'mail_id' => [ilDBConstants::T_INTEGER, (int) $row['mail_id']], - ] - ); - } - - if ($rows > 0) { - $this->count('columns_repaired', $rows); - } - - // Directories migrated by this version never empty the column, so nothing new can show up - $this->repair_done = $rows < self::REPAIRS_PER_STEP; - } - - private function buildRepairQuery(string $select): string - { - return 'SELECT ' . $select . ' FROM mail_attachment ma - INNER JOIN mail m ON m.mail_id = ma.mail_id - WHERE ma.rcid IS NOT NULL AND ma.rcid != "" AND ma.rcid != "' . self::SKIPPED_MARKER . '" - AND ma.path IS NOT NULL AND ma.path != "" - AND (m.attachments IS NULL OR m.attachments = "")'; - } - public function getRemainingAmountOfSteps(): int { $db = $this->helper->getDatabase(); @@ -532,20 +480,14 @@ public function getRemainingAmountOfSteps(): int ) )['cnt']; - $repair_count = (int) $db->fetchAssoc( - $db->query($this->buildRepairQuery('COUNT(*) cnt')) - )['cnt']; - $remaining = max( (int) ceil($path_count / self::PATHS_PER_STEP), - (int) ceil($mail_count / self::MAILS_PER_STEP), - (int) ceil($repair_count / self::REPAIRS_PER_STEP) + (int) ceil($mail_count / self::MAILS_PER_STEP) ); $this->info('Remaining', [ 'directories' => $path_count, 'drafts' => $mail_count, - 'columns_to_repair' => $repair_count, 'steps' => $remaining, ]); diff --git a/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php b/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php index 3de26da9e811..a86bda776803 100644 --- a/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php +++ b/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php @@ -419,26 +419,9 @@ public function testBrokenDraftValueIsClearedAndReported(): void } // --------------------------------------------------------------------------------------------------------- - // Repair, abort, reporting + // Abort, reporting // --------------------------------------------------------------------------------------------------------- - public function testRepairRestoresEmptiedAttachmentColumns(): void - { - $db = $this->useDatabaseMock(); - $db->expects($this->once())->method('setLimit')->with(100); - $db->method('query')->willReturn($this->createStub(ilDBStatement::class)); - $db->method('fetchAssoc')->willReturnOnConsecutiveCalls(['mail_id' => 7, 'rcid' => 'dir-rcid'], null); - $db->expects($this->once())->method('update')->with( - 'mail', - ['attachments' => [ilDBConstants::T_CLOB, 'dir-rcid']], - ['mail_id' => [ilDBConstants::T_INTEGER, 7]] - ); - - $this->invokePrivate('repairAttachmentColumnsOfMigratedDirectories', []); - // nothing left: the repair query is not executed again - $this->invokePrivate('repairAttachmentColumnsOfMigratedDirectories', []); - } - public function testAbortIsReportedWithItsCauseAndRethrown(): void { $this->db->method('query')->willThrowException(new RuntimeException('MySQL server has gone away')); From 4f684c73a9ecad4c61358c8a507c570b54d5cff5 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Thu, 22 Oct 2026 14:04:49 +0200 Subject: [PATCH 38/43] [Fix] Mail: Accept only collection ids as IRSS attachment values - Any stored value not starting with a: was taken as collection id, old serialized values like N; showed attachments that do not exist --- .../Mail/src/Attachments/MailAttachments.php | 6 ++++++ .../ILIAS/Mail/tests/MailAttachmentsTest.php | 18 +++++++++++------- .../Mail/tests/ilFileDataMailStageTest.php | 8 ++++---- components/ILIAS/Mail/tests/ilMailTest.php | 12 ++++++------ 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/components/ILIAS/Mail/src/Attachments/MailAttachments.php b/components/ILIAS/Mail/src/Attachments/MailAttachments.php index 1994f60c6e18..23551cc273ed 100644 --- a/components/ILIAS/Mail/src/Attachments/MailAttachments.php +++ b/components/ILIAS/Mail/src/Attachments/MailAttachments.php @@ -26,6 +26,7 @@ final class MailAttachments { public const string SERIALIZED_RCID_KEY = '__irss_rcid'; + private const string RCID_PATTERN = '/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/'; private function __construct( private readonly ?ResourceCollectionIdentification $rcid, @@ -87,6 +88,11 @@ public static function fromDb(?string $raw): ?self 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)); } diff --git a/components/ILIAS/Mail/tests/MailAttachmentsTest.php b/components/ILIAS/Mail/tests/MailAttachmentsTest.php index fcdb6dac25e4..d7c0cf5b9202 100644 --- a/components/ILIAS/Mail/tests/MailAttachmentsTest.php +++ b/components/ILIAS/Mail/tests/MailAttachmentsTest.php @@ -24,22 +24,26 @@ class MailAttachmentsTest extends TestCase { + private const string RCID = '657497dc-5079-4f95-b19d-aecdaf81ff1a'; + public function testParseRcidString(): void { - $rcid = new ResourceCollectionIdentification('rcid-123'); - $parsed = MailAttachments::fromDb($rcid->serialize()); + $parsed = MailAttachments::fromDb(self::RCID); $this->assertInstanceOf(MailAttachments::class, $parsed); $this->assertTrue($parsed->isIrss()); - $this->assertSame('rcid-123', $parsed->rcid()->serialize()); + $this->assertSame(self::RCID, $parsed->rcid()->serialize()); } - public function testValueContainingSerializationMarkerLaterIsNotTreatedAsLegacy(): void + public function testValueContainingSerializationMarkerLaterIsNoCollection(): void { - $parsed = MailAttachments::fromDb('rcid-with-a:-inside'); + $this->assertNull(MailAttachments::fromDb('rcid-with-a:-inside')); + } - $this->assertTrue($parsed->isIrss()); - $this->assertSame('rcid-with-a:-inside', $parsed->rcid()->serialize()); + public function testOtherSerializedValuesAreNoCollection(): void + { + $this->assertNull(MailAttachments::fromDb('N;')); + $this->assertNull(MailAttachments::fromDb(serialize('file.pdf'))); } public function testParseLegacyArray(): void diff --git a/components/ILIAS/Mail/tests/ilFileDataMailStageTest.php b/components/ILIAS/Mail/tests/ilFileDataMailStageTest.php index c8dc741f288d..e0f194f11adf 100644 --- a/components/ILIAS/Mail/tests/ilFileDataMailStageTest.php +++ b/components/ILIAS/Mail/tests/ilFileDataMailStageTest.php @@ -42,7 +42,7 @@ public function testRestrictingTheStageDeletesFilesMissingInTheForm(): void $rcid = $sut->restrictStageTo(['rid-a']); - $this->assertSame('stage-rcid', $rcid?->serialize()); + $this->assertSame('33333333-3333-4333-8333-333333333333', $rcid?->serialize()); $this->assertSame(['rid-a'], $this->stageRids()); } @@ -52,7 +52,7 @@ public function testRestrictingTheStageToAllFilesKeepsIt(): void $this->collections->expects($this->never())->method('store'); $this->manage->expects($this->never())->method('remove'); - $this->assertSame('stage-rcid', $sut->restrictStageTo(['rid-a', 'rid-b'])?->serialize()); + $this->assertSame('33333333-3333-4333-8333-333333333333', $sut->restrictStageTo(['rid-a', 'rid-b'])?->serialize()); } public function testWithoutStageNothingIsRestricted(): void @@ -95,7 +95,7 @@ public function testRemovingAStageFileDeletesIt(): void private function createSut(bool $has_stage): ilFileDataMail { - $this->stage = new ResourceCollection(new ResourceCollectionIdentification('stage-rcid'), 6, ''); + $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')); @@ -111,7 +111,7 @@ private function createSut(bool $has_stage): ilFileDataMail $db = $this->createStub(ilDBInterface::class); $db->method('queryF')->willReturn($this->createStub(ilDBStatement::class)); - $db->method('fetchAssoc')->willReturn($has_stage ? ['attachments' => 'stage-rcid'] : null); + $db->method('fetchAssoc')->willReturn($has_stage ? ['attachments' => '33333333-3333-4333-8333-333333333333'] : null); $reflection = new ReflectionClass(ilFileDataMail::class); $sut = $reflection->newInstanceWithoutConstructor(); diff --git a/components/ILIAS/Mail/tests/ilMailTest.php b/components/ILIAS/Mail/tests/ilMailTest.php index 2bb856e37bd7..8179b4cf8d8d 100755 --- a/components/ILIAS/Mail/tests/ilMailTest.php +++ b/components/ILIAS/Mail/tests/ilMailTest.php @@ -654,15 +654,15 @@ public function testPersistingToStageReleasesReplacedStageCollection(): void $mock_statement = $this->getMockBuilder(ilDBStatement::class)->disableOriginalConstructor()->getMock(); $this->mock_database->method('queryF')->willReturn($mock_statement); $this->mock_database->method('fetchAssoc')->willReturnOnConsecutiveCalls( - ['attachments' => 'old-stage-rcid'], - ['attachments' => 'new-stage-rcid', 'rcp_to' => ''] + ['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() === 'old-stage-rcid' + static fn(ResourceCollectionIdentification $rcid): bool => $rcid->serialize() === '11111111-1111-4111-8111-111111111111' )); $instance->persistToStage( @@ -672,7 +672,7 @@ public function testPersistingToStageReleasesReplacedStageCollection(): void '', '', '', - MailAttachments::fromIrss(new ResourceCollectionIdentification('new-stage-rcid')) + MailAttachments::fromIrss(new ResourceCollectionIdentification('22222222-2222-4222-8222-222222222222')) ); } @@ -682,7 +682,7 @@ public function testPersistingSameStageCollectionKeepsIt(): void $instance = $this->create(789, $usr_id); $mock_statement = $this->getMockBuilder(ilDBStatement::class)->disableOriginalConstructor()->getMock(); $this->mock_database->method('queryF')->willReturn($mock_statement); - $this->mock_database->method('fetchAssoc')->willReturn(['attachments' => 'stage-rcid', 'rcp_to' => '']); + $this->mock_database->method('fetchAssoc')->willReturn(['attachments' => '33333333-3333-4333-8333-333333333333', 'rcp_to' => '']); $this->mock_mail_file_data->expects($this->never())->method('releaseCollectionIfUnreferenced'); @@ -693,7 +693,7 @@ public function testPersistingSameStageCollectionKeepsIt(): void '', '', '', - MailAttachments::fromIrss(new ResourceCollectionIdentification('stage-rcid')) + MailAttachments::fromIrss(new ResourceCollectionIdentification('33333333-3333-4333-8333-333333333333')) ); } From 58158b110d91d3b411f1bbad15a5b4881e85b603 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Thu, 22 Oct 2026 14:05:27 +0200 Subject: [PATCH 39/43] [Fix] Mail: Resolve IRSS attachments only for mails of the user - The directory based lookup already checked the mail owner, the collection based lookup did not --- .../Mail/classes/class.ilFileDataMail.php | 17 +++++- .../ilFileDataMailAttachmentOwnerTest.php | 53 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 components/ILIAS/Mail/tests/ilFileDataMailAttachmentOwnerTest.php diff --git a/components/ILIAS/Mail/classes/class.ilFileDataMail.php b/components/ILIAS/Mail/classes/class.ilFileDataMail.php index 62ba9983e0ae..854b64df2a43 100755 --- a/components/ILIAS/Mail/classes/class.ilFileDataMail.php +++ b/components/ILIAS/Mail/classes/class.ilFileDataMail.php @@ -100,7 +100,7 @@ public function getAbsoluteAttachmentPoolPathPrefix(): string */ public function getAttachmentPathAndFilenameByMd5Hash(string $md5FileHash, int $mail_id): array { - $rcid = $this->getRcidForMail($mail_id); + $rcid = $this->getRcidForMailOfUser($mail_id); if ($rcid !== null) { $resource_identification = $this->getResourceIdByHash($rcid, $md5FileHash); if ($resource_identification === null) { @@ -671,6 +671,21 @@ public function replaceMailAttachmentCollection(int $mail_id, ?ResourceCollectio } } + private function getRcidForMailOfUser(int $mail_id): ?ResourceCollectionIdentification + { + $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 $rcid !== '' && $rcid !== '-' ? new ResourceCollectionIdentification($rcid) : null; + } + public function getRcidForMail(int $mail_id): ?ResourceCollectionIdentification { $res = $this->db->queryF( 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); + } + } +} From 5f0ef4b7c6d931ce4abbfa5dbdf5156b137af940 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Thu, 22 Oct 2026 14:06:55 +0200 Subject: [PATCH 40/43] [Test] Mail: Use stubs for the IRSS and statement doubles without expectations --- components/ILIAS/Mail/tests/ilMailMimeTest.php | 6 +++--- components/ILIAS/Mail/tests/ilMailTest.php | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/components/ILIAS/Mail/tests/ilMailMimeTest.php b/components/ILIAS/Mail/tests/ilMailMimeTest.php index 310ed8549e8d..630ad0f2e99a 100755 --- a/components/ILIAS/Mail/tests/ilMailMimeTest.php +++ b/components/ILIAS/Mail/tests/ilMailMimeTest.php @@ -101,7 +101,7 @@ public function testTransportFactoryWillReturnNullTransportIfExternalEmailDelive 'raise', ])->getMock(); - $irss = $this->getMockBuilder(ILIAS\ResourceStorage\Services::class)->disableOriginalConstructor()->getMock(); + $irss = $this->createStub(ILIAS\ResourceStorage\Services::class); $factory = new ilMailMimeTransportFactory($settings, $event_handler, $irss); $this->assertInstanceOf(ilMailMimeTransportNull::class, $factory->getTransport()); @@ -120,7 +120,7 @@ public function testTransportFactoryWillReturnSmtpTransportIfEnabled(): void 'raise', ])->getMock(); - $irss = $this->getMockBuilder(ILIAS\ResourceStorage\Services::class)->disableOriginalConstructor()->getMock(); + $irss = $this->createStub(ILIAS\ResourceStorage\Services::class); $factory = new ilMailMimeTransportFactory($settings, $event_handler, $irss); $this->assertInstanceOf(ilMailMimeTransportSmtp::class, $factory->getTransport()); @@ -151,7 +151,7 @@ public function testTransportFactoryWillReturnSendmailTransportIfSmtpTransportIs 'raise', ])->getMock(); - $irss = $this->getMockBuilder(ILIAS\ResourceStorage\Services::class)->disableOriginalConstructor()->getMock(); + $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/ilMailTest.php b/components/ILIAS/Mail/tests/ilMailTest.php index 8179b4cf8d8d..bbdafcdbc2dc 100755 --- a/components/ILIAS/Mail/tests/ilMailTest.php +++ b/components/ILIAS/Mail/tests/ilMailTest.php @@ -651,7 +651,7 @@ public function testPersistingToStageReleasesReplacedStageCollection(): void { $usr_id = 897; $instance = $this->create(789, $usr_id); - $mock_statement = $this->getMockBuilder(ilDBStatement::class)->disableOriginalConstructor()->getMock(); + $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'], @@ -680,7 +680,7 @@ public function testPersistingSameStageCollectionKeepsIt(): void { $usr_id = 897; $instance = $this->create(789, $usr_id); - $mock_statement = $this->getMockBuilder(ilDBStatement::class)->disableOriginalConstructor()->getMock(); + $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' => '']); @@ -704,7 +704,7 @@ public function testSaveAttachments(): void new ResourceCollectionIdentification('657497dc-5079-4f95-b19d-aecdaf81ff1a') ); $instance = $this->create(789, $usr_id); - $mock_statement = $this->getMockBuilder(ilDBStatement::class)->getMock(); + $mock_statement = $this->createStub(ilDBStatement::class); $this->mock_database->expects($this->exactly(3))->method('queryF')->willReturnCallback( $this->queryCallback($mock_statement, ['integer'], [$usr_id]) From 0df137f3cbca9dbc524c9564061dc2d34010f782 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Thu, 22 Oct 2026 14:09:35 +0200 Subject: [PATCH 41/43] [Refactoring] Mail: Revert unrelated formatting changes - Restore import order, blank lines, doc blocks and expressions of code the attachment changes do not touch - Move the import in ilWikiUtil below the license header and leave the commented code as it was --- .../Mail/classes/Message/MailBoxQuery.php | 5 ++- .../ILIAS/Mail/classes/class.ilMail.php | 45 ++++++++++--------- .../ILIAS/Wiki/classes/class.ilWikiUtil.php | 6 +-- 3 files changed, 29 insertions(+), 27 deletions(-) diff --git a/components/ILIAS/Mail/classes/Message/MailBoxQuery.php b/components/ILIAS/Mail/classes/Message/MailBoxQuery.php index eccfb35453a9..0cab65335765 100755 --- a/components/ILIAS/Mail/classes/Message/MailBoxQuery.php +++ b/components/ILIAS/Mail/classes/Message/MailBoxQuery.php @@ -52,8 +52,9 @@ class MailBoxQuery private string $order_direction = self::DEFAULT_ORDER_DIRECTION; private ?bool $scheduled_only = null; - public function __construct(private readonly int $user_id) - { + public function __construct( + private readonly int $user_id, + ) { global $DIC; $this->db = $DIC->database(); } diff --git a/components/ILIAS/Mail/classes/class.ilMail.php b/components/ILIAS/Mail/classes/class.ilMail.php index 4f8217e005b4..2d0b21e90462 100755 --- a/components/ILIAS/Mail/classes/class.ilMail.php +++ b/components/ILIAS/Mail/classes/class.ilMail.php @@ -18,27 +18,27 @@ declare(strict_types=1); -use ILIAS\Mail\Recipient; +use ILIAS\BackgroundTasks\Implementation\Bucket\BasicBucket; +use ILIAS\Mail\Autoresponder\AutoresponderService; use ILIAS\LegalDocuments\Conductor; +use ILIAS\Mail\Recipient; +use ILIAS\Mail\Service\MailSignatureService; +use ILIAS\Mail\Transformation\Utf8Mb4Sanitizer; +use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; use ILIAS\Mail\Folder\MailScheduleData; use ILIAS\Mail\Message\MailRecordMapper; use ILIAS\Mail\Attachments\MailAttachments; -use ILIAS\Mail\Service\MailSignatureService; -use ILIAS\Mail\Transformation\Utf8Mb4Sanitizer; -use ILIAS\Mail\Autoresponder\AutoresponderService; -use ILIAS\BackgroundTasks\Implementation\Bucket\BasicBucket; use ILIAS\ResourceStorage\Identification\ResourceIdentification; -use ILIAS\ResourceStorage\Identification\ResourceCollectionIdentification; class ilMail { public const string ILIAS_HOST = 'ilias'; public const string PROP_CONTEXT_SUBJECT_PREFIX = 'subject_prefix'; + private MailSignatureService $signature_service; public int $user_id; private string $table_mail; private string $table_mail_saved; - /** @var array|null */ protected ?array $mail_data = []; private bool $save_in_sentbox; @@ -47,13 +47,14 @@ class ilMail 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 = []; /** @var array */ private array $mail_options_by_usr_id_map = []; - /** @var array */ + /** @var array */ private array $user_instances_by_id_map = []; private int $max_recipient_character_length = 998; private readonly Conductor $legal_documents; @@ -231,7 +232,6 @@ public function formatNamesForOutput(string $recipients): string $user = $this->getUserInstanceById($usr_id); if ($user) { $names[] = $user->getFullname() . ' [' . $recipient . ']'; - continue; } } @@ -424,6 +424,9 @@ public function moveMailsToFolder(array $mail_ids, int $folder_id): bool return $affected_rows > 0; } + /** + * @param list $mailIds + */ public function deleteMails(array $mail_ids): void { $mail_ids = array_filter(array_map('intval', $mail_ids)); @@ -599,7 +602,7 @@ public function scheduledMail( $this->db->insert($this->table_mail, $mail_values); } else { $this->db->update($this->table_mail, $mail_values, [ - 'mail_id' => [ilDBConstants::T_INTEGER, $outbox_id], + 'mail_id' => [ilDBConstants::T_INTEGER, $outbox_id], ]); } @@ -707,6 +710,7 @@ private function replacePlaceholders( return $message; } + private function distributeMail(MailDeliveryData $mail_data): bool { $this->auto_responder_service->emptyAutoresponderData(); @@ -772,7 +776,7 @@ private function sendMailWithReplacedEmptyPlaceholder( } /** - * @param list $to_usr_ids + * @param list $to_usr_ids * @param list $cc_bcc_recipients */ private function sendMailWithoutReplacedPlaceholder( @@ -809,7 +813,6 @@ private function sendChanneledMails( 'Skipped recipient with id %s (User not found)', $recipient->getUserId() )); - continue; } @@ -820,7 +823,6 @@ private function sendChanneledMails( $recipient->getUserId(), is_string($can_read_internal->error()) ? $can_read_internal->error() : $can_read_internal->error()->getMessage() )); - continue; } @@ -835,7 +837,6 @@ private function sendChanneledMails( $recipient->getUserId(), implode(', ', $email_addresses) )); - continue; } @@ -923,6 +924,9 @@ private function sendChanneledMails( } } + /** + * @param array $usr_id_to_external_email_addresses_map + */ private function delegateExternalEmails( string $subject, MailAttachments $attachments, @@ -989,7 +993,7 @@ private function delegateExternalEmails( } /** - * @param list $recipients + * @param list $recipients * @return list */ private function getUserIds(array $recipients): array @@ -1049,7 +1053,6 @@ private function checkRecipients(string $recipients): array } } catch (Exception $e) { $position = strpos($e->getMessage(), ':'); - throw new ilMailException( ($position === false) ? $e->getMessage() : substr($e->getMessage(), $position + 2), $e->getCode(), @@ -1614,11 +1617,11 @@ private function getCountRecipients( string $bcc_recipients, bool $only_external_addresses = true ): int { - return + return ( $this->getCountRecipient($to_recipients, $only_external_addresses) + $this->getCountRecipient($cc_recipients, $only_external_addresses) + $this->getCountRecipient($bcc_recipients, $only_external_addresses) - ; + ); } private function getEmailRecipients(string $recipients): string @@ -1638,7 +1641,7 @@ public static function _getAutoGeneratedMessageString(?ilLanguage $lang = null): { global $DIC; - if (!$lang instanceof ilLanguage) { + if (!($lang instanceof ilLanguage)) { $lang = ilLanguageFactory::_getLanguage(); } @@ -1660,7 +1663,7 @@ public static function _getIliasMailerName(): string } /** - * @return bool|self + * @return self|bool */ public function appendInstallationSignature(?bool $a_flag = null) { @@ -1669,14 +1672,12 @@ public function appendInstallationSignature(?bool $a_flag = null) } $this->append_installation_signature = $a_flag; - return $this; } public static function _getInstallationSignature(): string { global $DIC; - return $DIC->mail()->signature()->installation(); } diff --git a/components/ILIAS/Wiki/classes/class.ilWikiUtil.php b/components/ILIAS/Wiki/classes/class.ilWikiUtil.php index dcec6c0986e2..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: * @@ -30,8 +32,6 @@ * */ // From include/Unicode/UtfNormal.php -use ILIAS\Mail\Attachments\MailAttachments; - if (!defined('UTF8_REPLACEMENT')) { define('UTF8_REPLACEMENT', "\xef\xbf\xbd" /*codepointToUtf8( UNICODE_REPLACEMENT )*/); } @@ -630,7 +630,7 @@ public static function sendNotification( "", $subject, $message, - MailAttachments::empty() + array() );*/ $message .= ilMail::_getInstallationSignature(); $mails[] = new ilMailValueObject( From 294bcd0c7aa4e72e7f407354bb9532b8ce2d2ccd Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Thu, 22 Oct 2026 14:27:29 +0200 Subject: [PATCH 42/43] [Refactoring] Mail: Remove the unused form attachment trait from the attachment pool --- components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php b/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php index 616aca51ada6..0456382a4661 100644 --- a/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php +++ b/components/ILIAS/Mail/classes/class.ilMailAttachmentGUI.php @@ -34,8 +34,6 @@ class ilMailAttachmentGUI extends AbstractCtrlAwareUploadHandler implements ilCtrlSecurityInterface, MailAttachmentCommands { - use FileDataRCHandling; - private readonly ilGlobalTemplateInterface $tpl; private readonly ilLanguage $lng; private readonly ilObjUser $user; From b36a1abf2338756ea4a3bfd6b01b5dab157d2e8a Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Thu, 22 Oct 2026 14:33:16 +0200 Subject: [PATCH 43/43] [Refactoring] Mail: Drop parentheses around new in member access - PHP 8.4 allows new Foo()->method() without wrapping the instantiation --- components/ILIAS/Mail/classes/class.ilMail.php | 2 +- .../ILIAS/Mail/classes/class.ilMailFormUploadHandlerGUI.php | 2 +- .../ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php | 2 +- components/ILIAS/Mail/tests/ilFileDataMailPoolTest.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/components/ILIAS/Mail/classes/class.ilMail.php b/components/ILIAS/Mail/classes/class.ilMail.php index 2d0b21e90462..0469c0b733af 100755 --- a/components/ILIAS/Mail/classes/class.ilMail.php +++ b/components/ILIAS/Mail/classes/class.ilMail.php @@ -442,7 +442,7 @@ public function deleteMails(array $mail_ids): void public function fetchMailData(?array $row): ?array { - $row = (new MailRecordMapper())->normalizeRow($row); + $row = new MailRecordMapper()->normalizeRow($row); if ($row === null) { return null; } diff --git a/components/ILIAS/Mail/classes/class.ilMailFormUploadHandlerGUI.php b/components/ILIAS/Mail/classes/class.ilMailFormUploadHandlerGUI.php index f48ca586053c..03d6d93272fb 100644 --- a/components/ILIAS/Mail/classes/class.ilMailFormUploadHandlerGUI.php +++ b/components/ILIAS/Mail/classes/class.ilMailFormUploadHandlerGUI.php @@ -57,7 +57,7 @@ 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( + new ilFormatMail($this->user_id)->saveAttachments( MailAttachments::fromIrss($this->mailFiles()->addResourceToStage($i)) ); $status = HandlerResultInterface::STATUS_OK; diff --git a/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php b/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php index a86bda776803..4778db883721 100644 --- a/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php +++ b/components/ILIAS/Mail/tests/MigrateMailAttachmentsToIRSSUnitTest.php @@ -438,7 +438,7 @@ public function testAbortIsReportedWithItsCauseAndRethrown(): void public function testMailAttachmentsAreConfidential(): void { - $this->assertTrue((new ilMailAttachmentStakeholder(6))->areNewResourcesConfidential()); + $this->assertTrue(new ilMailAttachmentStakeholder(6)->areNewResourcesConfidential()); } public function testResolveOwnerIdForPathUsesSenderIdAndFallsBackToSystemUser(): void diff --git a/components/ILIAS/Mail/tests/ilFileDataMailPoolTest.php b/components/ILIAS/Mail/tests/ilFileDataMailPoolTest.php index 1b4538e57907..c17b89a7c5b4 100644 --- a/components/ILIAS/Mail/tests/ilFileDataMailPoolTest.php +++ b/components/ILIAS/Mail/tests/ilFileDataMailPoolTest.php @@ -162,7 +162,7 @@ static function (string $rid) use ($known_rids): ?ResourceIdentification { private function newFileDataMail(): ilFileDataMail { - return (new ReflectionClass(ilFileDataMail::class))->newInstanceWithoutConstructor(); + return new ReflectionClass(ilFileDataMail::class)->newInstanceWithoutConstructor(); } private function injectIrss(IRSS $irss, ilFileDataMail $file_data_mail): void