diff --git a/.gitignore b/.gitignore index 4cac0a21..a7fc91d6 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ nbproject tmp/ clover.xml +composer.lock coveralls-upload.json phpunit.xml vendor diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..982a2855 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +All notable changes to this project will be documented in this file, in reverse chronological order by release. + +## 2.4.8 - 2015-09-10 + +### Added + +- Nothing. + +### Deprecated + +- Nothing. + +### Removed + +- Nothing. + +### Fixed + +- [#26](https://github.com/zendframework/zend-mail/pull/26) fixes the + `ContentType` header to properly handle parameters with encoded values. +- [#11](https://github.com/zendframework/zend-mail/pull/11) fixes the + behavior of the `Sender` header, ensuring it can handle domains that do not + contain a TLD, as well as addresses referencing mailboxes (no domain). +- [#24](https://github.com/zendframework/zend-mail/pull/24) fixes parsing of + mail messages that contain an initial blank line (prior to the headers), a + situation observed in particular with GMail. diff --git a/composer.json b/composer.json index d87d53dc..870ea9a0 100644 --- a/composer.json +++ b/composer.json @@ -14,17 +14,18 @@ }, "require": { "php": ">=5.3.23", - "zendframework/zend-crypt": "self.version", - "zendframework/zend-loader": "self.version", - "zendframework/zend-mime": "self.version", - "zendframework/zend-stdlib": "self.version", - "zendframework/zend-validator": "self.version" + "zendframework/zend-crypt": "~2.4.0", + "zendframework/zend-loader": "~2.4.0", + "zendframework/zend-mime": "~2.4.0", + "zendframework/zend-stdlib": "~2.4.0", + "zendframework/zend-validator": "~2.4.0" }, "require-dev": { - "zendframework/zend-servicemanager": "self.version", + "zendframework/zend-servicemanager": "~2.4.0", "fabpot/php-cs-fixer": "1.7.*", "satooshi/php-coveralls": "dev-master", - "phpunit/PHPUnit": "~4.0" + "phpunit/PHPUnit": "~4.0", + "zendframework/zend-config": "~2.4.0" }, "suggest": { "zendframework/zend-servicemanager": "Zend\\ServiceManager component" @@ -40,4 +41,4 @@ "ZendTest\\Mail\\": "test/" } } -} \ No newline at end of file +} diff --git a/src/Header/ContentType.php b/src/Header/ContentType.php index 32349cb3..e2efa919 100644 --- a/src/Header/ContentType.php +++ b/src/Header/ContentType.php @@ -10,14 +10,22 @@ namespace Zend\Mail\Header; use Zend\Mail\Headers; +use Zend\Mime\Mime; -class ContentType implements HeaderInterface +class ContentType implements UnstructuredInterface { /** * @var string */ protected $type; + /** + * Header encoding + * + * @var string + */ + protected $encoding = 'ASCII'; + /** * @var array */ @@ -66,6 +74,12 @@ public function getFieldValue($format = HeaderInterface::FORMAT_RAW) $values = array($prepared); foreach ($this->parameters as $attribute => $value) { + if (HeaderInterface::FORMAT_ENCODED === $format && !Mime::isPrintable($value)) { + $this->encoding = 'UTF-8'; + $value = HeaderWrap::wrap($value, $this); + $this->encoding = 'ASCII'; + } + $values[] = sprintf('%s="%s"', $attribute, $value); } @@ -74,18 +88,18 @@ public function getFieldValue($format = HeaderInterface::FORMAT_RAW) public function setEncoding($encoding) { - // This header must be always in US-ASCII + $this->encoding = $encoding; return $this; } public function getEncoding() { - return 'ASCII'; + return $this->encoding; } public function toString() { - return 'Content-Type: ' . $this->getFieldValue(); + return 'Content-Type: ' . $this->getFieldValue(HeaderInterface::FORMAT_ENCODED); } /** @@ -135,8 +149,10 @@ public function addParameter($name, $value) if (! HeaderValue::isValid($name)) { throw new Exception\InvalidArgumentException('Invalid content-type parameter name detected'); } - if (! HeaderValue::isValid($value)) { - throw new Exception\InvalidArgumentException('Invalid content-type parameter value detected'); + if (! HeaderWrap::canBeEncoded($value)) { + throw new Exception\InvalidArgumentException( + 'Parameter value must be composed of printable US-ASCII or UTF-8 characters.' + ); } $this->parameters[$name] = $value; diff --git a/src/Header/Sender.php b/src/Header/Sender.php index 3a70405c..2efc23bf 100644 --- a/src/Header/Sender.php +++ b/src/Header/Sender.php @@ -53,6 +53,8 @@ public static function fromString($headerLine) $senderName = null; } $senderEmail = $matches['email']; + } else { + $senderEmail = $value; } $header->setAddress($senderEmail, $senderName); diff --git a/src/Message.php b/src/Message.php index b5069fb9..e16c2438 100644 --- a/src/Message.php +++ b/src/Message.php @@ -517,6 +517,12 @@ protected function updateAddressList(AddressList $addressList, $emailOrAddressOr (is_object($emailOrAddressOrList) ? get_class($emailOrAddressOrList) : gettype($emailOrAddressOrList)) )); } + + if (is_string($emailOrAddressOrList) && $name === null) { + $addressList->addFromString($emailOrAddressOrList); + return; + } + $addressList->add($emailOrAddressOrList, $name); } diff --git a/src/Storage/Message.php b/src/Storage/Message.php index 726b5871..a7eb3d38 100644 --- a/src/Storage/Message.php +++ b/src/Storage/Message.php @@ -42,6 +42,8 @@ public function __construct(array $params) } else { $params['raw'] = stream_get_contents($params['file']); } + + $params['raw'] = ltrim($params['raw']); } if (!empty($params['flags'])) { diff --git a/test/Header/ContentTypeTest.php b/test/Header/ContentTypeTest.php index b9911863..81bba97f 100644 --- a/test/Header/ContentTypeTest.php +++ b/test/Header/ContentTypeTest.php @@ -10,37 +10,21 @@ namespace ZendTest\Mail\Header; use Zend\Mail\Header\ContentType; +use Zend\Mail\Header\Exception\InvalidArgumentException; +use Zend\Mail\Header\HeaderInterface; +use Zend\Mail\Header\UnstructuredInterface; /** * @group Zend_Mail */ class ContentTypeTest extends \PHPUnit_Framework_TestCase { - public function testContentTypeFromStringCreatesValidContentTypeHeader() + public function testImplementsHeaderInterface() { - $contentTypeHeader = ContentType::fromString('Content-Type: xxx/yyy'); - $this->assertInstanceOf('Zend\Mail\Header\HeaderInterface', $contentTypeHeader); - $this->assertInstanceOf('Zend\Mail\Header\ContentType', $contentTypeHeader); - } - - public function testContentTypeGetFieldNameReturnsHeaderName() - { - $contentTypeHeader = new ContentType(); - $this->assertEquals('Content-Type', $contentTypeHeader->getFieldName()); - } + $header = new ContentType(); - public function testContentTypeGetFieldValueReturnsProperValue() - { - $contentTypeHeader = new ContentType(); - $contentTypeHeader->setType('foo/bar'); - $this->assertEquals('foo/bar', $contentTypeHeader->getFieldValue()); - } - - public function testContentTypeToStringReturnsHeaderFormattedString() - { - $contentTypeHeader = new ContentType(); - $contentTypeHeader->setType('foo/bar'); - $this->assertEquals("Content-Type: foo/bar", $contentTypeHeader->toString()); + $this->assertInstanceOf('Zend\Mail\Header\UnstructuredInterface', $header); + $this->assertInstanceOf('Zend\Mail\Header\HeaderInterface', $header); } /** @@ -55,26 +39,6 @@ public function testTrailingSemiColonFromString() $this->assertEquals(array('boundary' => 'Apple-Mail=_1B852F10-F9C6-463D-AADD-CD503A5428DD'), $params); } - public function testProvidingParametersIntroducesHeaderFolding() - { - $header = new ContentType(); - $header->setType('application/x-unit-test'); - $header->addParameter('charset', 'us-ascii'); - $string = $header->toString(); - - $this->assertContains("Content-Type: application/x-unit-test;", $string); - $this->assertContains(";\r\n charset=\"us-ascii\"", $string); - } - - public function testExtractsExtraInformationFromContentType() - { - $contentTypeHeader = ContentType::fromString( - 'Content-Type: multipart/alternative; boundary="Apple-Mail=_1B852F10-F9C6-463D-AADD-CD503A5428DD"' - ); - $params = $contentTypeHeader->getParameters(); - $this->assertEquals($params, array('boundary' => 'Apple-Mail=_1B852F10-F9C6-463D-AADD-CD503A5428DD')); - } - public function testExtractsExtraInformationWithoutBeingConfusedByTrailingSemicolon() { $header = ContentType::fromString('Content-Type: application/pdf;name="foo.pdf";'); @@ -82,53 +46,46 @@ public function testExtractsExtraInformationWithoutBeingConfusedByTrailingSemico } /** - * @group #2728 - * - * Tests setting different MIME types + * @dataProvider setTypeProvider */ - public function testSetContentType() + public function testFromString($type, $parameters, $fieldValue, $expectedToString) { - $header = new ContentType(); - - $header->setType('application/vnd.ms-excel'); - $this->assertEquals('Content-Type: application/vnd.ms-excel', $header->toString()); - - $header->setType('application/rss+xml'); - $this->assertEquals('Content-Type: application/rss+xml', $header->toString()); - - $header->setType('video/mp4'); - $this->assertEquals('Content-Type: video/mp4', $header->toString()); - - $header->setType('message/rfc822'); - $this->assertEquals('Content-Type: message/rfc822', $header->toString()); + $header = ContentType::fromString($expectedToString); + + $this->assertInstanceOf('Zend\Mail\Header\ContentType', $header); + $this->assertEquals('Content-Type', $header->getFieldName(), 'getFieldName() value not match'); + $this->assertEquals($type, $header->getType(), 'getType() value not match'); + $this->assertEquals($fieldValue, $header->getFieldValue(), 'getFieldValue() value not match'); + $this->assertEquals($parameters, $header->getParameters(), 'getParameters() value not match'); + $this->assertEquals($expectedToString, $header->toString(), 'toString() value not match'); } /** - * @group ZF2015-04 + * @dataProvider setTypeProvider */ - public function testFromStringRaisesExceptionForInvalidName() + public function testSetType($type, $parameters, $fieldValue, $expectedToString) { - $this->setExpectedException('Zend\Mail\Header\Exception\InvalidArgumentException', 'header name'); - $header = ContentType::fromString('Content-Type' . chr(32) . ': text/html'); - } + $header = new ContentType(); - public function headerLines() - { - return array( - 'newline' => array("Content-Type: text/html;\nlevel=1"), - 'cr-lf' => array("Content-Type: text/html\r\n;level=1",), - 'multiline' => array("Content-Type: text/html;\r\nlevel=1\r\nq=0.1"), - ); + $header->setType($type); + foreach ($parameters as $name => $value) { + $header->addParameter($name, $value); + } + + $this->assertEquals('Content-Type', $header->getFieldName(), 'getFieldName() value not match'); + $this->assertEquals($type, $header->getType(), 'getType() value not match'); + $this->assertEquals($fieldValue, $header->getFieldValue(), 'getFieldValue() value not match'); + $this->assertEquals($parameters, $header->getParameters(), 'getParameters() value not match'); + $this->assertEquals($expectedToString, $header->toString(), 'toString() value not match'); } /** - * @dataProvider headerLines - * @group ZF2015-04 + * @dataProvider invalidHeaderLinesProvider */ - public function testFromStringRaisesExceptionForNonFoldingMultilineValues($headerLine) + public function testFromStringThrowException($headerLine, $expectedException, $exceptionMessage) { - $this->setExpectedException('Zend\Mail\Header\Exception\InvalidArgumentException', 'header value'); - $header = ContentType::fromString($headerLine); + $this->setExpectedException($expectedException, $exceptionMessage); + ContentType::fromString($headerLine); } /** @@ -142,24 +99,66 @@ public function testFromStringHandlesContinuations() } /** - * @group ZF2015-04 + * @dataProvider invalidParametersProvider */ - public function testAddParameterRaisesInvalidArgumentExceptionForInvalidParameterName() + public function testAddParameterThrowException($paramName, $paramValue, $expectedException, $exceptionMessage) { $header = new ContentType(); $header->setType('text/html'); - $this->setExpectedException('Zend\Mail\Header\Exception\InvalidArgumentException', 'parameter name'); - $header->addParameter("b\r\na\rr\n", "baz"); + + $this->setExpectedException($expectedException, $exceptionMessage); + $header->addParameter($paramName, $paramValue); } - /** - * @group ZF2015-04 - */ - public function testAddParameterRaisesInvalidArgumentExceptionForInvalidParameterValue() + public function setTypeProvider() { - $header = new ContentType(); - $header->setType('text/html'); - $this->setExpectedException('Zend\Mail\Header\Exception\InvalidArgumentException', 'parameter value'); - $header->addParameter('foo', "\nbar\r\nbaz\r"); + $foldingHeaderLine = "Content-Type: foo/baz;\r\n charset=\"us-ascii\""; + $foldingFieldValue = "foo/baz;\r\n charset=\"us-ascii\""; + + $encodedHeaderLine = "Content-Type: foo/baz;\r\n name=\"=?UTF-8?Q?=C3=93?=\""; + $encodedFieldValue = "foo/baz;\r\n name=\"Ó\""; + + // @codingStandardsIgnoreStart + return array( + // Description => [$type, $parameters, $fieldValue, toString()] + // @group #2728 + 'foo/a.b-c' => array('foo/a.b-c', array(), 'foo/a.b-c', 'Content-Type: foo/a.b-c'), + 'foo/a+b' => array('foo/a+b' , array(), 'foo/a+b' , 'Content-Type: foo/a+b'), + 'foo/baz' => array('foo/baz' , array(), 'foo/baz' , 'Content-Type: foo/baz'), + 'parameter use header folding' => array('foo/baz' , array('charset' => 'us-ascii'), $foldingFieldValue, $foldingHeaderLine), + 'encoded characters' => array('foo/baz' , array('name' => 'Ó'), $encodedFieldValue, $encodedHeaderLine), + ); + // @codingStandardsIgnoreEnd + } + + public function invalidParametersProvider() + { + $invalidArgumentException = 'Zend\Mail\Header\Exception\InvalidArgumentException'; + + // @codingStandardsIgnoreStart + return array( + // Description => [param name, param value, expected exception, exception message contain] + + // @group ZF2015-04 + 'invalid name' => array("b\r\na\rr\n", 'baz', $invalidArgumentException, 'parameter name'), + ); + // @codingStandardsIgnoreEnd + } + + public function invalidHeaderLinesProvider() + { + $invalidArgumentException = 'Zend\Mail\Header\Exception\InvalidArgumentException'; + + // @codingStandardsIgnoreStart + return array( + // Description => [header line, expected exception, exception message contain] + + // @group ZF2015-04 + 'invalid name' => array('Content-Type' . chr(32) . ': text/html', $invalidArgumentException, 'header name'), + 'newline' => array("Content-Type: text/html;\nlevel=1", $invalidArgumentException, 'header value'), + 'cr-lf' => array("Content-Type: text/html\r\n;level=1", $invalidArgumentException, 'header value'), + 'multiline' => array("Content-Type: text/html;\r\nlevel=1\r\nq=0.1", $invalidArgumentException, 'header value'), + ); + // @codingStandardsIgnoreEnd } } diff --git a/test/Header/SenderTest.php b/test/Header/SenderTest.php index 547d9879..7d05843e 100644 --- a/test/Header/SenderTest.php +++ b/test/Header/SenderTest.php @@ -31,7 +31,7 @@ public function testGetFieldNameReturnsHeaderName() } /** - * @dataProvider validSenderDataProvider + * @dataProvider validSenderHeaderDataProvider * @group ZF2015-04 * @param string $email * @param null|string $name @@ -39,7 +39,7 @@ public function testGetFieldNameReturnsHeaderName() * @param string $encodedValue * @param string $encoding */ - public function testParseValidSenderHeader($email, $name, $expectedFieldValue, $encodedValue, $encoding) + public function testParseValidSenderHeader($expectedFieldValue, $encodedValue, $encoding) { $header = Header\Sender::fromString('Sender:' . $encodedValue); @@ -145,6 +145,20 @@ public function validSenderDataProvider() ); } + public function validSenderHeaderDataProvider() + { + return array_merge(array_map(function ($parameters) { + return array_slice($parameters, 2); + }, $this->validSenderDataProvider()), array( + // Per RFC 2822, 3.4 and 3.6.2, "Sender: foo@bar" is valid. + 'Unbracketed email' => array( + '', + 'foo@bar', + 'ASCII' + ) + )); + } + public function invalidSenderDataProvider() { $mailInvalidArgumentException = 'Zend\Mail\Exception\InvalidArgumentException'; diff --git a/test/MessageTest.php b/test/MessageTest.php index 5259f3d3..82ab03e4 100644 --- a/test/MessageTest.php +++ b/test/MessageTest.php @@ -199,6 +199,16 @@ public function testCanAddFromAddressUsingName() $this->assertEquals('ZF DevTeam', $address->getName()); } + public function testCanAddFromAddressUsingEmailAndNameAsString() + { + $this->message->addFrom('ZF DevTeam '); + $addresses = $this->message->getFrom(); + $this->assertEquals(1, count($addresses)); + $address = $addresses->current(); + $this->assertEquals('zf-devteam@example.com', $address->getEmail()); + $this->assertEquals('ZF DevTeam', $address->getName()); + } + public function testCanAddFromAddressUsingAddressObject() { $address = new Address('zf-devteam@example.com', 'ZF DevTeam'); diff --git a/test/Storage/MessageTest.php b/test/Storage/MessageTest.php index 0c91bf19..a7a235dd 100644 --- a/test/Storage/MessageTest.php +++ b/test/Storage/MessageTest.php @@ -39,40 +39,40 @@ public function testInvalidFile() $this->fail('no exception raised while loading unknown file'); } - public function testIsMultipart() + /** + * @dataProvider filesProvider + */ + public function testIsMultipart($params) { - $message = new Message(array('file' => $this->_file)); - + $message = new Message($params); $this->assertTrue($message->isMultipart()); } - public function testGetHeader() + /** + * @dataProvider filesProvider + */ + public function testGetHeader($params) { - $message = new Message(array('file' => $this->_file)); - + $message = new Message($params); $this->assertEquals($message->subject, 'multipart'); } - public function testGetDecodedHeader() + /** + * @dataProvider filesProvider + */ + public function testGetDecodedHeader($params) { - $message = new Message(array('file' => $this->_file)); - + $message = new Message($params); $this->assertEquals('Peter Müller ', $message->from); } - public function testGetHeaderAsArray() - { - $message = new Message(array('file' => $this->_file)); - - $this->assertEquals($message->getHeader('subject', 'array'), array('multipart')); - } - - public function testGetHeaderFromOpenFile() + /** + * @dataProvider filesProvider + */ + public function testGetHeaderAsArray($params) { - $fh = fopen($this->_file, 'r'); - $message = new Message(array('file' => $fh)); - - $this->assertEquals($message->subject, 'multipart'); + $message = new Message($params); + $this->assertEquals(array('multipart'), $message->getHeader('subject', 'array'), 'getHeader() value not match'); } public function testGetFirstPart() @@ -428,4 +428,18 @@ public function testStrictParseMessage() $raw = "From foo@example.com Sun Jan 01 00:00:00 2000\n" . $raw; $message = new Message(array('raw' => $raw, 'strict' => true)); } + + public function filesProvider() + { + $filePath = __DIR__ . '/../_files/mail.txt'; + $fileBlankLineOnTop = __DIR__ . '/../_files/mail_blank_top_line.txt'; + + return array( + // Description => [params] + 'resource' => array(array('file' => fopen($filePath, 'r'))), + 'file path' => array(array('file' => $filePath)), + 'raw' => array(array('raw' => file_get_contents($filePath))), + 'file with blank line on top' => array(array('file' => $fileBlankLineOnTop)), + ); + } } diff --git a/test/_files/mail_blank_top_line.txt b/test/_files/mail_blank_top_line.txt new file mode 100644 index 00000000..56a53e93 --- /dev/null +++ b/test/_files/mail_blank_top_line.txt @@ -0,0 +1,28 @@ + +To: foo@example.com +Subject: multipart +Date: Sun, 01 Jan 2000 00:00:00 +0000 +From: =?UTF-8?Q?"Peter M=C3=BCller"?= +ContENT-type: multipart/alternative; boUNDary="crazy-multipart" +Message-ID: +MIME-version: 1.0 + +multipart message +--crazy-multipart +Content-type: text/plain + +The first part +is horizontal + +--crazy-multipart +Content-type: text/x-vertical + +T s p i v +h e a s e +e c r r + o t t + n i + d c + a + l +--crazy-multipart--