Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
106 changes: 97 additions & 9 deletions src/PhpImap/Mailbox.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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.
*
Expand Down
148 changes: 131 additions & 17 deletions tests/unit/MailboxTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,39 @@ public function testDownloadAttachmentTreatsMixedCaseRfc822DispositionAsEmlAttac
$this->assertFalse($attachment->emlOrigin);
}

/**
* @return array<string, array{0:int}>
*/
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<string, array{0:string, 1:string}>
*/
Expand All @@ -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 {
Expand All @@ -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.
*
Expand Down Expand Up @@ -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,
];
}
}
Loading