diff --git a/README.md b/README.md index 74b3d220..99aaf4de 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ You can run all PHPUnit tests by running the following command (inside of the in Below, you'll find an example code how you can use this library. For further information and other examples, you may take a look at the [wiki](https://github.com/barbushin/php-imap/wiki). -By default, this library uses random filenames for attachments as identical file names from other emails would overwrite other attachments. If you want to keep the original file name, you can set the attachment filename mode to ``true``, but then you also need to ensure, that those files don't get overwritten by other emails for example. +By default, this library uses random filenames for attachments as identical file names from other emails would overwrite other attachments. If you want to keep the original file name, you can set the attachment filename mode to `true`. For backward compatibility, this still overwrites an existing file with the same name. If you want to keep the original file name and automatically suffix duplicates with ` (1)`, ` (2)`, and so on, set the attachment filename collision mode to `PhpImap\Mailbox::ATTACHMENT_FILENAME_COLLISION_SUFFIX`. ```php // Create PhpImap\Mailbox instance for all further actions @@ -96,6 +96,10 @@ $mailbox = new PhpImap\Mailbox( false // Attachment filename mode (optional; false = random filename; true = original filename) ); +$mailbox->setAttachmentFilenameCollisionMode( + PhpImap\Mailbox::ATTACHMENT_FILENAME_COLLISION_SUFFIX +); + // set some connection arguments (if appropriate) $mailbox->setConnectionArgs( CL_EXPUNGE // expunge deleted mails upon mailbox close diff --git a/src/PhpImap/Mailbox.php b/src/PhpImap/Mailbox.php index e1a20dc0..b621aa92 100644 --- a/src/PhpImap/Mailbox.php +++ b/src/PhpImap/Mailbox.php @@ -41,8 +41,6 @@ use const OP_SECURE; use const OP_SHORTCACHE; use const OP_SILENT; -use const PATHINFO_EXTENSION; - use PhpImap\Exceptions\ConnectionException; use PhpImap\Exceptions\InvalidParameterException; @@ -105,6 +103,10 @@ class Mailbox public const AUTHENTICATION_TYPE_OAUTH = 'oauth'; + public const ATTACHMENT_FILENAME_COLLISION_OVERWRITE = 1; + + public const ATTACHMENT_FILENAME_COLLISION_SUFFIX = 2; + public const IMAP_OPTIONS_SUPPORTED_VALUES = OP_READONLY // 2 | OP_ANONYMOUS // 4 @@ -218,9 +220,12 @@ class Mailbox /** @var string */ protected $mailboxFolder; - /** @var bool|false */ + /** @var bool */ protected $attachmentFilenameMode = false; + /** @var int */ + protected $attachmentFilenameCollisionMode = self::ATTACHMENT_FILENAME_COLLISION_OVERWRITE; + /** @var resource|null */ private $imapStream; @@ -351,6 +356,41 @@ public function setAttachmentFilenameMode(bool $attachmentFilenameMode): void $this->attachmentFilenameMode = $attachmentFilenameMode; } + /** + * Returns the current collision handling mode for original attachment filenames. + * + * @return int Attachment filename collision mode + * + * @psalm-return 1|2 + */ + public function getAttachmentFilenameCollisionMode(): int + { + return $this->attachmentFilenameCollisionMode; + } + + /** + * Sets / Changes the collision handling mode for original attachment filenames. + * + * @param int $attachmentFilenameCollisionMode Attachment filename collision mode + * + * @psalm-param 1|2 $attachmentFilenameCollisionMode + * + * @throws InvalidParameterException + */ + public function setAttachmentFilenameCollisionMode(int $attachmentFilenameCollisionMode): void + { + $supported_modes = [ + self::ATTACHMENT_FILENAME_COLLISION_OVERWRITE, + self::ATTACHMENT_FILENAME_COLLISION_SUFFIX, + ]; + + if (!\in_array($attachmentFilenameCollisionMode, $supported_modes, true)) { + throw new InvalidParameterException('"'.$attachmentFilenameCollisionMode.'" is not supported by setAttachmentFilenameCollisionMode(). Supported modes are ATTACHMENT_FILENAME_COLLISION_OVERWRITE and ATTACHMENT_FILENAME_COLLISION_SUFFIX.'); + } + + $this->attachmentFilenameCollisionMode = $attachmentFilenameCollisionMode; + } + /** * Returns the current set IMAP search option. * @@ -1491,18 +1531,16 @@ public function downloadAttachment(DataPartInfo $dataInfo, array $params, object if (null != $attachmentsDir) { if (true == $this->getAttachmentFilenameMode()) { - $fileSysName = $this->sanitizeAttachmentFileSystemName($attachment->name); + $fileSysName = $this->resolveAttachmentFileSystemName( + $attachmentsDir, + $this->sanitizeAttachmentFileSystemName($attachment->name) + ); } else { $fileSysName = \bin2hex(\random_bytes(16)).'.bin'; } $filePath = $attachmentsDir.DIRECTORY_SEPARATOR.$fileSysName; - if (\strlen($filePath) > self::MAX_LENGTH_FILEPATH) { - $ext = \pathinfo($filePath, PATHINFO_EXTENSION); - $filePath = \substr($filePath, 0, self::MAX_LENGTH_FILEPATH - 1 - \strlen($ext)).'.'.$ext; - } - $attachment->setFilePath($filePath); $attachment->saveToDisk(); } @@ -1786,6 +1824,56 @@ protected function sanitizeAttachmentFileSystemName(string $fileName): string ]); } + protected function resolveAttachmentFileSystemName(string $attachmentsDir, string $fileSystemName): string + { + if (self::ATTACHMENT_FILENAME_COLLISION_SUFFIX !== $this->getAttachmentFilenameCollisionMode()) { + return $this->fitAttachmentFileSystemNameToPathLimit($attachmentsDir, $fileSystemName); + } + + $collisionIndex = 0; + + do { + $suffix = 0 === $collisionIndex ? '' : ' ('.$collisionIndex.')'; + $candidate = $this->fitAttachmentFileSystemNameToPathLimit($attachmentsDir, $fileSystemName, $suffix); + ++$collisionIndex; + } while (\file_exists($attachmentsDir.DIRECTORY_SEPARATOR.$candidate)); + + return $candidate; + } + + protected function fitAttachmentFileSystemNameToPathLimit(string $attachmentsDir, string $fileSystemName, string $suffix = ''): string + { + [$fileName, $fileExtension] = $this->splitAttachmentFileSystemName($fileSystemName); + + $maxFileNameLength = self::MAX_LENGTH_FILEPATH + - \strlen($attachmentsDir.DIRECTORY_SEPARATOR) + - \strlen($suffix) + - \strlen($fileExtension); + + if (\strlen($fileName) > $maxFileNameLength) { + $fileName = \substr($fileName, 0, \max(1, $maxFileNameLength)); + } + + return $fileName.$suffix.$fileExtension; + } + + /** + * @return array{0:string, 1:string} + */ + protected function splitAttachmentFileSystemName(string $fileSystemName): array + { + $lastDotPosition = \strrpos($fileSystemName, '.'); + + if (false === $lastDotPosition || 0 === $lastDotPosition) { + return [$fileSystemName, '']; + } + + return [ + \substr($fileSystemName, 0, $lastDotPosition), + \substr($fileSystemName, $lastDotPosition), + ]; + } + /** * Returns the list of available encodings in lower case. * diff --git a/tests/unit/MailboxTest.php b/tests/unit/MailboxTest.php index 1ff851b5..1fbda40c 100644 --- a/tests/unit/MailboxTest.php +++ b/tests/unit/MailboxTest.php @@ -464,6 +464,39 @@ public function testDownloadAttachmentTreatsMixedCaseRfc822DispositionAsEmlAttac $this->assertFalse($attachment->emlOrigin); } + /** + * @return array + */ + public function attachmentFilenameCollisionModeProvider(): array + { + return [ + 'overwrite' => [Mailbox::ATTACHMENT_FILENAME_COLLISION_OVERWRITE], + 'suffix' => [Mailbox::ATTACHMENT_FILENAME_COLLISION_SUFFIX], + ]; + } + + /** + * @dataProvider attachmentFilenameCollisionModeProvider + */ + public function testSetAndGetAttachmentFilenameCollisionMode(int $attachmentFilenameCollisionMode): void + { + $mailbox = $this->getMailbox(); + + $mailbox->setAttachmentFilenameCollisionMode($attachmentFilenameCollisionMode); + + $this->assertSame($attachmentFilenameCollisionMode, $mailbox->getAttachmentFilenameCollisionMode()); + } + + public function testSetAttachmentFilenameCollisionModeRejectsUnsupportedValue(): void + { + $mailbox = $this->getMailbox(); + + $this->expectException(InvalidParameterException::class); + $this->expectExceptionMessage('"3" is not supported by setAttachmentFilenameCollisionMode(). Supported modes are ATTACHMENT_FILENAME_COLLISION_OVERWRITE and ATTACHMENT_FILENAME_COLLISION_SUFFIX.'); + + $mailbox->setAttachmentFilenameCollisionMode(3); + } + /** * @return array */ @@ -483,23 +516,9 @@ public function testDownloadAttachmentSanitizesFilePathWhenUsingOriginalFilename $attachmentsDir = \sys_get_temp_dir().DIRECTORY_SEPARATOR.'php-imap-attachment-name-'.\bin2hex(\random_bytes(8)); \mkdir($attachmentsDir); - $mailbox = new class($this->imapPath, $this->login, $this->password, $attachmentsDir, $this->serverEncoding, true, true) extends Fixtures\Mailbox { - public function decodeMimeStr(string $string): string - { - return $string; - } - }; - $dataInfo = new Fixtures\DataPartInfo($mailbox, 1, '2', 0, 0); - $dataInfo->setData('attachment body'); - $partStructure = (object) [ - 'type' => 3, - 'subtype' => 'OCTET-STREAM', - 'bytes' => 15, - 'encoding' => 0, - 'ifid' => 0, - 'ifsubtype' => 1, - 'ifdescription' => 0, - ]; + $mailbox = $this->getAttachmentDownloadMailbox($attachmentsDir); + $dataInfo = $this->getAttachmentDownloadDataInfo($mailbox); + $partStructure = $this->getAttachmentDownloadPartStructure(); $attachmentPath = null; try { @@ -520,6 +539,70 @@ public function decodeMimeStr(string $string): string } } + public function testDownloadAttachmentOverwritesExistingFileByDefaultWhenUsingOriginalFilenameMode(): void + { + $attachmentsDir = \sys_get_temp_dir().DIRECTORY_SEPARATOR.'php-imap-attachment-name-'.\bin2hex(\random_bytes(8)); + \mkdir($attachmentsDir); + + $mailbox = $this->getAttachmentDownloadMailbox($attachmentsDir); + $dataInfo = $this->getAttachmentDownloadDataInfo($mailbox); + $partStructure = $this->getAttachmentDownloadPartStructure(); + $existingPath = $attachmentsDir.DIRECTORY_SEPARATOR.'report.txt'; + + \file_put_contents($existingPath, 'existing body'); + + try { + $attachment = $mailbox->downloadAttachment($dataInfo, ['filename' => 'report.txt'], $partStructure); + + $this->assertSame($existingPath, $attachment->filePath); + $this->assertSame('attachment body', \file_get_contents($existingPath)); + } finally { + if (\file_exists($existingPath)) { + \unlink($existingPath); + } + + if (\is_dir($attachmentsDir)) { + \rmdir($attachmentsDir); + } + } + } + + public function testDownloadAttachmentAddsSuffixWhenConfiguredToAvoidFilenameCollisions(): void + { + $attachmentsDir = \sys_get_temp_dir().DIRECTORY_SEPARATOR.'php-imap-attachment-name-'.\bin2hex(\random_bytes(8)); + \mkdir($attachmentsDir); + + $mailbox = $this->getAttachmentDownloadMailbox($attachmentsDir); + $mailbox->setAttachmentFilenameCollisionMode(Mailbox::ATTACHMENT_FILENAME_COLLISION_SUFFIX); + + $dataInfo = $this->getAttachmentDownloadDataInfo($mailbox); + $partStructure = $this->getAttachmentDownloadPartStructure(); + $existingPath = $attachmentsDir.DIRECTORY_SEPARATOR.'foo_bar.txt'; + $expectedPath = $attachmentsDir.DIRECTORY_SEPARATOR.'foo_bar (1).txt'; + + \file_put_contents($existingPath, 'existing body'); + + try { + $attachment = $mailbox->downloadAttachment($dataInfo, ['filename' => 'foo/bar.txt'], $partStructure); + + $this->assertSame($expectedPath, $attachment->filePath); + $this->assertSame('existing body', \file_get_contents($existingPath)); + $this->assertSame('attachment body', \file_get_contents($expectedPath)); + } finally { + if (\file_exists($expectedPath)) { + \unlink($expectedPath); + } + + if (\file_exists($existingPath)) { + \unlink($existingPath); + } + + if (\is_dir($attachmentsDir)) { + \rmdir($attachmentsDir); + } + } + } + /** * Provides test data for testing encoding. * @@ -938,4 +1021,35 @@ protected function getMailbox(): Fixtures\Mailbox { return new Fixtures\Mailbox($this->imapPath, $this->login, $this->password, $this->attachmentsDir, $this->serverEncoding); } + + protected function getAttachmentDownloadMailbox(string $attachmentsDir): Fixtures\Mailbox + { + return new class($this->imapPath, $this->login, $this->password, $attachmentsDir, $this->serverEncoding, true, true) extends Fixtures\Mailbox { + public function decodeMimeStr(string $string): string + { + return $string; + } + }; + } + + protected function getAttachmentDownloadDataInfo(Fixtures\Mailbox $mailbox): Fixtures\DataPartInfo + { + $dataInfo = new Fixtures\DataPartInfo($mailbox, 1, '2', 0, 0); + $dataInfo->setData('attachment body'); + + return $dataInfo; + } + + protected function getAttachmentDownloadPartStructure(): object + { + return (object) [ + 'type' => 3, + 'subtype' => 'OCTET-STREAM', + 'bytes' => 15, + 'encoding' => 0, + 'ifid' => 0, + 'ifsubtype' => 1, + 'ifdescription' => 0, + ]; + } }