diff --git a/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php b/components/ILIAS/Mail/classes/Attachments/FileDataRCHandling.php index 8e5d242d0b73..581e6440291a 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,39 +70,31 @@ 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 */ - protected function handleAttachments(array $attachments): array + protected function handleAttachments(array $attachments): ResourceCollectionIdentification { - $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 0fed31891775..64f5aca1cadf 100644 --- a/components/ILIAS/Mail/classes/Folder/OutboxDatabaseRepository.php +++ b/components/ILIAS/Mail/classes/Folder/OutboxDatabaseRepository.php @@ -20,6 +20,7 @@ namespace ILIAS\Mail\Folder; +use ILIAS\Mail\Attachments\MailAttachments; use Generator; use DateTimeZone; use ilDBConstants; @@ -78,7 +79,7 @@ public function getOutboxMails(): Generator $row['rcp_bcc'], $row['m_subject'], $row['m_message'], - $row['attachments'], + $row['attachments'] ?? MailAttachments::empty(), (bool) ($row['use_placeholders'] ?? false), isset($row['mail_id']) ? (int) $row['mail_id'] : null ); diff --git a/components/ILIAS/Mail/classes/MailDeliveryData.php b/components/ILIAS/Mail/classes/MailDeliveryData.php index 26c42c8eb2a1..5c410bb90d0c 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 ) { @@ -57,7 +59,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 b8a8ba9feeb5..6f4244bb6f50 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 { @@ -51,9 +51,8 @@ class MailBoxQuery private MailBoxOrderColumn $order_column = self::DEFAULT_ORDER_COLUMN; private string $order_direction = self::DEFAULT_ORDER_DIRECTION; - public function __construct( - private readonly int $user_id, - ) { + public function __construct(private readonly int $user_id) + { global $DIC; $this->db = $DIC->database(); } @@ -278,11 +277,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/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 97eab62834c2..0f8e867cab3d 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/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 2b4f44873b79..e1ff17448f61 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 path FROM mail_attachment WHERE mail_id = %s', ['integer'], @@ -375,6 +391,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); @@ -387,18 +414,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') @@ -495,6 +560,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( @@ -522,6 +588,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( ' @@ -547,6 +633,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); @@ -600,4 +692,308 @@ 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 getIrssMimeAttachments(ResourceCollectionIdentification $rcid): array + { + $attachments = []; + foreach ($this->getAssignedRidStrings($rcid) as $rid) { + $resource_identification = $this->irss->manage()->find($rid); + if ($resource_identification === null) { + continue; + } + + $info = $this->irss->manage()->getCurrentRevision($resource_identification)->getInformation(); + $attachments[] = [ + 'rid' => $rid, + 'name' => $info->getTitle(), + ]; + } + + return $attachments; + } } diff --git a/components/ILIAS/Mail/classes/class.ilMail.php b/components/ILIAS/Mail/classes/class.ilMail.php index a3c04849cf89..c6137be204dc 100755 --- a/components/ILIAS/Mail/classes/class.ilMail.php +++ b/components/ILIAS/Mail/classes/class.ilMail.php @@ -24,8 +24,9 @@ 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\ResourceStorage\Identification\ResourceIdentification; class ilMail { @@ -399,11 +400,8 @@ public function fetchMailData(?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; } @@ -458,12 +456,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, @@ -479,7 +474,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], @@ -520,7 +515,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()], @@ -551,7 +546,7 @@ public function scheduledMail( private function sendInternalMail( int $folder_id, int $sender_usr_id, - array $attachments, + MailAttachments $attachments, string $to, string $cc, string $bcc, @@ -577,7 +572,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], @@ -820,9 +815,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( @@ -833,13 +830,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 { @@ -973,9 +966,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, @@ -983,13 +973,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, @@ -997,7 +988,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], @@ -1030,18 +1021,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; @@ -1059,8 +1045,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); @@ -1116,7 +1105,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, @@ -1163,9 +1152,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( @@ -1254,11 +1250,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, @@ -1279,16 +1272,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)); @@ -1317,32 +1307,59 @@ function (string $message): string { $mailer->Bcc($bcc); } - foreach ($attachments as $attachment) { - $mailer->Attach( - $this->mail_file_data->getAbsoluteAttachmentPoolPathByFilename($attachment), - '', - 'inline', - $attachment - ); + if ($attachments->isIrss()) { + foreach ($this->mail_file_data->getIrssMimeAttachments($attachments->rcid()) as $attachment) { + $mailer->AttachResource( + new ResourceIdentification($attachment['rid']), + $attachment['name'] + ); + } + } 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 2eb886d7e37c..c887130ffd4a 100755 --- a/components/ILIAS/Mail/classes/class.ilMailFolderGUI.php +++ b/components/ILIAS/Mail/classes/class.ilMailFolderGUI.php @@ -28,8 +28,8 @@ use ILIAS\Mail\Folder\MailFolderSearch; use ILIAS\Mail\Folder\MailFolderTableUI; use ILIAS\Mail\Folder\MailFolderData; -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 @@ -929,12 +929,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()); @@ -1167,7 +1175,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); } @@ -1186,18 +1198,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') { @@ -1206,25 +1238,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 4f9c2878c016..d00bb6ce97d1 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; @@ -69,7 +70,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 = ''; @@ -229,9 +230,9 @@ 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->handleAttachments($form_values['attachments']); + $attachments = MailAttachments::fromIrss($this->handleAttachments($form_values['attachments'])); } $rcp_to = ''; @@ -276,7 +277,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 ), @@ -323,9 +324,9 @@ public function sendMessage(): void return; } - $files = []; + $attachments = MailAttachments::empty(); if (count($value['attachments']) > 0) { - $files = $this->handleAttachments($value['attachments']); + $attachments = MailAttachments::fromIrss($this->handleAttachments($value['attachments'])); } $mailer = $this->umail @@ -355,7 +356,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(); @@ -419,9 +420,9 @@ 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->handleAttachments($value['attachments']); + $attachments = MailAttachments::fromIrss($this->handleAttachments($value['attachments'])); } $draft_folder_id = $this->mbox->getDraftsFolder(); @@ -431,7 +432,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; @@ -446,7 +447,7 @@ public function saveDraft(): void $this->umail->updateDraft( $draft_folder_id, - $files, + $attachments, $rcp_to, $rcp_cc, $rcp_bcc, @@ -711,8 +712,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']); @@ -731,15 +738,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; @@ -841,8 +853,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; } @@ -919,10 +935,11 @@ private function saveMailBeforeSearch(?array $input_results = null): void $result = $input_results; } - $resource_collection_id = null; + $stage_attachments = null; if (!empty($result['attachments']->getValue())) { - $files = $this->handleAttachments($result['attachments']->getValue()); - $resource_collection_id = $this->getIdforCollection($files); + $stage_attachments = MailAttachments::fromIrss( + $this->handleAttachments($result['attachments']->getValue()) + ); } $rcp_to = implode(',', $result['rcp_to']->getValue() ?? []); @@ -936,7 +953,7 @@ private function saveMailBeforeSearch(?array $input_results = null): void $rcp_bcc, ilUtil::securePlainString($result['m_subject']->getValue()), ilUtil::securePlainString($result['m_message']->getValue()), - $resource_collection_id, + $stage_attachments, (bool) $result['use_placeholders']->getValue(), ilMailFormCall::getContextId(), ilMailFormCall::getContextParameters() @@ -982,6 +999,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( @@ -1014,17 +1032,23 @@ protected function buildFormElements(?array $mail_data): array } } - $has_files = !empty($mail_data['attachments']); $attachments = $ff->file( $this->upload_handler, $this->lng->txt('attachments') )->withMaxFiles(10); - 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.ilMimeMail.php b/components/ILIAS/Mail/classes/class.ilMimeMail.php index 9e80aee45fb7..4c31635c85f0 100755 --- a/components/ILIAS/Mail/classes/class.ilMimeMail.php +++ b/components/ILIAS/Mail/classes/class.ilMimeMail.php @@ -19,7 +19,9 @@ declare(strict_types=1); use ILIAS\Data\Factory; +use ILIAS\Mail\Mime\MailMimeAttachment; use ILIAS\Refinery\Factory as Refinery; +use ILIAS\ResourceStorage\Identification\ResourceIdentification; class ilMimeMail { @@ -47,14 +49,8 @@ class ilMimeMail protected array $abcc = []; /** @var array */ protected array $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 readonly Refinery $refinery; /** @var Closure(string): string|null */ private ?Closure $to_html_transformation = null; @@ -195,35 +191,53 @@ public function Attach( string $disposition = 'inline', ?string $display_name = null ): void { - if ($file_type === '') { - $file_type = 'application/octet-stream'; - } + $this->mime_attachments[] = MailMimeAttachment::fromPath( + $filename, + $file_type, + $disposition, + $display_name + ); + } - $this->aattach[] = $filename; - $this->actype[] = $file_type; - $this->adispo[] = $disposition; - $this->adisplay[] = $display_name; + public function AttachResource( + ResourceIdentification $resource_identification, + string $display_name, + string $file_type = '', + string $disposition = 'inline' + ): void { + $this->mime_attachments[] = MailMimeAttachment::fromResource( + $resource_identification, + $display_name, + $file_type, + $disposition + ); + } + + /** + * @return list + */ + public function getMimeAttachments(): array + { + return $this->mime_attachments; } /** * @return array{path: string, name: string}[] + * @deprecated Use getMimeAttachments() for path and IRSS attachments. */ public function getAttachments(): array { $attachments = []; - $i = 0; - foreach ($this->aattach as $attachment) { - $name = ''; - if (isset($this->adisplay[$i]) && is_string($this->adisplay[$i]) && $this->adisplay[$i] !== '') { - $name = $this->adisplay[$i]; + foreach ($this->mime_attachments as $attachment) { + if ($attachment->isResource() || $attachment->getPath() === null) { + continue; } $attachments[] = [ - 'path' => $attachment, - 'name' => $name + 'path' => $attachment->getPath(), + 'name' => $attachment->getDisplayName(), ]; - ++$i; } return $attachments; diff --git a/components/ILIAS/Mail/classes/class.ilObjMailGUI.php b/components/ILIAS/Mail/classes/class.ilObjMailGUI.php index 20093d1c0aea..bd29e1722d56 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 af1b17589623..40e4db495006 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);