Add new optional beta email parser thats based on ImapEngine instead of Webklex

This commit is contained in:
johnnyq
2026-02-26 16:11:49 -05:00
parent 1ba19cc249
commit 9cb1ff7330
682 changed files with 101834 additions and 8 deletions

View File

@@ -0,0 +1,3 @@
# These are supported funding model platforms
github: zbateson

View File

@@ -0,0 +1,36 @@
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
php: [8.5, 8.4, 8.3, 8.2, 8.1, 8.0]
stability: [prefer-stable]
name: P${{ matrix.php }} - ${{ matrix.stability }} - ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo
coverage: none
- name: Setup problem matchers
run: |
echo "::add-matcher::${{ runner.tool_cache }}/php.json"
echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
- name: Install dependencies
run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction
- name: Execute tests
run: ./vendor/bin/phpunit -c tests/phpunit.xml

View File

@@ -0,0 +1,15 @@
<?php
/*
* This document has been generated with
* https://mlocati.github.io/php-cs-fixer-configurator/#version:3.0.0-rc.1|configurator
* you can change this configuration by importing this file.
*
*/
$config = include 'vendor/zbateson/mb-wrapper/PhpCsFixer.php';
return $config->setFinder(PhpCsFixer\Finder::create()
->exclude('vendor')
->in(__DIR__.'/src')
->in(__DIR__.'/tests')
);

View File

@@ -0,0 +1,24 @@
Copyright (c) 2014-2015, Zaahid Bateson
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -0,0 +1,4 @@
<?php
define ('TEST_DATA_DIR', __DIR__ . '/tests/_data');
define ('TEST_OUTPUT_DIR', __DIR__ . '/tests/_output');

View File

@@ -0,0 +1,125 @@
# zbateson/mail-mime-parser
Testable and PSR-compliant mail mime parser alternative to PHP's imap* functions and Pear libraries for reading messages in _Internet Message Format_ [RFC 822](http://tools.ietf.org/html/rfc822) (and later revisions [RFC 2822](http://tools.ietf.org/html/rfc2822), [RFC 5322](http://tools.ietf.org/html/rfc5322)).
[![Build Status](https://github.com/zbateson/mail-mime-parser/actions/workflows/tests.yml/badge.svg)](https://github.com/zbateson/mail-mime-parser/actions/workflows/tests.yml)
[![Code Coverage](https://scrutinizer-ci.com/g/zbateson/mail-mime-parser/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/zbateson/mail-mime-parser/?branch=master)
[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/zbateson/mail-mime-parser/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/zbateson/mail-mime-parser/?branch=master)
[![Total Downloads](https://poser.pugx.org/zbateson/mail-mime-parser/downloads)](//packagist.org/packages/zbateson/mail-mime-parser)
[![Latest Stable Version](https://poser.pugx.org/zbateson/mail-mime-parser/v)](//packagist.org/packages/zbateson/mail-mime-parser)
The goals of this project are to be:
* Well written
* Standards-compliant but forgiving
* Tested where possible
To include it for use in your project, install it via composer:
```
composer require zbateson/mail-mime-parser
```
## Sponsors
[![SecuMailer](https://mail-mime-parser.org/sponsors/logo-secumailer.png)](https://secumailer.com)
A huge thank you to [all my sponsors](https://github.com/sponsors/zbateson). <3
If this project's helped you, please consider [sponsoring me](https://github.com/sponsors/zbateson).
## Php 7 Support Dropped
As of mail-mime-parser 3.0, support for php 7 has been dropped.
## New in 3.0
Most changes in 3.0 are 'backend' changes, for example switching to PHP-DI for dependency injection, and basic usage should not be affected.
The header class method 'getAllParts' includes comment parts in 3.0.
Error, validation, and logging support has been added.
For a more complete list of changes, please visit the [3.0 Upgrade Guide](https://mail-mime-parser.org/upgrade-3.0) and the [Usage Guide](https://mail-mime-parser.org/).
## Requirements
MailMimeParser requires PHP 8.0 or newer. Tested on PHP 8.0, 8.1, 8.2, 8.3 and 8.4.
## Usage
```php
use ZBateson\MailMimeParser\MailMimeParser;
use ZBateson\MailMimeParser\Message;
use ZBateson\MailMimeParser\Header\HeaderConsts;
// use an instance of MailMimeParser as a class dependency
$mailParser = new MailMimeParser();
// parse() accepts a string, resource or Psr7 StreamInterface
// pass `true` as the second argument to attach the passed $handle and close
// it when the returned IMessage is destroyed.
$handle = fopen('file.mime', 'r');
$message = $mailParser->parse($handle, false); // returns `IMessage`
// OR: use this procedurally (Message::from also accepts a string,
// resource or Psr7 StreamInterface
// true or false as second parameter doesn't matter if passing a string.
$string = "Content-Type: text/plain\r\nSubject: Test\r\n\r\nMessage";
$message = Message::from($string, false);
echo $message->getHeaderValue(HeaderConsts::FROM); // user@example.com
echo $message
->getHeader(HeaderConsts::FROM) // AddressHeader
->getPersonName(); // Person Name
echo $message->getSubject(); // The email's subject
echo $message
->getHeader(HeaderConsts::TO) // also AddressHeader
->getAddresses()[0] // AddressPart
->getPersonName(); // Person Name
echo $message
->getHeader(HeaderConsts::CC) // also AddressHeader
->getAddresses()[0] // AddressPart
->getEmail(); // user@example.com
echo $message->getTextContent(); // or getHtmlContent()
echo $message->getHeader('X-Foo'); // for custom or undocumented headers
$att = $message->getAttachmentPart(0); // first attachment
echo $att->getHeaderValue(HeaderConsts::CONTENT_TYPE); // e.g. "text/plain"
echo $att->getHeaderParameter( // value of "charset" part
HeaderConsts::CONTENT_TYPE,
'charset'
);
echo $att->getContent(); // get the attached file's contents
$stream = $att->getContentStream(); // the file is decoded automatically
$dest = \GuzzleHttp\Psr7\stream_for(
fopen('my-file.ext')
);
\GuzzleHttp\Psr7\copy_to_stream(
$stream, $dest
);
// OR: more simply if saving or copying to another stream
$att->saveContent('my-file.ext'); // writes to my-file.ext
$att->saveContent($stream); // copies to the stream
// close only when $message is no longer being used.
fclose($handle);
```
## Documentation
* [Usage Guide](https://mail-mime-parser.org/)
* [API Reference](https://mail-mime-parser.org/api/3.0)
## Upgrade guides
* [1.x Upgrade Guide](https://mail-mime-parser.org/upgrade-1.0)
* [2.x Upgrade Guide](https://mail-mime-parser.org/upgrade-2.0)
* [3.x Upgrade Guide](https://mail-mime-parser.org/upgrade-3.0)
## License
BSD licensed - please see [license agreement](https://github.com/zbateson/mail-mime-parser/blob/master/LICENSE).

View File

@@ -0,0 +1,45 @@
{
"name": "zbateson/mail-mime-parser",
"description": "MIME email message parser",
"keywords": ["mail", "mime", "parser", "email", "php-imap", "mailparse", "mimeparse", "MimeMailParser"],
"homepage": "https://mail-mime-parser.org",
"license": "BSD-2-Clause",
"authors": [
{
"name": "Zaahid Bateson"
},
{
"name": "Contributors",
"homepage": "https://github.com/zbateson/mail-mime-parser/graphs/contributors"
}
],
"support": {
"issues": "https://github.com/zbateson/mail-mime-parser/issues",
"source": "https://github.com/zbateson/mail-mime-parser",
"docs": "https://mail-mime-parser.org/#usage-guide"
},
"require": {
"php": ">=8.0",
"guzzlehttp/psr7": "^2.5",
"zbateson/mb-wrapper": "^2.0",
"zbateson/stream-decorators": "^2.1",
"php-di/php-di": "^6.0|^7.0",
"psr/log": "^1|^2|^3"
},
"require-dev": {
"phpunit/phpunit": "^9.6",
"friendsofphp/php-cs-fixer": "*",
"phpstan/phpstan": "*",
"monolog/monolog": "^2|^3"
},
"suggest": {
"ext-mbstring": "For best support/performance",
"ext-iconv": "For best support/performance"
},
"autoload": {
"psr-4": {"ZBateson\\MailMimeParser\\": "src/"}
},
"autoload-dev": {
"psr-4": {"ZBateson\\MailMimeParser\\": "tests/MailMimeParser"}
}
}

View File

@@ -0,0 +1,13 @@
parameters:
level: 6
errorFormat: raw
editorUrl: '%%file%% %%line%% %%column%%: %%error%%'
paths:
- src
bootstrapFiles:
- PHPStanConstants.php
ignoreErrors:
-
message: '#Call to an undefined method ZBateson\\MailMimeParser#'
paths:
- src/*

View File

@@ -0,0 +1,123 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser;
use InvalidArgumentException;
use Psr\Log\LogLevel;
use Throwable;
/**
* Holds information about an error or notice that happened on a specific
* object.
*
* @author Zaahid Bateson
*/
class Error
{
/**
* @var string The error message.
*/
protected string $message;
/**
* @var string The PSR log level for this error.
*/
protected string $psrLevel;
/**
* @var ErrorBag The object the error/notice occurred on.
*/
protected ErrorBag $object;
/**
* @var ?Throwable An Exception object if one happened, or null if not
*/
protected ?Throwable $exception;
/**
* @var array<string, int>
*/
private array $levelMap = [
LogLevel::EMERGENCY => 0,
LogLevel::ALERT => 1,
LogLevel::CRITICAL => 2,
LogLevel::ERROR => 3,
LogLevel::WARNING => 4,
LogLevel::NOTICE => 5,
LogLevel::INFO => 6,
LogLevel::DEBUG => 7,
];
/**
*
* @throws InvalidArgumentException if the passed $psrLogLevelAsErrorLevel
* is not a known PSR log level (see \Psr\Log\LogLevel)
*/
public function __construct(string $message, string $psrLogLevelAsErrorLevel, ErrorBag $object, ?Throwable $exception = null)
{
if (!isset($this->levelMap[$psrLogLevelAsErrorLevel])) {
throw new InvalidArgumentException($psrLogLevelAsErrorLevel . ' is not a known PSR Log Level');
}
$this->message = $message;
$this->psrLevel = $psrLogLevelAsErrorLevel;
$this->object = $object;
$this->exception = $exception;
}
/**
* Returns the error message.
*/
public function getMessage() : string
{
return $this->message;
}
/**
* Returns the PSR string log level for this error message.
*/
public function getPsrLevel() : string
{
return $this->psrLevel;
}
/**
* Returns the class type the error occurred on.
*/
public function getClass() : string
{
return \get_class($this->object);
}
/**
* Returns the object the error occurred on.
*/
public function getObject() : ErrorBag
{
return $this->object;
}
/**
* Returns the exception that occurred, if any, or null.
*/
public function getException() : ?Throwable
{
return $this->exception;
}
/**
* Returns true if the PSR log level for this error is equal to or greater
* than the one passed, e.g. passing LogLevel::ERROR would return true for
* LogLevel::ERROR and LogLevel::CRITICAL, ALERT and EMERGENCY.
*/
public function isPsrLevelGreaterOrEqualTo(string $minLevel) : bool
{
$minIntLevel = $this->levelMap[$minLevel] ?? 1000;
$thisLevel = $this->levelMap[$this->psrLevel];
return ($minIntLevel >= $thisLevel);
}
}

View File

@@ -0,0 +1,124 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use Throwable;
/**
* Provides a top-level abstract implementation of IErrorBag.
*
* @author Zaahid Bateson
*/
abstract class ErrorBag implements IErrorBag
{
protected LoggerInterface $logger;
/**
* @var Error[] array of Error objects belonging to this object.
*/
private array $errors = [];
/**
* @var bool true once the object has been validated.
*/
private bool $validated = false;
public function __construct(LoggerInterface $logger)
{
$this->logger = $logger;
}
/**
* Returns the class name. Override to identify objects in logs.
*
*/
public function getErrorLoggingContextName() : string
{
return static::class;
}
/**
* Return any children ErrorBag objects.
*
* @return IErrorBag[]
*/
abstract protected function getErrorBagChildren() : array;
/**
* Perform any extra validation and call 'addError'.
*
* getErrors and getAllErrors call validate() if their $validate parameter
* is true. validate() is only called once on an object with getErrors
* getAllErrors.
*/
protected function validate() : void
{
// do nothing
}
public function addError(string $message, string $psrLogLevel, ?Throwable $exception = null) : static
{
$error = new Error($message, $psrLogLevel, $this, $exception);
$this->errors[] = $error;
$this->logger->log(
$psrLogLevel,
'{contextName} {message} {exception}',
[
'contextName' => $this->getErrorLoggingContextName(),
'message' => $message,
'exception' => $exception ?? ''
]
);
return $this;
}
public function getErrors(bool $validate = false, string $minPsrLevel = LogLevel::ERROR) : array
{
if ($validate && !$this->validated) {
$this->validated = true;
$this->validate();
}
return \array_values(\array_filter(
$this->errors,
function($e) use ($minPsrLevel) {
return $e->isPsrLevelGreaterOrEqualTo($minPsrLevel);
}
));
}
public function hasErrors(bool $validate = false, string $minPsrLevel = LogLevel::ERROR) : bool
{
return (\count($this->getErrors($validate, $minPsrLevel)) > 0);
}
public function getAllErrors(bool $validate = false, string $minPsrLevel = LogLevel::ERROR) : array
{
$arr = \array_values(\array_map(
function($e) use ($validate, $minPsrLevel) {
return $e->getAllErrors($validate, $minPsrLevel);
},
$this->getErrorBagChildren()
));
return \array_merge($this->getErrors($validate, $minPsrLevel), ...$arr);
}
public function hasAnyErrors(bool $validate = false, string $minPsrLevel = LogLevel::ERROR) : bool
{
if ($this->hasErrors($validate, $minPsrLevel)) {
return true;
}
foreach ($this->getErrorBagChildren() as $ch) {
if ($ch->hasAnyErrors($validate, $minPsrLevel)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,226 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use ZBateson\MailMimeParser\ErrorBag;
use ZBateson\MailMimeParser\Header\Consumer\IConsumerService;
use ZBateson\MailMimeParser\Header\Part\CommentPart;
use ZBateson\MailMimeParser\MailMimeParser;
/**
* Abstract base class representing a mime email's header.
*
* The base class sets up the header's consumer for parsing, sets the name of
* the header, and calls the consumer to parse the header's value.
*
* @author Zaahid Bateson
*/
abstract class AbstractHeader extends ErrorBag implements IHeader
{
/**
* @var string the name of the header
*/
protected string $name;
/**
* @var IHeaderPart[] all parts not including CommentParts.
*/
protected array $parts = [];
/**
* @var IHeaderPart[] the header's parts (as returned from the consumer),
* including commentParts
*/
protected array $allParts = [];
/**
* @var string the raw value
*/
protected string $rawValue;
/**
* @var string[] array of comments, initialized on demand in getComments()
*/
private ?array $comments = null;
/**
* Assigns the header's name and raw value, then calls parseHeaderValue to
* extract a parsed value.
*
* @param IConsumerService $consumerService For parsing the value.
* @param string $name Name of the header.
* @param string $value Value of the header.
*/
public function __construct(
LoggerInterface $logger,
IConsumerService $consumerService,
string $name,
string $value
) {
parent::__construct($logger);
$this->name = $name;
$this->rawValue = $value;
$this->parseHeaderValue($consumerService, $value);
}
/**
* Filters $this->allParts into the parts required by $this->parts
* and assigns it.
*
* The AbstractHeader::filterAndAssignToParts method filters out CommentParts.
*/
protected function filterAndAssignToParts() : void
{
$this->parts = \array_values(\array_filter($this->allParts, function($p) {
return !($p instanceof CommentPart);
}));
}
/**
* Calls the consumer and assigns the parsed parts to member variables.
*
* The default implementation assigns the returned value to $this->allParts
* and filters out comments from it, assigning the filtered array to
* $this->parts by calling filterAndAssignToParts.
*/
protected function parseHeaderValue(IConsumerService $consumer, string $value) : void
{
$this->allParts = $consumer($value);
$this->filterAndAssignToParts();
}
/**
* @return IHeaderPart[]
*/
public function getParts() : array
{
return $this->parts;
}
/**
* @return IHeaderPart[]
*/
public function getAllParts() : array
{
return $this->allParts;
}
/**
* @return string[]
*/
public function getComments() : array
{
if ($this->comments === null) {
$this->comments = \array_map(fn (IHeaderPart $c) => $c->getComment(), \array_merge(...\array_map(
fn ($p) => ($p instanceof CommentPart) ? [$p] : $p->getComments(),
$this->allParts
)));
}
return $this->comments;
}
public function getValue() : ?string
{
if (!empty($this->parts)) {
return $this->parts[0]->getValue();
}
return null;
}
public function getRawValue() : string
{
return $this->rawValue;
}
public function getName() : string
{
return $this->name;
}
public function __toString() : string
{
return "{$this->name}: {$this->rawValue}";
}
public function getErrorLoggingContextName() : string
{
return 'Header::' . $this->getName();
}
protected function getErrorBagChildren() : array
{
return $this->getAllParts();
}
protected function validate() : void
{
if (\strlen(\trim($this->name)) === 0) {
$this->addError('Header doesn\'t have a name', LogLevel::ERROR);
}
if (\strlen(\trim($this->rawValue)) === 0) {
$this->addError('Header doesn\'t have a value', LogLevel::NOTICE);
}
}
/**
* Checks if the passed $value parameter is null, and if so tries to parse
* a header line from $nameOrLine splitting on first occurrence of a ':'
* character.
*
* The returned array always contains two elements. The first being the
* name (or blank if a ':' char wasn't found and $value is null), and the
* second being the value.
*
* @return string[]
*/
protected static function getHeaderPartsFrom(string $nameOrLine, ?string $value = null) : array
{
$namePart = $nameOrLine;
$valuePart = $value;
if ($value === null) {
// full header line
$parts = \explode(':', $nameOrLine, 2);
$namePart = (\count($parts) > 1) ? $parts[0] : '';
$valuePart = \trim((\count($parts) > 1) ? $parts[1] : $parts[0]);
}
return [$namePart, $valuePart];
}
/**
* Parses the passed parameters into an IHeader object.
*
* The type of returned IHeader is determined by the name of the header.
* See {@see HeaderFactory::newInstance} for more details.
*
* The required $nameOrLine parameter may contain either the name of a
* header to parse, or a full header line, e.g. From: email@example.com. If
* passing a full header line, the $value parameter must be set to null (the
* default).
*
* Note that more specific types can be called on directly. For instance an
* AddressHeader may be created by calling AddressHeader::from() which will
* ignore the name of the header, and always return an AddressHeader, or by
* calling `new AddressHeader('name', 'value')` directly.
*
* @param string $nameOrLine The header's name or full header line.
* @param string|null $value The header's value, or null if passing a full
* header line to parse.
*/
public static function from(string $nameOrLine, ?string $value = null) : IHeader
{
$parts = static::getHeaderPartsFrom($nameOrLine, $value);
$container = MailMimeParser::getGlobalContainer();
$hf = $container->get(HeaderFactory::class);
if (self::class !== static::class) {
return $hf->newInstanceOf($parts[0], $parts[1], static::class);
}
return $hf->newInstance($parts[0], $parts[1]);
}
}

View File

@@ -0,0 +1,138 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Header\Consumer\AddressBaseConsumerService;
use ZBateson\MailMimeParser\Header\Part\AddressGroupPart;
use ZBateson\MailMimeParser\Header\Part\AddressPart;
use ZBateson\MailMimeParser\MailMimeParser;
/**
* A header containing one or more email addresses and/or groups of addresses.
*
* An address is separated by a comma, and each group separated by a semi-colon.
* The AddressHeader provides a complete list of all addresses referenced in a
* header including any addresses in groups, in addition to being able to access
* the groups separately if needed.
*
* For full specifications, see {@link https://www.ietf.org/rfc/rfc2822.txt}
*
* @author Zaahid Bateson
*/
class AddressHeader extends AbstractHeader
{
/**
* @var AddressPart[] array of addresses, included all addresses contained
* in groups.
*/
protected array $addresses = [];
/**
* @var AddressGroupPart[] array of address groups (lists).
*/
protected array $groups = [];
public function __construct(
string $name,
string $value,
?LoggerInterface $logger = null,
?AddressBaseConsumerService $consumerService = null
) {
$di = MailMimeParser::getGlobalContainer();
parent::__construct(
$logger ?? $di->get(LoggerInterface::class),
$consumerService ?? $di->get(AddressBaseConsumerService::class),
$name,
$value
);
}
/**
* Filters $this->allParts into the parts required by $this->parts
* and assignes it.
*
* The AbstractHeader::filterAndAssignToParts method filters out CommentParts.
*/
protected function filterAndAssignToParts() : void
{
parent::filterAndAssignToParts();
foreach ($this->parts as $part) {
if ($part instanceof AddressPart) {
$this->addresses[] = $part;
} elseif ($part instanceof AddressGroupPart) {
$this->addresses = \array_merge($this->addresses, $part->getAddresses());
$this->groups[] = $part;
}
}
}
/**
* Returns all address parts in the header including any addresses that are
* in groups (lists).
*
* @return AddressPart[] The addresses.
*/
public function getAddresses() : array
{
return $this->addresses;
}
/**
* Returns all group parts (lists) in the header.
*
* @return AddressGroupPart[]
*/
public function getGroups() : array
{
return $this->groups;
}
/**
* Returns true if an address exists with the passed email address.
*
* Comparison is done case insensitively.
*
*/
public function hasAddress(string $email) : bool
{
foreach ($this->addresses as $addr) {
if (\strcasecmp($addr->getEmail(), $email) === 0) {
return true;
}
}
return false;
}
/**
* Returns the first email address in the header.
*
* @return ?string The email address
*/
public function getEmail() : ?string
{
if (!empty($this->addresses)) {
return $this->addresses[0]->getEmail();
}
return null;
}
/**
* Returns the name associated with the first email address to complement
* getEmail() if one is set, or null if not.
*
* @return string|null The person name.
*/
public function getPersonName() : ?string
{
if (!empty($this->addresses)) {
return $this->addresses[0]->getName();
}
return null;
}
}

View File

@@ -0,0 +1,338 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Consumer;
use ArrayIterator;
use Iterator;
use NoRewindIterator;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Header\IHeaderPart;
use ZBateson\MailMimeParser\Header\Part\HeaderPartFactory;
use ZBateson\MailMimeParser\Header\Part\MimeToken;
/**
* Abstract base class for all header token consumers.
*
* Defines the base parser that loops over tokens, consuming them and creating
* header parts.
*
* @author Zaahid Bateson
*/
abstract class AbstractConsumerService implements IConsumerService
{
protected LoggerInterface $logger;
/**
* @var HeaderPartFactory used to construct IHeaderPart objects
*/
protected HeaderPartFactory $partFactory;
/**
* @var AbstractConsumerService[] array of sub-consumers used by this
* consumer if any, or an empty array if none exist.
*/
protected array $subConsumers = [];
/**
* @var ?string the generated token split pattern on first run, so it doesn't
* need to be regenerated every time.
*/
private ?string $tokenSplitPattern = null;
/**
* @param AbstractConsumerService[] $subConsumers
*/
public function __construct(LoggerInterface $logger, HeaderPartFactory $partFactory, array $subConsumers = [])
{
$this->logger = $logger;
$this->partFactory = $partFactory;
$this->subConsumers = $subConsumers;
}
public function __invoke(string $value) : array
{
$this->logger->debug('Starting {class} for "{value}"', ['class' => static::class, 'value' => $value]);
if ($value !== '') {
$parts = $this->parseRawValue($value);
$this->logger->debug(
'Ending {class} for "{value}": parsed into {cnt} header part objects',
['class' => static::class, 'value' => $value, 'cnt' => \count($parts)]
);
return $parts;
}
return [];
}
/**
* Returns this consumer and all unique sub consumers.
*
* Loops into the sub-consumers (and their sub-consumers, etc...) finding
* all unique consumers, and returns them in an array.
*
* @return AbstractConsumerService[] Array of unique consumers.
*/
protected function getAllConsumers() : array
{
$found = [$this];
do {
$current = \current($found);
$subConsumers = $current->subConsumers;
foreach ($subConsumers as $consumer) {
if (!\in_array($consumer, $found)) {
$found[] = $consumer;
}
}
} while (\next($found) !== false);
return $found;
}
/**
* Parses the raw header value into header parts.
*
* Calls splitTokens to split the value into token part strings, then calls
* parseParts to parse the returned array.
*
* @return \ZBateson\MailMimeParser\Header\IHeaderPart[] the array of parsed
* parts
*/
private function parseRawValue(string $value) : array
{
$tokens = $this->splitRawValue($value);
return $this->parseTokensIntoParts(new NoRewindIterator(new ArrayIterator($tokens)));
}
/**
* Returns an array of regular expression separators specific to this
* consumer.
*
* The returned patterns are used to split the header value into tokens for
* the consumer to parse into parts.
*
* Each array element makes part of a generated regular expression that is
* used in a call to preg_split(). RegEx patterns can be used, and care
* should be taken to escape special characters.
*
* @return string[] Array of regex patterns.
*/
abstract protected function getTokenSeparators() : array;
/**
* Returns a list of regular expression markers for this consumer and all
* sub-consumers by calling getTokenSeparators().
*
* @return string[] Array of regular expression markers.
*/
protected function getAllTokenSeparators() : array
{
$markers = $this->getTokenSeparators();
$subConsumers = $this->getAllConsumers();
foreach ($subConsumers as $consumer) {
$markers = \array_merge($consumer->getTokenSeparators(), $markers);
}
return \array_unique($markers);
}
/**
* Returns a regex pattern used to split the input header string.
*
* The default implementation calls
* {@see AbstractConsumerService::getAllTokenSeparators()} and implodes the
* returned array with the regex OR '|' character as its glue.
*
* @return string the regex pattern
*/
protected function getTokenSplitPattern() : string
{
$sChars = \implode('|', $this->getAllTokenSeparators());
$mimePartPattern = MimeToken::MIME_PART_PATTERN;
return '~(' . $mimePartPattern . '|\\\\\r\n|\\\\.|' . $sChars . ')~ms';
}
/**
* Returns an array of split tokens from the input string.
*
* The method calls preg_split using
* {@see AbstractConsumerService::getTokenSplitPattern()}. The split array
* will not contain any empty parts and will contain the markers.
*
* @param string $rawValue the raw string
* @return string[] the array of tokens
*/
protected function splitRawValue($rawValue) : array
{
if ($this->tokenSplitPattern === null) {
$this->tokenSplitPattern = $this->getTokenSplitPattern();
$this->logger->debug(
'Configuring {class} with token split pattern: {pattern}',
['class' => static::class, 'pattern' => $this->tokenSplitPattern]
);
}
return \preg_split(
$this->tokenSplitPattern,
$rawValue,
-1,
PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY
);
}
/**
* Returns true if the passed string token marks the beginning marker for
* the current consumer.
*
* @param string $token The current token
*/
abstract protected function isStartToken(string $token) : bool;
/**
* Returns true if the passed string token marks the end marker for the
* current consumer.
*
* @param string $token The current token
*/
abstract protected function isEndToken(string $token) : bool;
/**
* Constructs and returns an IHeaderPart for the passed string token.
*
* If the token should be ignored, the function must return null.
*
* The default created part uses the instance's partFactory->newInstance
* method.
*
* @param string $token the token
* @param bool $isLiteral set to true if the token represents a literal -
* e.g. an escaped token
* @return ?IHeaderPart The constructed header part or null if the token
* should be ignored.
*/
protected function getPartForToken(string $token, bool $isLiteral) : ?IHeaderPart
{
if ($isLiteral) {
return $this->partFactory->newToken($token, true);
}
// can be overridden with custom PartFactory
return $this->partFactory->newInstance($token);
}
/**
* Iterates through this consumer's sub-consumers checking if the current
* token triggers a sub-consumer's start token and passes control onto that
* sub-consumer's parseTokenIntoParts().
*
* If no sub-consumer is responsible for the current token, calls
* {@see AbstractConsumerService::getPartForToken()} and returns it in an
* array.
*
* @param Iterator<string> $tokens
* @return IHeaderPart[]
*/
protected function getConsumerTokenParts(Iterator $tokens) : array
{
$token = $tokens->current();
$subConsumers = $this->subConsumers;
foreach ($subConsumers as $consumer) {
if ($consumer->isStartToken($token)) {
$this->logger->debug(
'Token: "{value}" in {class} starting sub-consumer {consumer}',
['value' => $token, 'class' => static::class, 'consumer' => \get_class($consumer)]
);
$this->advanceToNextToken($tokens, true);
return $consumer->parseTokensIntoParts($tokens);
}
}
$part = $this->getPartForToken($token, false);
return ($part !== null) ? [$part] : [];
}
/**
* Returns an array of IHeaderPart for the current token on the iterator.
*
* If the current token is a start token from a sub-consumer, the sub-
* consumer's {@see AbstractConsumerService::parseTokensIntoParts()} method
* is called.
*
* @param Iterator<string> $tokens The token iterator.
* @return IHeaderPart[]
*/
protected function getTokenParts(Iterator $tokens) : array
{
$token = $tokens->current();
if ($token === "\\\r\n" || (\strlen($token) === 2 && $token[0] === '\\')) {
$part = $this->getPartForToken(\substr($token, 1), true);
return ($part !== null) ? [$part] : [];
}
return $this->getConsumerTokenParts($tokens);
}
/**
* Determines if the iterator should be advanced to the next token after
* reading tokens or finding a start token.
*
* The default implementation will advance for a start token, but not
* advance on the end token of the current consumer, allowing the end token
* to be passed up to a higher-level consumer.
*
* @param Iterator $tokens The token iterator.
* @param bool $isStartToken true for the start token.
*/
protected function advanceToNextToken(Iterator $tokens, bool $isStartToken) : static
{
$checkEndToken = (!$isStartToken && $tokens->valid());
$isEndToken = ($checkEndToken && $this->isEndToken($tokens->current()));
if (($isStartToken) || ($checkEndToken && !$isEndToken)) {
$tokens->next();
}
return $this;
}
/**
* Iterates over the passed token Iterator and returns an array of parsed
* IHeaderPart objects.
*
* The method checks each token to see if the token matches a sub-consumer's
* start token, or if it matches the current consumer's end token to stop
* processing.
*
* If a sub-consumer's start token is matched, the sub-consumer is invoked
* and its returned parts are merged to the current consumer's header parts.
*
* After all tokens are read and an array of Header\Parts are constructed,
* the array is passed to {@see AbstractConsumerService::processParts} for
* any final processing if there are any parts.
*
* @param Iterator<string> $tokens An iterator over a string of tokens
* @return IHeaderPart[] An array of parsed parts
*/
protected function parseTokensIntoParts(Iterator $tokens) : array
{
$parts = [];
while ($tokens->valid() && !$this->isEndToken($tokens->current())) {
$this->logger->debug('Parsing token: {token} in class: {consumer}', ['token' => $tokens->current(), 'consumer' => static::class]);
$parts = \array_merge($parts, $this->getTokenParts($tokens));
$this->advanceToNextToken($tokens, false);
}
return (empty($parts)) ? [] : $this->processParts($parts);
}
/**
* Performs any final processing on the array of parsed parts before
* returning it to the consumer client. The passed $parts array is
* guaranteed to not be empty.
*
* The default implementation simply returns the passed array after
* filtering out null/empty parts.
*
* @param IHeaderPart[] $parts The parsed parts.
* @return IHeaderPart[] Array of resulting final parts.
*/
protected function processParts(array $parts) : array
{
$this->logger->debug('Processing parts array {parts} in {consumer}', ['parts' => $parts, 'consumer' => static::class]);
return $parts;
}
}

View File

@@ -0,0 +1,69 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Consumer;
/**
* A minimal implementation of AbstractConsumerService splitting tokens by
* whitespace.
*
* Although the class doesn't have any abstract methods, it's defined as
* abstract because it doesn't define specific sub-consumers as constructor
* dependencies, and so is defined as abstract to avoid its direct use (use
* the concrete GenericConsumerService or GenericConsumerMimeLiteralPartService
* classes instead).
*
* @author Zaahid Bateson
*/
abstract class AbstractGenericConsumerService extends AbstractConsumerService
{
/**
* Returns the regex '\s+' (whitespace) pattern matcher as a token marker so
* the header value is split along whitespace characters.
*
* @return string[] an array of regex pattern matchers
*/
protected function getTokenSeparators() : array
{
return ['\s+'];
}
/**
* AbstractGenericConsumerService doesn't have start/end tokens, and so
* always returns false.
*/
protected function isEndToken(string $token) : bool
{
return false;
}
/**
* AbstractGenericConsumerService doesn't have start/end tokens, and so
* always returns false.
*
* @codeCoverageIgnore
*/
protected function isStartToken(string $token) : bool
{
return false;
}
/**
* Overridden to combine all part values into a single string and return it
* as an array with a single element.
*
* The returned IHeaderPart array consists of a single ContainerPart created
* out of all passed IHeaderParts.
*
* @param \ZBateson\MailMimeParser\Header\IHeaderPart[] $parts
* @return \ZBateson\MailMimeParser\Header\IHeaderPart[]
*/
protected function processParts(array $parts) : array
{
return [$this->partFactory->newContainerPart($parts)];
}
}

View File

@@ -0,0 +1,104 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Consumer;
use Iterator;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Header\IHeaderPart;
use ZBateson\MailMimeParser\Header\Part\HeaderPartFactory;
/**
* Serves as a base-consumer for recipient/sender email address headers (like
* From and To).
*
* AddressBaseConsumerService passes on token processing to its sub-consumer, an
* AddressConsumerService, and collects Part\AddressPart objects processed and
* returned by AddressConsumerService.
*
* @author Zaahid Bateson
*/
class AddressBaseConsumerService extends AbstractConsumerService
{
public function __construct(
LoggerInterface $logger,
HeaderPartFactory $partFactory,
AddressConsumerService $addressConsumerService
) {
parent::__construct($logger, $partFactory, [$addressConsumerService]);
}
/**
* Returns an empty array.
*
* @return string[] an array of regex pattern matchers
*/
protected function getTokenSeparators() : array
{
return [];
}
/**
* Disables advancing for start tokens.
*
* The start token for AddressBaseConsumerService is part of an
* {@see AddressPart} (or a sub-consumer) and so must be passed on.
*/
protected function advanceToNextToken(Iterator $tokens, bool $isStartToken) : static
{
if ($isStartToken) {
return $this;
}
parent::advanceToNextToken($tokens, $isStartToken);
return $this;
}
/**
* AddressBaseConsumerService doesn't have start/end tokens, and so always
* returns false.
*
* @return false
*/
protected function isEndToken(string $token) : bool
{
return false;
}
/**
* AddressBaseConsumerService doesn't have start/end tokens, and so always
* returns false.
*
* @codeCoverageIgnore
* @return false
*/
protected function isStartToken(string $token) : bool
{
return false;
}
/**
* Overridden so tokens aren't handled at this level, and instead are passed
* on to AddressConsumerService.
*
* @return \ZBateson\MailMimeParser\Header\IHeaderPart[]|array
*/
protected function getTokenParts(Iterator $tokens) : array
{
return $this->getConsumerTokenParts($tokens);
}
/**
* Never reached by AddressBaseConsumerService. Overridden to satisfy
* AbstractConsumerService.
*
* @codeCoverageIgnore
*/
protected function getPartForToken(string $token, bool $isLiteral) : ?IHeaderPart
{
return null;
}
}

View File

@@ -0,0 +1,139 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Consumer;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Header\Part\AddressGroupPart;
use ZBateson\MailMimeParser\Header\Part\AddressPart;
use ZBateson\MailMimeParser\Header\Part\MimeToken;
use ZBateson\MailMimeParser\Header\Part\MimeTokenPartFactory;
/**
* Parses a single part of an address header.
*
* Represents a single part of a list of addresses. A part could be one email
* address, or one 'group' containing multiple addresses. The consumer ends on
* finding either a comma token, representing a separation between addresses, or
* a semi-colon token representing the end of a group.
*
* A single email address may consist of just an email, or a name and an email
* address. Both of these are valid examples of a From header:
* - From: jonsnow@winterfell.com
* - From: Jon Snow <jonsnow@winterfell.com>
*
* Groups must be named, for example:
* - To: Winterfell: jonsnow@winterfell.com, Arya Stark <arya@winterfell.com>;
*
* Addresses may contain quoted parts and comments, and names may be mime-header
* encoded.
*
* @author Zaahid Bateson
*/
class AddressConsumerService extends AbstractConsumerService
{
public function __construct(
LoggerInterface $logger,
MimeTokenPartFactory $partFactory,
AddressGroupConsumerService $addressGroupConsumerService,
AddressEmailConsumerService $addressEmailConsumerService,
CommentConsumerService $commentConsumerService,
QuotedStringConsumerService $quotedStringConsumerService
) {
$addressGroupConsumerService->setAddressConsumerService($this);
parent::__construct(
$logger,
$partFactory,
[
$addressGroupConsumerService,
$addressEmailConsumerService,
$commentConsumerService,
$quotedStringConsumerService
]
);
}
/**
* Overridden to return patterns matching end tokens ("," and ";"), and
* whitespace.
*
* @return string[] the patterns
*/
public function getTokenSeparators() : array
{
return [',', ';', '\s+'];
}
/**
* Returns true for commas and semi-colons.
*
* Although the semi-colon is not strictly the end token of an
* AddressConsumerService, it could end a parent
* {@see AddressGroupConsumerService}.
*/
protected function isEndToken(string $token) : bool
{
return ($token === ',' || $token === ';');
}
/**
* AddressConsumer is "greedy", so this always returns true.
*/
protected function isStartToken(string $token) : bool
{
return true;
}
/**
* Performs final processing on parsed parts.
*
* AddressConsumerService's implementation looks for tokens representing the
* beginning of an address part, to create a {@see AddressPart} out of a
* name/address pair, or assign the name part to a parsed
* {@see AddressGroupPart} returned from its AddressGroupConsumerService
* sub-consumer.
*
* The returned array consists of a single element - either an
* {@see AddressPart} or an {@see AddressGroupPart}.
*
* @param \ZBateson\MailMimeParser\Header\IHeaderPart[] $parts
* @return \ZBateson\MailMimeParser\Header\IHeaderPart[]|array
*/
protected function processParts(array $parts) : array
{
$found = null;
$revved = \array_reverse($parts, true);
foreach ($revved as $key => $part) {
if ($part instanceof AddressGroupPart || $part instanceof AddressPart) {
$found = $part;
// purposefully ignoring anything after
\array_splice($parts, $key);
break;
}
}
if ($found !== null) {
if ($found instanceof AddressGroupPart) {
return [$this->partFactory->newAddressGroupPart(
$parts,
[$found]
)];
}
return [$this->partFactory->newAddress(
$parts,
[$found]
)];
}
return [
$this->partFactory->newAddress(
[],
\array_map(fn ($p) => ($p instanceof MimeToken) ? $this->partFactory->newToken($p->getRawValue()) : $p, $parts)
)
];
}
}

View File

@@ -0,0 +1,78 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Consumer;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Header\Part\HeaderPartFactory;
/**
* Parses the Address portion of an email address header, for an address part
* that contains both a name and an email address, e.g. "name" <email@tld.com>.
*
* The address portion found within the '<' and '>' chars may contain comments
* and quoted portions.
*
* @author Zaahid Bateson
*/
class AddressEmailConsumerService extends AbstractConsumerService
{
public function __construct(
LoggerInterface $logger,
HeaderPartFactory $partFactory,
CommentConsumerService $commentConsumerService,
QuotedStringConsumerService $quotedStringConsumerService
) {
parent::__construct(
$logger,
$partFactory,
[$commentConsumerService, $quotedStringConsumerService]
);
}
/**
* Overridden to return patterns matching the beginning/end part of an
* address in a name/address part ("<" and ">" chars).
*
* @return string[] the patterns
*/
public function getTokenSeparators() : array
{
return ['<', '>'];
}
/**
* Returns true for the '>' char.
*/
protected function isEndToken(string $token) : bool
{
return ($token === '>');
}
/**
* Returns true for the '<' char.
*/
protected function isStartToken(string $token) : bool
{
return ($token === '<');
}
/**
* Returns a single {@see ZBateson\MailMimeParser\Header\Part\AddressPart}
* with its 'email' portion set, so an {@see AddressConsumerService} can
* identify it and create an
* {@see ZBateson\MailMimeParser\Header\Part\AddressPart} Address with
* both a name and email set.
*
* @param \ZBateson\MailMimeParser\Header\IHeaderPart[] $parts
* @return \ZBateson\MailMimeParser\Header\IHeaderPart[]|array
*/
protected function processParts(array $parts) : array
{
return [$this->partFactory->newAddress([], $parts)];
}
}

View File

@@ -0,0 +1,106 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Consumer;
use Psr\Log\LoggerInterface;
use Iterator;
use ZBateson\MailMimeParser\Header\Part\AddressGroupPart;
use ZBateson\MailMimeParser\Header\Part\HeaderPartFactory;
/**
* Parses a single group of addresses (as a named-group part of an address
* header).
*
* Finds addresses using its AddressConsumerService sub-consumer separated by
* commas, and ends processing once a semi-colon is found.
*
* Prior to returning to its calling client, AddressGroupConsumerService
* constructs a single Part\AddressGroupPart object filling it with all located
* addresses, and returns it.
*
* The AddressGroupConsumerService extends AddressBaseConsumerService to define
* start/end tokens, token separators, and construct a Part\AddressGroupPart to
* return.
*
* @author Zaahid Bateson
*/
class AddressGroupConsumerService extends AddressBaseConsumerService
{
public function __construct(LoggerInterface $logger, HeaderPartFactory $partFactory)
{
AbstractConsumerService::__construct($logger, $partFactory, []);
}
/**
* Needs to be called in AddressConsumerService's constructor to avoid a
* circular dependency.
*
*/
public function setAddressConsumerService(AddressConsumerService $subConsumer) : void
{
$this->subConsumers = [$subConsumer];
}
/**
* Overridden to return patterns matching the beginning and end markers of a
* group address: colon and semi-colon (":" and ";") characters.
*
* @return string[] the patterns
*/
public function getTokenSeparators() : array
{
return [':', ';'];
}
/**
* Returns true if the passed token is a semi-colon.
*/
protected function isEndToken(string $token) : bool
{
return ($token === ';');
}
/**
* Returns true if the passed token is a colon.
*/
protected function isStartToken(string $token) : bool
{
return ($token === ':');
}
/**
* Overridden to always call processParts even for an empty set of
* addresses, since a group could be empty.
*
* @param Iterator $tokens
* @return IHeaderPart[]
*/
protected function parseTokensIntoParts(Iterator $tokens) : array
{
$ret = parent::parseTokensIntoParts($tokens);
if ($ret === []) {
return $this->processParts([]);
}
return $ret;
}
/**
* Performs post-processing on parsed parts.
*
* Returns an array with a single
* {@see AddressGroupPart} element with all email addresses from this and
* any sub-groups.
*
* @param \ZBateson\MailMimeParser\Header\IHeaderPart[] $parts
* @return AddressGroupPart[]|array
*/
protected function processParts(array $parts) : array
{
return [$this->partFactory->newAddressGroupPart([], $parts)];
}
}

View File

@@ -0,0 +1,113 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Consumer;
use Iterator;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Header\IHeaderPart;
use ZBateson\MailMimeParser\Header\Part\MimeTokenPartFactory;
/**
* Consumes all tokens within parentheses as comments.
*
* Parenthetical comments in mime-headers can be nested within one another. The
* outer-level continues after an inner-comment ends. Additionally,
* quoted-literals may exist with comments as well meaning a parenthesis inside
* a quoted string would not begin or end a comment section.
*
* In order to satisfy these specifications, CommentConsumerService inherits
* from GenericConsumerService which defines CommentConsumerService and
* QuotedStringConsumerService as sub-consumers.
*
* Examples:
* X-Mime-Header: Some value (comment)
* X-Mime-Header: Some value (comment (nested comment) still in comment)
* X-Mime-Header: Some value (comment "and part of original ) comment" -
* still a comment)
*
* @author Zaahid Bateson
*/
class CommentConsumerService extends GenericConsumerService
{
public function __construct(
LoggerInterface $logger,
MimeTokenPartFactory $partFactory,
QuotedStringConsumerService $quotedStringConsumerService
) {
parent::__construct(
$logger,
$partFactory,
$this,
$quotedStringConsumerService
);
}
/**
* Returns patterns matching open and close parenthesis characters
* as separators.
*
* @return string[] the patterns
*/
protected function getTokenSeparators() : array
{
return \array_merge(parent::getTokenSeparators(), ['\(', '\)']);
}
/**
* Returns true if the token is an open parenthesis character, '('.
*/
protected function isStartToken(string $token) : bool
{
return ($token === '(');
}
/**
* Returns true if the token is a close parenthesis character, ')'.
*/
protected function isEndToken(string $token) : bool
{
return ($token === ')');
}
/**
* Instantiates and returns Part\Token objects.
*
* Tokens from this and sub-consumers are combined into a Part\CommentPart
* in processParts.
*/
protected function getPartForToken(string $token, bool $isLiteral) : ?IHeaderPart
{
return $this->partFactory->newInstance($token);
}
/**
* Calls $tokens->next() and returns.
*
* The default implementation checks if the current token is an end token,
* and will not advance past it. Because a comment part of a header can be
* nested, its implementation must advance past its own 'end' token.
*/
protected function advanceToNextToken(Iterator $tokens, bool $isStartToken) : static
{
$tokens->next();
return $this;
}
/**
* Post processing involves creating a single Part\CommentPart out of
* generated parts from tokens. The Part\CommentPart is returned in an
* array.
*
* @param IHeaderPart[] $parts
* @return IHeaderPart[]
*/
protected function processParts(array $parts) : array
{
return [$this->partFactory->newCommentPart($parts)];
}
}

View File

@@ -0,0 +1,45 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Consumer;
use ZBateson\MailMimeParser\Header\IHeaderPart;
/**
* Parses a date header into a Part\DatePart taking care of comment and quoted
* parts as necessary.
*
* @author Zaahid Bateson
*/
class DateConsumerService extends GenericConsumerService
{
/**
* Returns a Part\LiteralPart for the current token
*
* @param string $token the token
* @param bool $isLiteral set to true if the token represents a literal -
* e.g. an escaped token
*/
protected function getPartForToken(string $token, bool $isLiteral) : ?IHeaderPart
{
return $this->partFactory->newToken($token, false);
}
/**
* Constructs a single Part\DatePart of any parsed parts returning it in an
* array with a single element.
*
* @param \ZBateson\MailMimeParser\Header\IHeaderPart[] $parts The parsed
* parts.
* @return \ZBateson\MailMimeParser\Header\IHeaderPart[] Array of resulting
* final parts.
*/
protected function processParts(array $parts) : array
{
return [$this->partFactory->newDatePart($parts)];
}
}

View File

@@ -0,0 +1,34 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Consumer;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Header\Part\MimeTokenPartFactory;
/**
* GenericConsumerMimeLiteralPartService uses a MimeTokenPartFactory instead
* of a HeaderPartFactory.
*
* @author Zaahid Bateson
*/
class GenericConsumerMimeLiteralPartService extends GenericConsumerService
{
public function __construct(
LoggerInterface $logger,
MimeTokenPartFactory $partFactory,
CommentConsumerService $commentConsumerService,
QuotedStringConsumerService $quotedStringConsumerService
) {
parent::__construct(
$logger,
$partFactory,
$commentConsumerService,
$quotedStringConsumerService
);
}
}

View File

@@ -0,0 +1,34 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Consumer;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Header\Part\HeaderPartFactory;
/**
* The base GenericConsumerService is a consumer with CommentConsumerService and
* QuotedStringConsumerService as sub-consumers, and splitting tokens by
* whitespace.
*
* @author Zaahid Bateson
*/
class GenericConsumerService extends AbstractGenericConsumerService
{
public function __construct(
LoggerInterface $logger,
HeaderPartFactory $partFactory,
CommentConsumerService $commentConsumerService,
QuotedStringConsumerService $quotedStringConsumerService
) {
parent::__construct(
$logger,
$partFactory,
[$commentConsumerService, $quotedStringConsumerService]
);
}
}

View File

@@ -0,0 +1,26 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Consumer;
/**
* Interface defining a consumer service class.
*
* @author Zaahid Bateson
*/
interface IConsumerService
{
/**
* Invokes parsing of a header's value into header parts.
*
* @param string $value the raw header value
* @return \ZBateson\MailMimeParser\Header\IHeaderPart[] the array of parsed
* parts
*/
public function __invoke(string $value) : array;
}

View File

@@ -0,0 +1,90 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Consumer;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Header\IHeaderPart;
use ZBateson\MailMimeParser\Header\Part\HeaderPartFactory;
/**
* Serves as a base-consumer for ID headers (like Message-ID and Content-ID).
*
* IdBaseConsumerService handles invalidly-formatted IDs not within '<' and '>'
* characters. Processing for validly-formatted IDs are passed on to its
* sub-consumer, IdConsumer.
*
* @author Zaahid Bateson
*/
class IdBaseConsumerService extends AbstractConsumerService
{
public function __construct(
LoggerInterface $logger,
HeaderPartFactory $partFactory,
CommentConsumerService $commentConsumerService,
QuotedStringConsumerService $quotedStringConsumerService,
IdConsumerService $idConsumerService
) {
parent::__construct(
$logger,
$partFactory,
[
$commentConsumerService,
$quotedStringConsumerService,
$idConsumerService
]
);
}
/**
* Returns '\s+' as a whitespace separator.
*
* @return string[] an array of regex pattern matchers.
*/
protected function getTokenSeparators() : array
{
return ['\s+'];
}
/**
* IdBaseConsumerService doesn't have start/end tokens, and so always
* returns false.
*/
protected function isEndToken(string $token) : bool
{
return false;
}
/**
* IdBaseConsumerService doesn't have start/end tokens, and so always
* returns false.
*
* @codeCoverageIgnore
*/
protected function isStartToken(string $token) : bool
{
return false;
}
/**
* Returns null for whitespace, and
* {@see ZBateson\MailMimeParser\Header\Part\Token} for anything else.
*
* @param string $token the token
* @param bool $isLiteral set to true if the token represents a literal -
* e.g. an escaped token
* @return ?IHeaderPart The constructed header part or null if the token
* should be ignored
*/
protected function getPartForToken(string $token, bool $isLiteral) : ?IHeaderPart
{
if (\preg_match('/^\s+$/', $token)) {
return null;
}
return $this->partFactory->newToken($token, true);
}
}

View File

@@ -0,0 +1,57 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Consumer;
use ZBateson\MailMimeParser\Header\IHeaderPart;
/**
* Parses a single ID from an ID header. Begins consuming on a '<' char, and
* ends on a '>' char.
*
* @author Zaahid Bateson
*/
class IdConsumerService extends GenericConsumerService
{
/**
* Overridden to return patterns matching the beginning part of an ID ('<'
* and '>' chars).
*
* @return string[] the patterns
*/
public function getTokenSeparators() : array
{
return \array_merge(parent::getTokenSeparators(), ['<', '>']);
}
/**
* Returns true for '>'.
*/
protected function isEndToken(string $token) : bool
{
return ($token === '>');
}
/**
* Returns true for '<'.
*/
protected function isStartToken(string $token) : bool
{
return ($token === '<');
}
/**
* Returns null for whitespace, and Token for anything else.
*/
protected function getPartForToken(string $token, bool $isLiteral) : ?IHeaderPart
{
if (\preg_match('/^\s+$/', $token)) {
return null;
}
return $this->partFactory->newToken($token, true);
}
}

View File

@@ -0,0 +1,96 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Consumer;
use Iterator;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Header\IHeaderPart;
use ZBateson\MailMimeParser\Header\Part\HeaderPartFactory;
use ZBateson\MailMimeParser\Header\Part\ParameterPart;
/**
* Reads headers separated into parameters consisting of an optional main value,
* and subsequent name/value pairs - for example text/html; charset=utf-8.
*
* A ParameterConsumerService's parts are separated by a semi-colon. Its
* name/value pairs are separated with an '=' character.
*
* Parts may be mime-encoded entities, or RFC-2231 split/encoded parts.
* Additionally, a value can be quoted and comments may exist.
*
* Actual processing of parameters is done in ParameterNameValueConsumerService,
* with ParameterConsumerService processing all collected parts into split
* parameter parts as necessary.
*
* @author Zaahid Bateson
*/
class ParameterConsumerService extends AbstractGenericConsumerService
{
use QuotedStringMimeLiteralPartTokenSplitPatternTrait;
public function __construct(
LoggerInterface $logger,
HeaderPartFactory $partFactory,
ParameterNameValueConsumerService $parameterNameValueConsumerService,
CommentConsumerService $commentConsumerService,
QuotedStringConsumerService $quotedStringConsumerService
) {
parent::__construct(
$logger,
$partFactory,
[$parameterNameValueConsumerService, $commentConsumerService, $quotedStringConsumerService]
);
}
/**
* Disables advancing for start tokens.
*/
protected function advanceToNextToken(Iterator $tokens, bool $isStartToken) : static
{
if ($isStartToken) {
return $this;
}
parent::advanceToNextToken($tokens, $isStartToken);
return $this;
}
/**
* Post processing involves looking for split parameter parts with matching
* names and combining them into a SplitParameterPart, and otherwise
* returning ParameterParts from ParameterNameValueConsumer as-is.
*
* @param IHeaderPart[] $parts The parsed parts.
* @return IHeaderPart[] Array of resulting final parts.
*/
protected function processParts(array $parts) : array
{
$factory = $this->partFactory;
return \array_values(\array_map(
function($partsArray) use ($factory) {
if (\count($partsArray) > 1) {
return $factory->newSplitParameterPart($partsArray);
}
return $partsArray[0];
},
\array_merge_recursive(...\array_map(
function($p) {
// if $p->getIndex is non-null, it's a split-parameter part
// and an array of one element consisting of name => ParameterPart
// is returned, which is then merged into name => array-of-parameter-parts
// or ';' object_id . ';' for non-split parts with a value of a single
// element array of [ParameterPart]
if ($p instanceof ParameterPart && $p->getIndex() !== null) {
return [\strtolower($p->getName()) => [$p]];
}
return [';' . \spl_object_id($p) . ';' => [$p]];
},
$parts
))
));
}
}

View File

@@ -0,0 +1,97 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Consumer;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Header\IHeaderPart;
use ZBateson\MailMimeParser\Header\Part\ContainerPart;
use ZBateson\MailMimeParser\Header\Part\MimeTokenPartFactory;
/**
* Parses an individual part of a parameter header.
*
* 'isStartToken' always returns true, so control is taken from
* ParameterConsumerService always, and returned when a ';' is encountered (and
* so processes a single part and returns it, then gets control back).0
*
* If an '=' is encountered, the ParameterValueConsumerService sub-consumer
* takes control and parses the value of a parameter.
*
* If no '=' is encountered, it's assumed to be a single value element, which
* should be the first part of a parameter header, e.g. 'text/html' in
* Content-Type: text/html; charset=utf-8
*
* @author Zaahid Bateson
*/
class ParameterNameValueConsumerService extends AbstractGenericConsumerService
{
public function __construct(
LoggerInterface $logger,
MimeTokenPartFactory $partFactory,
ParameterValueConsumerService $parameterValueConsumerService,
CommentConsumerService $commentConsumerService,
QuotedStringConsumerService $quotedStringConsumerService
) {
parent::__construct(
$logger,
$partFactory,
[$parameterValueConsumerService, $commentConsumerService, $quotedStringConsumerService]
);
}
/**
* Returns semi-colon as a token separator, in addition to parent token
* separators.
*
* @return string[]
*/
protected function getTokenSeparators() : array
{
return \array_merge(parent::getTokenSeparators(), [';']);
}
/**
* Always returns true to grab control from its parent
* ParameterConsumerService.
*/
protected function isStartToken(string $token) : bool
{
return true;
}
/**
* Returns true if the token is a ';' char.
*/
protected function isEndToken(string $token) : bool
{
return ($token === ';');
}
/**
* Creates either a ContainerPart if an '=' wasn't encountered, indicating
* this to be the main 'value' part of a header (or a malformed part of a
* parameter header), or a ParameterPart if the last IHeaderPart in the
* passed $parts array is already a ContainerPart (indicating it was parsed
* in ParameterValueConsumerService.)
*
* @param IHeaderPart[] $parts The parsed parts.
* @return IHeaderPart[] Array of resulting final parts.
*/
protected function processParts(array $parts) : array
{
$nameOnly = $parts;
$valuePart = \array_pop($nameOnly);
if (!($valuePart instanceof ContainerPart)) {
return [$this->partFactory->newContainerPart($parts)];
}
return [$this->partFactory->newParameterPart(
$nameOnly,
$valuePart
)];
}
}

View File

@@ -0,0 +1,64 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Consumer;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Header\Part\MimeTokenPartFactory;
/**
* Starts processing tokens after a '=' character is found, indicating the
* 'value' portion of a name/value pair in a parameter header.
*
* The value portion will consist of all tokens, quoted parts, and comment parts
* parsed up to a semi-colon token indicating control should be returned to the
* parent ParameterNameValueConsumerService.
*
* @author Zaahid Bateson
*/
class ParameterValueConsumerService extends GenericConsumerMimeLiteralPartService
{
public function __construct(
LoggerInterface $logger,
MimeTokenPartFactory $partFactory,
CommentConsumerService $commentConsumerService,
QuotedStringMimeLiteralPartConsumerService $quotedStringConsumerService
) {
parent::__construct(
$logger,
$partFactory,
$commentConsumerService,
$quotedStringConsumerService
);
}
/**
* Returns semi-colon and equals char as token separators.
*
* @return string[]
*/
protected function getTokenSeparators() : array
{
return \array_merge(parent::getTokenSeparators(), ['=', ';']);
}
/**
* Returns true if the token is an '=' character.
*/
protected function isStartToken(string $token) : bool
{
return ($token === '=');
}
/**
* Returns true if the token is a ';' character.
*/
protected function isEndToken(string $token) : bool
{
return ($token === ';');
}
}

View File

@@ -0,0 +1,79 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Consumer;
use ZBateson\MailMimeParser\Header\IHeaderPart;
/**
* Represents a quoted part of a header value starting at a double quote, and
* ending at the next double quote.
*
* A quoted-pair part in a header is a literal. There are no sub-consumers for
* it and a Part\LiteralPart is returned.
*
* Newline characters (CR and LF) are stripped entirely from the quoted part.
* This is based on the example at:
*
* https://tools.ietf.org/html/rfc822#section-3.1.1
*
* And https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html in section 7.2.1
* splitting the boundary.
*
* @author Zaahid Bateson
*/
class QuotedStringConsumerService extends AbstractConsumerService
{
/**
* Returns true if the token is a double quote.
*/
protected function isStartToken(string $token) : bool
{
return ($token === '"');
}
/**
* Returns true if the token is a double quote.
*/
protected function isEndToken(string $token) : bool
{
return ($token === '"');
}
/**
* Returns a single regex pattern for a double quote.
*
* @return string[]
*/
protected function getTokenSeparators() : array
{
return ['\"'];
}
/**
* Constructs a LiteralPart and returns it.
*/
protected function getPartForToken(string $token, bool $isLiteral) : ?IHeaderPart
{
return $this->partFactory->newToken($token, $isLiteral, true);
}
/**
* Overridden to combine all part values into a single string and return it
* as an array with a single element.
*
* The returned IHeaderParts is an array containing a single
* QuotedLiteralPart.
*
* @param IHeaderPart[] $parts
* @return IHeaderPart[]
*/
protected function processParts(array $parts) : array
{
return [$this->partFactory->newQuotedLiteralPart($parts)];
}
}

View File

@@ -0,0 +1,33 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Consumer;
use ZBateson\MailMimeParser\Header\IHeaderPart;
use ZBateson\MailMimeParser\Header\Part\MimeToken;
/**
* Allows for mime-encoded parts inside a quoted part.
*
* @author Zaahid Bateson
*/
class QuotedStringMimeLiteralPartConsumerService extends QuotedStringConsumerService
{
/**
* Constructs a LiteralPart and returns it.
*
* @param bool $isLiteral not used - everything in a quoted string is a
* literal
*/
protected function getPartForToken(string $token, bool $isLiteral) : ?IHeaderPart
{
if (!$isLiteral && \preg_match('/' . MimeToken::MIME_PART_PATTERN . '/', $token)) {
return $this->partFactory->newMimeToken($token);
}
return $this->partFactory->newToken($token, $isLiteral);
}
}

View File

@@ -0,0 +1,37 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Consumer;
use ZBateson\MailMimeParser\Header\Part\MimeToken;
/**
* Provides a getTokenSplitPattern for consumers that could have quoted parts
* that are mime-header-encoded.
*
* @author Zaahid Bateson
*/
trait QuotedStringMimeLiteralPartTokenSplitPatternTrait
{
/**
* Overridden to use a specialized regex for finding mime-encoded parts
* (RFC 2047).
*
* Some implementations seem to place mime-encoded parts within quoted
* parameters, and split the mime-encoded parts across multiple split
* parameters. The specialized regex doesn't allow double quotes inside a
* mime encoded part, so it can be "continued" in another parameter.
*
* @return string the regex pattern
*/
protected function getTokenSplitPattern() : string
{
$sChars = \implode('|', $this->getAllTokenSeparators());
$mimePartPattern = MimeToken::MIME_PART_PATTERN_NO_QUOTES;
return '~(' . $mimePartPattern . '|\\\\\r\n|\\\\.|' . $sChars . ')~ms';
}
}

View File

@@ -0,0 +1,68 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Consumer\Received;
/**
* Parses a so-called "extended-domain" (from and by) part of a Received header.
*
* Looks for and extracts the following fields from an extended-domain part:
* Name, Hostname and Address.
*
* The Name part is always the portion of the extended-domain part existing on
* its own, outside of the parenthesized hostname and address part. This is
* true regardless of whether an address is used as the name, as its assumed to
* be the string used to identify the server, whatever it may be.
*
* The parenthesized part normally (but not necessarily) following a name must
* "look like" a tcp-info section of an extended domain as defined by RFC5321.
* The validation is very purposefully very loose to be accommodating to many
* erroneous implementations. The only restriction is the host part must
* contain two characters, the first being alphanumeric, followed by any number
* of more alphanumeric, '.', and '-' characters. The address part must be
* within square brackets, '[]'... although an address outside of square
* brackets could be matched by the domain matcher if it exists alone within the
* parentheses. The address is any number of '.', numbers, ':' and letters a-f.
* This allows it to match ipv6 addresses as well. In addition, the address may
* start with the string "ipv6", and may be followed by a port number as some
* implementations seem to do.
*
* Strings in parentheses not matching the aforementioned 'domain/address'
* pattern will be considered comments, and will be returned as a separate
* CommentPart.
*
* @see https://tools.ietf.org/html/rfc5321#section-4.4
* @see https://github.com/Te-k/pyreceived/blob/master/test.py
* @author Zaahid Bateson
* @author Mariusz Krzaczkowski
*/
class DomainConsumerService extends GenericReceivedConsumerService
{
/**
* Overridden to return true if the passed token is a closing parenthesis.
*/
protected function isEndToken(string $token) : bool
{
if ($token === ')') {
return true;
}
return parent::isEndToken($token);
}
/**
* Creates a single ReceivedDomainPart out of matched parts. If an
* unmatched parenthesized expression was found, it's returned as a
* CommentPart.
*
* @param \ZBateson\MailMimeParser\Header\Part\HeaderPart[] $parts
* @return \ZBateson\MailMimeParser\Header\Part\ReceivedDomainPart[]|\ZBateson\MailMimeParser\Header\Part\CommentPart[]|\ZBateson\MailMimeParser\Header\Part\HeaderPart[]
*/
protected function processParts(array $parts) : array
{
return [$this->partFactory->newReceivedDomainPart($this->partName, $parts)];
}
}

View File

@@ -0,0 +1,113 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Consumer\Received;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Header\Consumer\AbstractGenericConsumerService;
use ZBateson\MailMimeParser\Header\Consumer\CommentConsumerService;
use ZBateson\MailMimeParser\Header\Part\HeaderPartFactory;
/**
* Consumes simple literal strings for parts of a Received header.
*
* Starts consuming when the initialized $partName string is located, for
* instance when initialized with "FROM", will start consuming on " FROM" or
* "FROM ".
*
* The consumer ends when any possible "Received" header part is found, namely
* on one of the following tokens: from, by, via, with, id, for, or when the
* start token for the date stamp is found, ';'.
*
* The consumer allows comments in and around the consumer... although the
* Received header specification only allows them before a part, for example,
* technically speaking this is valid:
*
* "FROM machine (host) (comment) BY machine"
*
* However, this is not:
*
* "FROM machine (host) BY machine WITH (comment) ESMTP"
*
* The consumer will allow both.
*
* @author Zaahid Bateson
*/
class GenericReceivedConsumerService extends AbstractGenericConsumerService
{
/**
* @var string the current part name being parsed.
*
* This is always the lower-case name provided to the constructor, not the
* actual string that started the consumer, which could be in any case.
*/
protected $partName;
/**
* Constructor overridden to include $partName parameter.
*
*/
public function __construct(
LoggerInterface $logger,
HeaderPartFactory $partFactory,
CommentConsumerService $commentConsumerService,
string $partName
) {
parent::__construct($logger, $partFactory, [$commentConsumerService]);
$this->partName = $partName;
}
/**
* Returns true if the passed token matches (case-insensitively)
* $this->getPartName() with optional whitespace surrounding it.
*/
protected function isStartToken(string $token) : bool
{
$pattern = '/^' . \preg_quote($this->partName, '/') . '$/i';
return (\preg_match($pattern, $token) === 1);
}
/**
* Returns true if the token matches (case-insensitively) any of the
* following, with optional surrounding whitespace:
*
* o by
* o via
* o with
* o id
* o for
* o ;
*/
protected function isEndToken(string $token) : bool
{
return (\preg_match('/^(by|via|with|id|for|;)$/i', $token) === 1);
}
/**
* Returns a whitespace separator (for filtering ignorable whitespace
* between parts), and a separator matching the current part name as
* set on $this->partName.
*
* @return string[] an array of regex pattern matchers
*/
protected function getTokenSeparators() : array
{
return [
'\s+',
'(\A\s*|\s+)(?i)' . \preg_quote($this->partName, '/') . '(?-i)(?=\s+)'
];
}
/**
* @param \ZBateson\MailMimeParser\Header\IHeaderPart[] $parts
* @return \ZBateson\MailMimeParser\Header\IHeaderPart[]
*/
protected function processParts(array $parts) : array
{
return [$this->partFactory->newReceivedPart($this->partName, $parts)];
}
}

View File

@@ -0,0 +1,40 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Consumer\Received;
use ZBateson\MailMimeParser\Header\Consumer\DateConsumerService;
/**
* Parses the date portion of a Received header into a DatePart.
*
* The only difference between DateConsumerService and
* ReceivedDateConsumerService is the addition of a start token, ';', and a
* token separator (also ';').
*
* @author Zaahid Bateson
*/
class ReceivedDateConsumerService extends DateConsumerService
{
/**
* Returns true if the token is a ';'
*/
protected function isStartToken(string $token) : bool
{
return ($token === ';');
}
/**
* Returns an array containing ';'.
*
* @return string[] an array of regex pattern matchers
*/
protected function getTokenSeparators() : array
{
return [';'];
}
}

View File

@@ -0,0 +1,129 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Consumer;
use Iterator;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Header\Consumer\Received\DomainConsumerService;
use ZBateson\MailMimeParser\Header\Consumer\Received\GenericReceivedConsumerService;
use ZBateson\MailMimeParser\Header\Consumer\Received\ReceivedDateConsumerService;
use ZBateson\MailMimeParser\Header\Part\HeaderPartFactory;
use ZBateson\MailMimeParser\Header\Part\Token;
/**
* Parses a Received header into ReceivedParts, ReceivedDomainParts, a DatePart,
* and CommentParts.
*
* Parts that don't correspond to any of the above are discarded.
*
* @author Zaahid Bateson
*/
class ReceivedConsumerService extends AbstractConsumerService
{
public function __construct(
LoggerInterface $logger,
HeaderPartFactory $partFactory,
DomainConsumerService $fromDomainConsumerService,
DomainConsumerService $byDomainConsumerService,
GenericReceivedConsumerService $viaGenericReceivedConsumerService,
GenericReceivedConsumerService $withGenericReceivedConsumerService,
GenericReceivedConsumerService $idGenericReceivedConsumerService,
GenericReceivedConsumerService $forGenericReceivedConsumerService,
ReceivedDateConsumerService $receivedDateConsumerService,
CommentConsumerService $commentConsumerService
) {
parent::__construct(
$logger,
$partFactory,
[
$fromDomainConsumerService,
$byDomainConsumerService,
$viaGenericReceivedConsumerService,
$withGenericReceivedConsumerService,
$idGenericReceivedConsumerService,
$forGenericReceivedConsumerService,
$receivedDateConsumerService,
$commentConsumerService
]
);
}
/**
* ReceivedConsumerService doesn't have any token separators of its own.
* Sub-Consumers will return separators matching 'part' word separators, for
* example 'from' and 'by', and ';' for date, etc...
*
* @return string[] an array of regex pattern matchers
*/
protected function getTokenSeparators() : array
{
return [];
}
/**
* ReceivedConsumerService doesn't have an end token, and so this just
* returns false.
*/
protected function isEndToken(string $token) : bool
{
return false;
}
/**
* ReceivedConsumerService doesn't start consuming at a specific token, it's
* the base handler for the Received header, and so this always returns
* false.
*
* @codeCoverageIgnore
*/
protected function isStartToken(string $token) : bool
{
return false;
}
/**
* Overridden to exclude the MimeLiteralPart pattern that comes by default
* in AbstractConsumer.
*
* @return string the regex pattern
*/
protected function getTokenSplitPattern() : string
{
$sChars = \implode('|', $this->getAllTokenSeparators());
return '~(' . $sChars . ')~';
}
/**
* Overridden to /not/ advance when the end token matches a start token for
* a sub-consumer.
*/
protected function advanceToNextToken(Iterator $tokens, bool $isStartToken) : static
{
if ($isStartToken) {
$tokens->next();
} elseif ($tokens->valid() && !$this->isEndToken($tokens->current())) {
foreach ($this->subConsumers as $consumer) {
if ($consumer->isStartToken($tokens->current())) {
return $this;
}
}
$tokens->next();
}
return $this;
}
/**
* @param \ZBateson\MailMimeParser\Header\IHeaderPart[] $parts
* @return \ZBateson\MailMimeParser\Header\IHeaderPart[]
*/
protected function processParts(array $parts) : array
{
// filtering out tokens (filters out the names, e.g. 'by' or 'with')
return \array_values(\array_filter($parts, fn ($p) => !$p instanceof Token));
}
}

View File

@@ -0,0 +1,62 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Consumer;
use Iterator;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Header\IHeaderPart;
use ZBateson\MailMimeParser\Header\Part\MimeToken;
use ZBateson\MailMimeParser\Header\Part\MimeTokenPartFactory;
/**
* Extends AbstractGenericConsumerService to use a MimeTokenPartFactory, and
* to preserve all whitespace and escape sequences as-is (unlike other headers
* subject headers don't have escape chars such as '\\' for a backslash).
*
* SubjectConsumerService doesn't define any sub-consumers.
*
* @author Zaahid Bateson
*/
class SubjectConsumerService extends AbstractGenericConsumerService
{
public function __construct(LoggerInterface $logger, MimeTokenPartFactory $partFactory)
{
parent::__construct($logger, $partFactory);
}
/**
* Overridden to preserve whitespace.
*
* Whitespace between two words is preserved unless the whitespace begins
* with a newline (\n or \r\n), in which case the entire string of
* whitespace is discarded, and a single space ' ' character is used in its
* place.
*/
protected function getPartForToken(string $token, bool $isLiteral) : ?IHeaderPart
{
if (\preg_match('/' . MimeToken::MIME_PART_PATTERN . '/', $token)) {
return $this->partFactory->newMimeToken($token);
}
return $this->partFactory->newSubjectToken($token);
}
/**
* Returns an array of \ZBateson\MailMimeParser\Header\Part\HeaderPart for
* the current token on the iterator.
*
* Overridden from AbstractConsumerService to remove special filtering for
* backslash escaping, which also seems to not apply to Subject headers at
* least in ThunderBird's implementation.
*
* @return IHeaderPart[]
*/
protected function getTokenParts(Iterator $tokens) : array
{
return $this->getConsumerTokenParts($tokens);
}
}

View File

@@ -0,0 +1,67 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header;
use DateTime;
use DateTimeImmutable;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Header\Consumer\DateConsumerService;
use ZBateson\MailMimeParser\Header\Part\DatePart;
use ZBateson\MailMimeParser\MailMimeParser;
/**
* Reads a DatePart value header in either RFC 2822 or RFC 822 format.
*
* @author Zaahid Bateson
*/
class DateHeader extends AbstractHeader
{
public function __construct(
string $name,
string $value,
?LoggerInterface $logger = null,
?DateConsumerService $consumerService = null
) {
$di = MailMimeParser::getGlobalContainer();
parent::__construct(
$logger ?? $di->get(LoggerInterface::class),
$consumerService ?? $di->get(DateConsumerService::class),
$name,
$value
);
}
/**
* Convenience method returning the part's DateTime object, or null if the
* date could not be parsed.
*
* @return ?DateTime The parsed DateTime object.
*/
public function getDateTime() : ?DateTime
{
if (!empty($this->parts) && $this->parts[0] instanceof DatePart) {
return $this->parts[0]->getDateTime();
}
return null;
}
/**
* Returns a DateTimeImmutable for the part's DateTime object, or null if
* the date could not be parsed.
*
* @return ?DateTimeImmutable The parsed DateTimeImmutable object.
*/
public function getDateTimeImmutable() : ?DateTimeImmutable
{
$dateTime = $this->getDateTime();
if ($dateTime !== null) {
return DateTimeImmutable::createFromMutable($dateTime);
}
return null;
}
}

View File

@@ -0,0 +1,47 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Header\Consumer\GenericConsumerMimeLiteralPartService;
use ZBateson\MailMimeParser\MailMimeParser;
/**
* Reads a generic header.
*
* Header's may contain mime-encoded parts, quoted parts, and comments. The
* string value is the combined value of all its parts.
*
* @author Zaahid Bateson
*/
class GenericHeader extends AbstractHeader
{
public function __construct(
string $name,
string $value,
?LoggerInterface $logger = null,
?GenericConsumerMimeLiteralPartService $consumerService = null
) {
$di = MailMimeParser::getGlobalContainer();
parent::__construct(
$logger ?? $di->get(LoggerInterface::class),
$consumerService ?? $di->get(DateConsumerService::class),
$name,
$value
);
parent::__construct($logger, $consumerService, $name, $value);
}
public function getValue() : ?string
{
if (!empty($this->parts)) {
return \implode('', \array_map(function($p) { return $p->getValue(); }, $this->parts));
}
return null;
}
}

View File

@@ -0,0 +1,95 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header;
/**
* List of header name constants.
*
* @author Thomas Landauer
*/
abstract class HeaderConsts
{
// Headers according to the table at https://tools.ietf.org/html/rfc5322#section-3.6
public const RETURN_PATH = 'Return-Path';
public const RECEIVED = 'Received';
public const RESENT_DATE = 'Resent-Date';
public const RESENT_FROM = 'Resent-From';
public const RESENT_SENDER = 'Resent-Sender';
public const RESENT_TO = 'Resent-To';
public const RESENT_CC = 'Resent-Cc';
public const RESENT_BCC = 'Resent-Bcc';
public const RESENT_MSD_ID = 'Resent-Message-ID';
public const RESENT_MESSAGE_ID = self::RESENT_MSD_ID;
public const ORIG_DATE = 'Date';
public const DATE = self::ORIG_DATE;
public const FROM = 'From';
public const SENDER = 'Sender';
public const REPLY_TO = 'Reply-To';
public const TO = 'To';
public const CC = 'Cc';
public const BCC = 'Bcc';
public const MESSAGE_ID = 'Message-ID';
public const IN_REPLY_TO = 'In-Reply-To';
public const REFERENCES = 'References';
public const SUBJECT = 'Subject';
public const COMMENTS = 'Comments';
public const KEYWORDS = 'Keywords';
// https://datatracker.ietf.org/doc/html/rfc4021#section-2.2
public const MIME_VERSION = 'MIME-Version';
public const CONTENT_TYPE = 'Content-Type';
public const CONTENT_TRANSFER_ENCODING = 'Content-Transfer-Encoding';
public const CONTENT_ID = 'Content-ID';
public const CONTENT_DESCRIPTION = 'Content-Description';
public const CONTENT_DISPOSITION = 'Content-Disposition';
public const CONTENT_LANGUAGE = 'Content-Language';
public const CONTENT_BASE = 'Content-Base';
public const CONTENT_LOCATION = 'Content-Location';
public const CONTENT_FEATURES = 'Content-features';
public const CONTENT_ALTERNATIVE = 'Content-Alternative';
public const CONTENT_MD5 = 'Content-MD5';
public const CONTENT_DURATION = 'Content-Duration';
// https://datatracker.ietf.org/doc/html/rfc3834
public const AUTO_SUBMITTED = 'Auto-Submitted';
}

View File

@@ -0,0 +1,213 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header;
use Psr\Log\LoggerInterface;
use ReflectionClass;
use ZBateson\MailMimeParser\Header\Consumer\AddressBaseConsumerService;
use ZBateson\MailMimeParser\Header\Consumer\DateConsumerService;
use ZBateson\MailMimeParser\Header\Consumer\GenericConsumerMimeLiteralPartService;
use ZBateson\MailMimeParser\Header\Consumer\IConsumerService;
use ZBateson\MailMimeParser\Header\Consumer\IdBaseConsumerService;
use ZBateson\MailMimeParser\Header\Consumer\ParameterConsumerService;
use ZBateson\MailMimeParser\Header\Consumer\ReceivedConsumerService;
use ZBateson\MailMimeParser\Header\Consumer\SubjectConsumerService;
use ZBateson\MailMimeParser\Header\Part\MimeTokenPartFactory;
/**
* Constructs various IHeader types depending on the type of header passed.
*
* If the passed header resolves to a specific defined header type, it is parsed
* as such. Otherwise, a GenericHeader is instantiated and returned. Headers
* are mapped as follows:
*
* - {@see AddressHeader}: From, To, Cc, Bcc, Sender, Reply-To, Resent-From,
* Resent-To, Resent-Cc, Resent-Bcc, Resent-Reply-To, Return-Path,
* Delivered-To
* - {@see DateHeader}: Date, Resent-Date, Delivery-Date, Expires, Expiry-Date,
* Reply-By
* - {@see ParameterHeader}: Content-Type, Content-Disposition, Received-SPF,
* Authentication-Results, DKIM-Signature, Autocrypt
* - {@see SubjectHeader}: Subject
* - {@see IdHeader}: Message-ID, Content-ID, In-Reply-To, References
* - {@see ReceivedHeader}: Received
*
* @author Zaahid Bateson
*/
class HeaderFactory
{
protected LoggerInterface $logger;
/**
* @var IConsumerService[] array of available consumer service classes
*/
protected array $consumerServices;
/**
* @var MimeTokenPartFactory for mime decoding.
*/
protected MimeTokenPartFactory $mimeTokenPartFactory;
/**
* @var string[][] maps IHeader types to headers.
*/
protected $types = [
AddressHeader::class => [
'from',
'to',
'cc',
'bcc',
'sender',
'replyto',
'resentfrom',
'resentto',
'resentcc',
'resentbcc',
'resentreplyto',
'returnpath',
'deliveredto',
],
DateHeader::class => [
'date',
'resentdate',
'deliverydate',
'expires',
'expirydate',
'replyby',
],
ParameterHeader::class => [
'contenttype',
'contentdisposition',
'receivedspf',
'authenticationresults',
'dkimsignature',
'autocrypt',
],
SubjectHeader::class => [
'subject',
],
IdHeader::class => [
'messageid',
'contentid',
'inreplyto',
'references'
],
ReceivedHeader::class => [
'received'
]
];
/**
* @var string Defines the generic IHeader type to use for headers that
* aren't mapped in $types
*/
protected $genericType = GenericHeader::class;
public function __construct(
LoggerInterface $logger,
MimeTokenPartFactory $mimeTokenPartFactory,
AddressBaseConsumerService $addressBaseConsumerService,
DateConsumerService $dateConsumerService,
GenericConsumerMimeLiteralPartService $genericConsumerMimeLiteralPartService,
IdBaseConsumerService $idBaseConsumerService,
ParameterConsumerService $parameterConsumerService,
ReceivedConsumerService $receivedConsumerService,
SubjectConsumerService $subjectConsumerService
) {
$this->logger = $logger;
$this->mimeTokenPartFactory = $mimeTokenPartFactory;
$this->consumerServices = [
AddressBaseConsumerService::class => $addressBaseConsumerService,
DateConsumerService::class => $dateConsumerService,
GenericConsumerMimeLiteralPartService::class => $genericConsumerMimeLiteralPartService,
IdBaseConsumerService::class => $idBaseConsumerService,
ParameterConsumerService::class => $parameterConsumerService,
ReceivedConsumerService::class => $receivedConsumerService,
SubjectConsumerService::class => $subjectConsumerService
];
}
/**
* Returns the string in lower-case, and with non-alphanumeric characters
* stripped out.
*
* @param string $header The header name
* @return string The normalized header name
*/
public function getNormalizedHeaderName(string $header) : string
{
return \preg_replace('/[^a-z0-9]/', '', \strtolower($header));
}
/**
* Returns the name of an IHeader class for the passed header name.
*
* @param string $name The header name.
* @return string The Fully Qualified class name.
*/
private function getClassFor(string $name) : string
{
$test = $this->getNormalizedHeaderName($name);
foreach ($this->types as $class => $matchers) {
foreach ($matchers as $matcher) {
if ($test === $matcher) {
return $class;
}
}
}
return $this->genericType;
}
/**
* Creates an IHeader instance for the passed header name and value, and
* returns it.
*
* @param string $name The header name.
* @param string $value The header value.
* @return IHeader The created header object.
*/
public function newInstance(string $name, string $value) : IHeader
{
$class = $this->getClassFor($name);
$this->logger->debug(
'Creating {class} for header with name "{name}" and value "{value}"',
['class' => $class, 'name' => $name, 'value' => $value]
);
return $this->newInstanceOf($name, $value, $class);
}
/**
* Creates an IHeader instance for the passed header name and value using
* the passed IHeader class, and returns it.
*
* @param string $name The header name.
* @param string $value The header value.
* @param string $iHeaderClass The class to use for header creation
* @return IHeader The created header object.
*/
public function newInstanceOf(string $name, string $value, string $iHeaderClass) : IHeader
{
$ref = new ReflectionClass($iHeaderClass);
$params = $ref->getConstructor()->getParameters();
if ($ref->isSubclassOf(MimeEncodedHeader::class)) {
return new $iHeaderClass(
$name,
$value,
$this->logger,
$this->mimeTokenPartFactory,
$this->consumerServices[$params[4]->getType()->getName()]
);
}
return new $iHeaderClass(
$name,
$value,
$this->logger,
$this->consumerServices[$params[3]->getType()->getName()]
);
}
}

View File

@@ -0,0 +1,84 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header;
use ZBateson\MailMimeParser\IErrorBag;
/**
* A mime email header line consisting of a name and value.
*
* The header object provides methods to access the header's name, raw value,
* and also its parsed value. The parsed value will depend on the type of
* header and in some cases may be broken up into other parts (for example email
* addresses in an address header, or parameters in a parameter header).
*
* @author Zaahid Bateson
*/
interface IHeader extends IErrorBag
{
/**
* Returns an array of IHeaderPart objects the header's value has been
* parsed into, excluding any
* {@see \ZBateson\MailMimeParser\Header\Part\CommentPart}s.
*
* To retrieve all parts /including/ CommentParts, {@see getAllParts()}.
*
* @return IHeaderPart[] The array of parts.
*/
public function getParts() : array;
/**
* Returns an array of all IHeaderPart objects the header's value has been
* parsed into, including any CommentParts.
*
* @return IHeaderPart[] The array of parts.
*/
public function getAllParts() : array;
/**
* Returns an array of comments parsed from the header. If there are no
* comments in the header, an empty array is returned.
*
* @return string[]
*/
public function getComments() : array;
/**
* Returns the parsed 'value' of the header.
*
* For headers that contain multiple parts, like address headers (To, From)
* or parameter headers (Content-Type), the 'value' is the value of the
* first parsed part that isn't a comment.
*
* @return string The value
*/
public function getValue() : ?string;
/**
* Returns the raw value of the header.
*
* @return string The raw value.
*/
public function getRawValue() : string;
/**
* Returns the name of the header.
*
* @return string The name.
*/
public function getName() : string;
/**
* Returns the string representation of the header.
*
* i.e.: '<HeaderName>: <RawValue>'
*
* @return string The string representation.
*/
public function __toString() : string;
}

View File

@@ -0,0 +1,36 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header;
use Stringable;
use ZBateson\MailMimeParser\IErrorBag;
/**
* Represents a single parsed part of a header line's value.
*
* For header values with multiple parts, for instance a list of addresses, each
* address would be parsed into a single part.
*
* @author Zaahid Bateson
*/
interface IHeaderPart extends IErrorBag, Stringable
{
/**
* Returns the part's value.
*
* @return string The value of the part
*/
public function getValue() : ?string;
/**
* Returns any CommentParts under this part container.
*
* @return CommentPart[]
*/
public function getComments() : array;
}

View File

@@ -0,0 +1,71 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Header\Consumer\IdBaseConsumerService;
use ZBateson\MailMimeParser\Header\Part\CommentPart;
use ZBateson\MailMimeParser\Header\Part\MimeTokenPartFactory;
use ZBateson\MailMimeParser\MailMimeParser;
/**
* Represents a Content-ID, Message-ID, In-Reply-To or References header.
*
* For a multi-id header like In-Reply-To or References, all IDs can be
* retrieved by calling {@see IdHeader::getIds()}. Otherwise, to retrieve the
* first (or only) ID call {@see IdHeader::getValue()}.
*
* @author Zaahid Bateson
*/
class IdHeader extends MimeEncodedHeader
{
public function __construct(
string $name,
string $value,
?LoggerInterface $logger = null,
?MimeTokenPartFactory $mimeTokenPartFactory = null,
?IdBaseConsumerService $consumerService = null
) {
$di = MailMimeParser::getGlobalContainer();
parent::__construct(
$logger ?? $di->get(LoggerInterface::class),
$mimeTokenPartFactory ?? $di->get(MimeTokenPartFactory::class),
$consumerService ?? $di->get(IdBaseConsumerService::class),
$name,
$value
);
}
/**
* Returns the ID. Synonymous to calling getValue().
*
* @return string|null The ID
*/
public function getId() : ?string
{
return $this->getValue();
}
/**
* Returns all IDs parsed for a multi-id header like References or
* In-Reply-To.
*
* @return string[] An array of IDs
*/
public function getIds() : array
{
return \array_values(\array_map(
function($p) {
return $p->getValue();
},
\array_filter($this->parts, function($p) {
return !($p instanceof CommentPart);
})
));
}
}

View File

@@ -0,0 +1,66 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Header\Consumer\IConsumerService;
use ZBateson\MailMimeParser\Header\Part\MimeToken;
use ZBateson\MailMimeParser\Header\Part\MimeTokenPartFactory;
/**
* Allows a header to be mime-encoded and be decoded with a consumer after
* decoding.
*
* @author Zaahid Bateson
*/
abstract class MimeEncodedHeader extends AbstractHeader
{
/**
* @var MimeTokenPartFactory for mime decoding.
*/
protected MimeTokenPartFactory $mimeTokenPartFactory;
/**
* @var MimeLiteralPart[] the mime encoded parsed parts contained in this
* header
*/
protected $mimeEncodedParsedParts = [];
public function __construct(
LoggerInterface $logger,
MimeTokenPartFactory $mimeTokenPartFactory,
IConsumerService $consumerService,
string $name,
string $value
) {
$this->mimeTokenPartFactory = $mimeTokenPartFactory;
parent::__construct($logger, $consumerService, $name, $value);
}
/**
* Mime-decodes any mime-encoded parts prior to invoking
* parent::parseHeaderValue.
*/
protected function parseHeaderValue(IConsumerService $consumer, string $value) : void
{
// handled differently from MimeLiteralPart's decoding which ignores
// whitespace between parts, etc...
$matchp = '~(' . MimeToken::MIME_PART_PATTERN . ')~';
$aMimeParts = \preg_split($matchp, $value, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
$this->mimeEncodedParsedParts = \array_map([$this->mimeTokenPartFactory, 'newInstance'], $aMimeParts);
parent::parseHeaderValue(
$consumer,
\implode('', \array_map(fn ($part) => $part->getValue(), $this->mimeEncodedParsedParts))
);
}
protected function getErrorBagChildren() : array
{
return \array_values(\array_filter(\array_merge($this->getAllParts(), $this->mimeEncodedParsedParts)));
}
}

View File

@@ -0,0 +1,99 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Header\Consumer\IConsumerService;
use ZBateson\MailMimeParser\Header\Consumer\ParameterConsumerService;
use ZBateson\MailMimeParser\Header\Part\NameValuePart;
use ZBateson\MailMimeParser\MailMimeParser;
/**
* Represents a header containing an optional main value part and subsequent
* name/value pairs.
*
* If header doesn't contain a non-parameterized 'main' value part, 'getValue()'
* will return the value of the first parameter.
*
* For example: 'Content-Type: text/html; charset=utf-8; name=test.ext'
*
* The 'text/html' portion is considered the 'main' value, and 'charset' and
* 'name' are added as parameterized name/value pairs.
*
* With the Autocrypt header, there is no main value portion, for example:
* 'Autocrypt: addr=zb@example.com; keydata=b64-data'
*
* In that example, calling ```php $header->getValue() ``` would return
* 'zb@example.com', as would calling ```php $header->getValueFor('addr'); ```.
*
* @author Zaahid Bateson
*/
class ParameterHeader extends AbstractHeader
{
/**
* @var ParameterPart[] key map of lower-case parameter names and associated
* ParameterParts.
*/
protected array $parameters = [];
public function __construct(
string $name,
string $value,
?LoggerInterface $logger = null,
?ParameterConsumerService $consumerService = null
) {
$di = MailMimeParser::getGlobalContainer();
parent::__construct(
$logger ?? $di->get(LoggerInterface::class),
$consumerService ?? $di->get(ParameterConsumerService::class),
$name,
$value
);
}
/**
* Overridden to assign ParameterParts to a map of lower-case parameter
* names to ParameterParts.
*/
protected function parseHeaderValue(IConsumerService $consumer, string $value) : void
{
parent::parseHeaderValue($consumer, $value);
foreach ($this->parts as $part) {
if ($part instanceof NameValuePart) {
$this->parameters[\strtolower($part->getName())] = $part;
}
}
}
/**
* Returns true if a parameter exists with the passed name.
*
* @param string $name The parameter to look up.
*/
public function hasParameter(string $name) : bool
{
return isset($this->parameters[\strtolower($name)]);
}
/**
* Returns the value of the parameter with the given name, or $defaultValue
* if not set.
*
* @param string $name The parameter to retrieve.
* @param string $defaultValue Optional default value (defaulting to null if
* not provided).
* @return string|null The parameter's value.
*/
public function getValueFor(string $name, ?string $defaultValue = null) : ?string
{
if (!$this->hasParameter($name)) {
return $defaultValue;
}
return $this->parameters[\strtolower($name)]->getValue();
}
}

View File

@@ -0,0 +1,86 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Part;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use ZBateson\MbWrapper\MbWrapper;
/**
* Holds a group of addresses and a group name.
*
* @author Zaahid Bateson
*/
class AddressGroupPart extends NameValuePart
{
/**
* @var AddressPart[] an array of AddressParts
*/
protected array $addresses;
/**
* Creates an AddressGroupPart out of the passed array of AddressParts/
* AddressGroupParts and name.
*
* @param HeaderPart[] $nameParts
* @param AddressPart[]|AddressGroupPart[] $addressesAndGroupParts
*/
public function __construct(
LoggerInterface $logger,
MbWrapper $charsetConverter,
array $nameParts,
array $addressesAndGroupParts
) {
parent::__construct(
$logger,
$charsetConverter,
$nameParts,
$addressesAndGroupParts
);
$this->addresses = \array_merge(...\array_map(
fn ($p) => ($p instanceof AddressGroupPart) ? $p->getAddresses() : [$p],
$addressesAndGroupParts
));
// for backwards compatibility
$this->value = $this->name;
}
/**
* Return the AddressGroupPart's array of addresses.
*
* @return AddressPart[] An array of address parts.
*/
public function getAddresses() : array
{
return $this->addresses;
}
/**
* Returns the AddressPart at the passed index or null.
*
* @param int $index The 0-based index.
* @return ?AddressPart The address.
*/
public function getAddress(int $index) : ?AddressPart
{
if (!isset($this->addresses[$index])) {
return null;
}
return $this->addresses[$index];
}
protected function validate() : void
{
if ($this->name === null || \mb_strlen($this->name) === 0) {
$this->addError('Address group doesn\'t have a name', LogLevel::ERROR);
}
if (empty($this->addresses)) {
$this->addError('Address group doesn\'t have any email addresses defined in it', LogLevel::NOTICE);
}
}
}

View File

@@ -0,0 +1,57 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Part;
use Psr\Log\LogLevel;
/**
* Holds a single address or name/address pair.
*
* The name part of the address may be mime-encoded, but the email address part
* can't be mime-encoded. Any whitespace in the email address part is stripped
* out.
*
* A convenience method, getEmail, is provided for clarity -- but getValue
* returns the email address as well.
*
* @author Zaahid Bateson
*/
class AddressPart extends NameValuePart
{
protected function getValueFromParts(array $parts) : string
{
return \implode('', \array_map(
function($p) {
if ($p instanceof AddressPart) {
return $p->getValue();
} elseif ($p instanceof QuotedLiteralPart && $p->getValue() !== '') {
return '"' . \preg_replace('/(["\\\])/', '\\\$1', $p->getValue()) . '"';
}
return \preg_replace('/\s+/', '', $p->getValue());
},
$parts
));
}
/**
* Returns the email address.
*
* @return string The email address.
*/
public function getEmail() : string
{
return $this->value;
}
protected function validate() : void
{
if (empty($this->value)) {
$this->addError('Address doesn\'t contain an email address', LogLevel::ERROR);
}
}
}

View File

@@ -0,0 +1,77 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Part;
use Psr\Log\LoggerInterface;
use ZBateson\MbWrapper\MbWrapper;
/**
* Represents a mime header comment -- text in a structured mime header
* value existing within parentheses.
*
* @author Zaahid Bateson
*/
class CommentPart extends ContainerPart
{
/**
* @var HeaderPartFactory used to create intermediate parts.
*/
protected HeaderPartFactory $partFactory;
/**
* @var string the contents of the comment
*/
protected string $comment;
public function __construct(
LoggerInterface $logger,
MbWrapper $charsetConverter,
HeaderPartFactory $partFactory,
array $children
) {
$this->partFactory = $partFactory;
parent::__construct($logger, $charsetConverter, $children);
$this->comment = $this->value;
$this->value = '';
$this->isSpace = true;
$this->canIgnoreSpacesBefore = true;
$this->canIgnoreSpacesAfter = true;
}
protected function getValueFromParts(array $parts) : string
{
$partFactory = $this->partFactory;
return parent::getValueFromParts(\array_map(
function($p) use ($partFactory) {
if ($p instanceof CommentPart) {
return $partFactory->newQuotedLiteralPart([$partFactory->newToken('(' . $p->getComment() . ')')]);
} elseif ($p instanceof QuotedLiteralPart) {
return $partFactory->newQuotedLiteralPart([$partFactory->newToken('"' . \str_replace('(["\\])', '\$1', $p->getValue()) . '"')]);
}
return $p;
},
$parts
));
}
/**
* Returns the comment's text.
*/
public function getComment() : string
{
return $this->comment;
}
/**
* Returns an empty string.
*/
public function getValue() : string
{
return '';
}
}

View File

@@ -0,0 +1,128 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Part;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\ErrorBag;
use ZBateson\MbWrapper\MbWrapper;
/**
* Base HeaderPart for a part that consists of other parts.
*
* The base container part constructs a string value out of the passed parts by
* concatenating their values, discarding whitespace between parts that can be
* ignored (in general allows for a single space but removes extras.)
*
* A ContainerPart can also contain any number of child comment parts. The
* CommentParts in this and all child parts can be returned by calling
* getComments.
*
* @author Zaahid Bateson
*/
class ContainerPart extends HeaderPart
{
/**
* @var HeaderPart[] parts that were used to create this part, collected for
* proper error reporting and validation.
*/
protected $children = [];
public function __construct(
LoggerInterface $logger,
MbWrapper $charsetConverter,
array $children
) {
ErrorBag::__construct($logger);
$this->charsetConverter = $charsetConverter;
$this->children = $children;
$str = (!empty($children)) ? $this->getValueFromParts($children) : '';
parent::__construct(
$logger,
$this->charsetConverter,
$str
);
}
/**
* Filters out ignorable space tokens.
*
* Spaces are removed if parts on either side of it have their
* canIgnoreSpaceAfter/canIgnoreSpaceBefore properties set to true.
*
* @param HeaderPart[] $parts
* @return HeaderPart[]
*/
protected function filterIgnoredSpaces(array $parts) : array
{
$ends = (object) ['isSpace' => true, 'canIgnoreSpacesAfter' => true, 'canIgnoreSpacesBefore' => true, 'value' => ''];
$spaced = \array_merge($parts, [$ends]);
$filtered = \array_slice(\array_reduce(
\array_slice(\array_keys($spaced), 0, -1),
function($carry, $key) use ($spaced, $ends) {
$p = $spaced[$key];
$l = \end($carry);
$a = $spaced[$key + 1];
if ($p->isSpace && $a === $ends) {
// trim
if ($l->isSpace) {
\array_pop($carry);
}
return $carry;
} elseif ($p->isSpace && ($l->isSpace || ($l->canIgnoreSpacesAfter && $a->canIgnoreSpacesBefore))) {
return $carry;
}
return \array_merge($carry, [$p]);
},
[$ends]
), 1);
return $filtered;
}
/**
* Creates the string value representation of this part constructed from the
* child parts passed to it.
*
* The default implementation filters out ignorable whitespace between
* parts, and concatenates parts calling 'getValue'.
*
* @param HeaderParts[] $parts
*/
protected function getValueFromParts(array $parts) : string
{
return \array_reduce($this->filterIgnoredSpaces($parts), fn ($c, $p) => $c . $p->getValue(), '');
}
/**
* Returns the child parts this container part consists of.
*
* @return IHeaderPart[]
*/
public function getChildParts() : array
{
return $this->children;
}
public function getComments() : array
{
return \array_merge(...\array_filter(\array_map(
fn ($p) => ($p instanceof CommentPart) ? [$p] : $p->getComments(),
$this->children
)));
}
/**
* Returns this part's children, same as getChildParts().
*
* @return ErrorBag
*/
protected function getErrorBagChildren() : array
{
return $this->children;
}
}

View File

@@ -0,0 +1,72 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Part;
use DateTime;
use Exception;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use ZBateson\MbWrapper\MbWrapper;
/**
* Represents the value of a date header, parsing the date into a \DateTime
* object.
*
* @author Zaahid Bateson
*/
class DatePart extends ContainerPart
{
/**
* @var DateTime the parsed date, or null if the date could not be parsed
*/
protected ?DateTime $date = null;
/**
* Tries parsing the passed token as an RFC 2822 date, and failing that into
* an RFC 822 date, and failing that, tries to parse it by calling
* new DateTime($value).
*
* @param HeaderPart[] $children
*/
public function __construct(
LoggerInterface $logger,
MbWrapper $charsetConverter,
array $children
) {
// parent::__construct converts character encoding -- may cause problems sometimes.
parent::__construct($logger, $charsetConverter, $children);
$this->value = $dateToken = \trim($this->value);
// Missing "+" in timezone definition. eg: Thu, 13 Mar 2014 15:02:47 0000 (not RFC compliant)
// Won't result in an Exception, but in a valid DateTime in year `0000` - therefore we need to check this first:
if (\preg_match('# [0-9]{4}$#', $dateToken)) {
$dateToken = \preg_replace('# ([0-9]{4})$#', ' +$1', $dateToken);
// @see https://bugs.php.net/bug.php?id=42486
} elseif (\preg_match('#UT$#', $dateToken)) {
$dateToken = $dateToken . 'C';
}
try {
$this->date = new DateTime($dateToken);
} catch (Exception $e) {
$this->addError(
"Unable to parse date from header: \"{$dateToken}\"",
LogLevel::ERROR,
$e
);
}
}
/**
* Returns a DateTime object or null if it can't be parsed.
*/
public function getDateTime() : ?DateTime
{
return $this->date;
}
}

View File

@@ -0,0 +1,116 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Part;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use ZBateson\MailMimeParser\ErrorBag;
use ZBateson\MailMimeParser\Header\IHeaderPart;
use ZBateson\MbWrapper\MbWrapper;
use ZBateson\MbWrapper\UnsupportedCharsetException;
/**
* Abstract base class representing a single part of a parsed header.
*
* @author Zaahid Bateson
*/
abstract class HeaderPart extends ErrorBag implements IHeaderPart
{
/**
* @var string the representative value of the part after any conversion or
* processing has been done on it (e.g. removing new lines, converting,
* whatever else).
*/
protected string $value;
/**
* @var MbWrapper $charsetConverter the charset converter used for
* converting strings in HeaderPart::convertEncoding
*/
protected MbWrapper $charsetConverter;
/**
* @var bool set to true to ignore spaces before this part
*/
protected bool $canIgnoreSpacesBefore = false;
/**
* @var bool set to true to ignore spaces after this part
*/
protected bool $canIgnoreSpacesAfter = false;
/**
* True if the part is a space token
*/
protected bool $isSpace = false;
public function __construct(LoggerInterface $logger, MbWrapper $charsetConverter, string $value)
{
parent::__construct($logger);
$this->charsetConverter = $charsetConverter;
$this->value = $value;
}
/**
* Returns the part's representative value after any necessary processing
* has been performed. For the raw value, call getRawValue().
*/
public function getValue() : string
{
return $this->value;
}
/**
* Returns the value of the part (which is a string).
*
* @return string the value
*/
public function __toString() : string
{
return $this->value;
}
/**
* Ensures the encoding of the passed string is set to UTF-8.
*
* The method does nothing if the passed $from charset is UTF-8 already, or
* if $force is set to false and mb_check_encoding for $str returns true
* for 'UTF-8'.
*
* @return string utf-8 string
*/
protected function convertEncoding(string $str, string $from = 'ISO-8859-1', bool $force = false) : string
{
if ($from !== 'UTF-8') {
// mime header part decoding will force it. This is necessary for
// UTF-7 because mb_check_encoding will return true
if ($force || !($this->charsetConverter->checkEncoding($str, 'UTF-8'))) {
try {
return $this->charsetConverter->convert($str, $from, 'UTF-8');
} catch (UnsupportedCharsetException $ce) {
$this->addError('Unable to convert charset', LogLevel::ERROR, $ce);
return $this->charsetConverter->convert($str, 'ISO-8859-1', 'UTF-8');
}
}
}
return $str;
}
public function getComments() : array
{
return [];
}
/**
* Default implementation returns an empty array.
*/
protected function getErrorBagChildren() : array
{
return [];
}
}

View File

@@ -0,0 +1,176 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Part;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Header\IHeaderPart;
use ZBateson\MbWrapper\MbWrapper;
/**
* Constructs and returns IHeaderPart objects.
*
* @author Zaahid Bateson
*/
class HeaderPartFactory
{
/**
* @var MbWrapper $charsetConverter passed to IHeaderPart constructors
* for converting strings in IHeaderPart::convertEncoding
*/
protected MbWrapper $charsetConverter;
protected LoggerInterface $logger;
public function __construct(LoggerInterface $logger, MbWrapper $charsetConverter)
{
$this->logger = $logger;
$this->charsetConverter = $charsetConverter;
}
/**
* Creates and returns a default IHeaderPart for this factory, allowing
* subclass factories for specialized IHeaderParts.
*
* The default implementation returns a new Token
*/
public function newInstance(string $value) : IHeaderPart
{
return $this->newToken($value);
}
/**
* Initializes and returns a new Token.
*/
public function newToken(string $value, bool $isLiteral = false, bool $preserveSpaces = false) : Token
{
return new Token($this->logger, $this->charsetConverter, $value, $isLiteral, $preserveSpaces);
}
/**
* Initializes and returns a new SubjectToken.
*/
public function newSubjectToken(string $value) : SubjectToken
{
return new SubjectToken($this->logger, $this->charsetConverter, $value);
}
/**
* Initializes and returns a new MimeToken.
*/
public function newMimeToken(string $value) : MimeToken
{
return new MimeToken($this->logger, $this->charsetConverter, $value);
}
/**
* Initializes and returns a new ContainerPart.
*
* @param HeaderPart[] $children
*/
public function newContainerPart(array $children) : ContainerPart
{
return new ContainerPart($this->logger, $this->charsetConverter, $children);
}
/**
* Instantiates and returns a SplitParameterPart.
*
* @param ParameterPart[] $children
*/
public function newSplitParameterPart(array $children) : SplitParameterPart
{
return new SplitParameterPart($this->logger, $this->charsetConverter, $this, $children);
}
/**
* Initializes and returns a new QuotedLiteralPart.
*
* @param HeaderPart[] $parts
*/
public function newQuotedLiteralPart(array $parts) : QuotedLiteralPart
{
return new QuotedLiteralPart($this->logger, $this->charsetConverter, $parts);
}
/**
* Initializes and returns a new CommentPart.
*
* @param HeaderPart[] $children
*/
public function newCommentPart(array $children) : CommentPart
{
return new CommentPart($this->logger, $this->charsetConverter, $this, $children);
}
/**
* Initializes and returns a new AddressPart.
*
* @param HeaderPart[] $nameParts
* @param HeaderPart[] $emailParts
*/
public function newAddress(array $nameParts, array $emailParts) : AddressPart
{
return new AddressPart($this->logger, $this->charsetConverter, $nameParts, $emailParts);
}
/**
* Initializes and returns a new AddressGroupPart
*
* @param HeaderPart[] $nameParts
* @param AddressPart[]|AddressGroupPart[] $addressesAndGroups
*/
public function newAddressGroupPart(array $nameParts, array $addressesAndGroups) : AddressGroupPart
{
return new AddressGroupPart($this->logger, $this->charsetConverter, $nameParts, $addressesAndGroups);
}
/**
* Initializes and returns a new DatePart
*
* @param HeaderPart[] $children
*/
public function newDatePart(array $children) : DatePart
{
return new DatePart($this->logger, $this->charsetConverter, $children);
}
/**
* Initializes and returns a new ParameterPart.
*
* @param HeaderPart[] $nameParts
*/
public function newParameterPart(array $nameParts, ContainerPart $valuePart) : ParameterPart
{
return new ParameterPart($this->logger, $this->charsetConverter, $nameParts, $valuePart);
}
/**
* Initializes and returns a new ReceivedPart.
*
* @param HeaderPart[] $children
*/
public function newReceivedPart(string $name, array $children) : ReceivedPart
{
return new ReceivedPart($this->logger, $this->charsetConverter, $name, $children);
}
/**
* Initializes and returns a new ReceivedDomainPart.
*
* @param HeaderPart[] $children
*/
public function newReceivedDomainPart(string $name, array $children) : ReceivedDomainPart
{
return new ReceivedDomainPart(
$this->logger,
$this->charsetConverter,
$name,
$children
);
}
}

View File

@@ -0,0 +1,109 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Part;
use Psr\Log\LoggerInterface;
use ZBateson\MbWrapper\MbWrapper;
/**
* Represents a single mime header part token, with the possibility of it being
* MIME-Encoded as per RFC-2047.
*
* MimeToken automatically decodes the value if it's encoded.
*
* @author Zaahid Bateson
*/
class MimeToken extends Token
{
/**
* @var string regex pattern matching a mime-encoded part
*/
public const MIME_PART_PATTERN = '=\?[^?=]+\?[QBqb]\?[^\?]+\?=';
/**
* @var string regex pattern used when parsing parameterized headers
*/
public const MIME_PART_PATTERN_NO_QUOTES = '=\?[^\?=]+\?[QBqb]\?[^\?"]+\?=';
/**
* @var ?string the language code if any, or null otherwise
*/
protected ?string $language = null;
/**
* @var ?string the charset if any, or null otherwise
*/
protected ?string $charset = null;
public function __construct(LoggerInterface $logger, MbWrapper $charsetConverter, string $value)
{
parent::__construct($logger, $charsetConverter, $value);
$this->value = $this->decodeMime(\preg_replace('/\r|\n/', '', $this->value));
$pattern = self::MIME_PART_PATTERN;
$this->canIgnoreSpacesBefore = (bool) \preg_match("/^\s*{$pattern}|\s+/", $this->rawValue);
$this->canIgnoreSpacesAfter = (bool) \preg_match("/{$pattern}\s*|\s+\$/", $this->rawValue);
}
/**
* Finds and replaces mime parts with their values.
*
* The method splits the token value into an array on mime-part-patterns,
* either replacing a mime part with its value by calling iconv_mime_decode
* or converts the encoding on the text part by calling convertEncoding.
*/
protected function decodeMime(string $value) : string
{
if (\preg_match('/^=\?([A-Za-z\-_0-9]+)\*?([A-Za-z\-_0-9]+)?\?([QBqb])\?([^\?]*)\?=$/', $value, $matches)) {
return $this->decodeMatchedEntity($matches);
}
return $this->convertEncoding($value);
}
/**
* Decodes a matched mime entity part into a string and returns it, after
* adding the string into the languages array.
*
* @param string[] $matches
*/
private function decodeMatchedEntity(array $matches) : string
{
$body = $matches[4];
if (\strtoupper($matches[3]) === 'Q') {
$body = \quoted_printable_decode(\str_replace('_', '=20', $body));
} else {
$body = \base64_decode($body);
}
$this->charset = $matches[1];
$this->language = (!empty($matches[2])) ? $matches[2] : null;
if ($this->charset !== null) {
return $this->convertEncoding($body, $this->charset, true);
}
return $this->convertEncoding($body, 'ISO-8859-1', true);
}
/**
* Returns the language code for the mime part.
*/
public function getLanguage() : ?string
{
return $this->language;
}
/**
* Returns the charset for the encoded part.
*/
public function getCharset() : ?string
{
return $this->charset;
}
public function getRawValue() : string
{
return $this->rawValue;
}
}

View File

@@ -0,0 +1,27 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Part;
use ZBateson\MailMimeParser\Header\IHeaderPart;
/**
* Extends HeaderPartFactory to instantiate MimeTokens for its
* newInstance method.
*
* @author Zaahid Bateson
*/
class MimeTokenPartFactory extends HeaderPartFactory
{
/**
* Creates and returns a MimeToken.
*/
public function newInstance(string $value) : IHeaderPart
{
return $this->newMimeToken($value);
}
}

View File

@@ -0,0 +1,65 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Part;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use ZBateson\MailMimeParser\ErrorBag;
use ZBateson\MbWrapper\MbWrapper;
/**
* Represents a name/value pair part of a header.
*
* @author Zaahid Bateson
*/
class NameValuePart extends ContainerPart
{
/**
* @var string the name of the part
*/
protected string $name;
public function __construct(
LoggerInterface $logger,
MbWrapper $charsetConverter,
array $nameParts,
array $valueParts
) {
ErrorBag::__construct($logger);
$this->charsetConverter = $charsetConverter;
$this->name = (!empty($nameParts)) ? $this->getNameFromParts($nameParts) : '';
parent::__construct($logger, $charsetConverter, $valueParts);
\array_unshift($this->children, ...$nameParts);
}
/**
* Creates the string 'name' representation of this part constructed from
* the child name parts passed to it.
*
* @param HeaderParts[] $parts
*/
protected function getNameFromParts(array $parts) : string
{
return \array_reduce($this->filterIgnoredSpaces($parts), fn ($c, $p) => $c . $p->getValue(), '');
}
/**
* Returns the name of the name/value part.
*/
public function getName() : string
{
return $this->name;
}
protected function validate() : void
{
if ($this->value === '') {
$this->addError('NameValuePart value is empty', LogLevel::NOTICE);
}
}
}

View File

@@ -0,0 +1,119 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Part;
use Psr\Log\LoggerInterface;
use ZBateson\MbWrapper\MbWrapper;
/**
* Represents a name/value parameter part of a header.
*
* @author Zaahid Bateson
*/
class ParameterPart extends NameValuePart
{
/**
* @var string the RFC-1766 language tag if set.
*/
protected ?string $language = null;
/**
* @var string charset of content if set.
*/
protected ?string $charset = null;
/**
* @var int the zero-based index of the part if part of a 'continuation' in
* an RFC-2231 split parameter.
*/
protected ?int $index = null;
/**
* @var bool true if the part is an RFC-2231 encoded part, and the value
* needs to be decoded.
*/
protected bool $encoded = false;
/**
* @param HeaderPart[] $nameParts
*/
public function __construct(
LoggerInterface $logger,
MbWrapper $charsetConverter,
array $nameParts,
ContainerPart $valuePart
) {
parent::__construct($logger, $charsetConverter, $nameParts, $valuePart->children);
}
protected function getNameFromParts(array $parts) : string
{
$name = parent::getNameFromParts($parts);
if (\preg_match('~^\s*([^\*]+)\*(\d*)(\*)?$~', $name, $matches)) {
$name = $matches[1];
$this->index = ($matches[2] !== '') ? (int) ($matches[2]) : null;
$this->encoded = (($matches[2] === '') || !empty($matches[3]));
}
return $name;
}
protected function decodePartValue(string $value, ?string $charset = null) : string
{
if ($charset !== null) {
return $this->convertEncoding(\rawurldecode($value), $charset, true);
}
return $this->convertEncoding(\rawurldecode($value));
}
protected function getValueFromParts(array $parts) : string
{
$value = parent::getValueFromParts($parts);
if ($this->encoded && \preg_match('~^([^\']*)\'?([^\']*)\'?(.*)$~', $value, $matches)) {
$this->charset = (!empty($matches[1]) && !empty($matches[3])) ? $matches[1] : $this->charset;
$this->language = (!empty($matches[2])) ? $matches[2] : $this->language;
$ev = (empty($matches[3])) ? $matches[1] : $matches[3];
// only if it's not part of a SplitParameterPart
if ($this->index === null) {
// subsequent parts are decoded as a SplitParameterPart since only
// the first part are supposed to have charset/language fields
return $this->decodePartValue($ev, $this->charset);
}
return $ev;
}
return $value;
}
/**
* Returns the charset if the part is an RFC-2231 part with a charset set.
*/
public function getCharset() : ?string
{
return $this->charset;
}
/**
* Returns the RFC-1766 (or subset) language tag, if the parameter is an
* RFC-2231 part with a language tag set.
*
* @return ?string the language if set, or null if not
*/
public function getLanguage() : ?string
{
return $this->language;
}
public function isUrlEncoded() : bool
{
return $this->encoded;
}
public function getIndex() : ?int
{
return $this->index;
}
}

View File

@@ -0,0 +1,46 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Part;
/**
* A quoted literal header string part. The value of the part is stripped of CR
* and LF characters, and whitespace between two adjacent MimeTokens is removed.
*
* @author Zaahid Bateson
*/
class QuotedLiteralPart extends ContainerPart
{
/**
* Strips spaces found between two adjacent MimeToken parts.
* Other whitespace is returned as-is.
*
* @param HeaderPart[] $parts
* @return HeaderPart[]
*/
protected function filterIgnoredSpaces(array $parts) : array
{
$filtered = \array_reduce(
\array_keys($parts),
function($carry, $key) use ($parts) {
$cur = $parts[$key];
$last = ($carry !== null) ? \end($carry) : null;
$next = (count($parts) > $key + 1) ? $parts[$key + 1] : null;
if ($last !== null && $next !== null && $cur->isSpace && (
$last->canIgnoreSpacesAfter
&& $next->canIgnoreSpacesBefore
&& $last instanceof MimeToken
&& $next instanceof MimeToken
)) {
return $carry;
}
return \array_merge($carry ?? [], [$cur]);
}
);
return $filtered;
}
}

View File

@@ -0,0 +1,103 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Part;
use Psr\Log\LoggerInterface;
use ZBateson\MbWrapper\MbWrapper;
/**
* Holds extra information about a parsed Received header part, for FROM and BY
* parts, namely: ehlo name, hostname, and address.
*
* The parsed parts would be mapped as follows:
*
* FROM ehlo name (hostname [address]), for example: FROM computer (domain.com
* [1.2.3.4]) would contain "computer" for getEhloName(), domain.com for
* getHostname and 1.2.3.4 for getAddress().
*
* This doesn't change if the ehlo name is an address, it is still returned in
* getEhloName(), and not in getAddress(). Additionally square brackets are not
* stripped from getEhloName() if its an address. For example: "FROM [1.2.3.4]"
* would return "[1.2.3.4]" in a call to getEhloName().
*
* For further information on how the header's parsed, check the documentation
* for {@see \ZBateson\MailMimeParser\Header\Consumer\Received\DomainConsumer}.
*
* @author Zaahid Bateson
*/
class ReceivedDomainPart extends ReceivedPart
{
/**
* @var string The name used to identify the server in the EHLO line.
*/
protected ?string $ehloName = null;
/**
* @var string The hostname.
*/
protected ?string $hostname = null;
/**
* @var string The address.
*/
protected ?string $address = null;
/**
* @param HeaderPart[] $children
*/
public function __construct(
LoggerInterface $logger,
MbWrapper $charsetConverter,
string $name,
array $children
) {
parent::__construct($logger, $charsetConverter, $name, $children);
$this->ehloName = ($this->value !== '') ? $this->value : null;
$cps = $this->getComments();
$commentPart = (!empty($cps)) ? $cps[0] : null;
$pattern = '~^(\[(IPv[64])?(?P<addr1>[a-f\d\.\:]+)\])?\s*(helo=)?(?P<name>[a-z0-9\-]+[a-z0-9\-\.]+)?\s*(\[(IPv[64])?(?P<addr2>[a-f\d\.\:]+)\])?$~i';
if ($commentPart !== null && \preg_match($pattern, $commentPart->getComment(), $matches)) {
$this->value .= ' (' . $commentPart->getComment() . ')';
$this->hostname = (!empty($matches['name'])) ? $matches['name'] : null;
$this->address = (!empty($matches['addr1'])) ? $matches['addr1'] : ((!empty($matches['addr2'])) ? $matches['addr2'] : null);
}
}
/**
* Returns the name used to identify the server in the first part of the
* extended-domain line.
*
* Note that this is not necessarily the name used in the EHLO line to an
* SMTP server, since implementations differ so much, not much can be
* guaranteed except the position it was parsed in.
*/
public function getEhloName() : ?string
{
return $this->ehloName;
}
/**
* Returns the hostname of the server, or whatever string in the hostname
* position when parsing (but never an address).
*/
public function getHostname() : ?string
{
return $this->hostname;
}
/**
* Returns the address of the server, or whatever string that looks like an
* address in the address position when parsing (but never a hostname).
*/
public function getAddress() : ?string
{
return $this->address;
}
}

View File

@@ -0,0 +1,37 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Part;
use Psr\Log\LoggerInterface;
use ZBateson\MbWrapper\MbWrapper;
/**
* Represents one parameter in a parsed 'Received' header, e.g. the FROM or VIA
* part.
*
* Note that FROM and BY actually get parsed into a sub-class,
* ReceivedDomainPart which keeps track of other sub-parts that can be parsed
* from them.
*
* @author Zaahid Bateson
*/
class ReceivedPart extends NameValuePart
{
/**
* @param HeaderPart[] $children
*/
public function __construct(
LoggerInterface $logger,
MbWrapper $charsetConverter,
string $name,
array $children
) {
parent::__construct($logger, $charsetConverter, [], $children);
$this->name = $name;
}
}

View File

@@ -0,0 +1,102 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Part;
use Psr\Log\LoggerInterface;
use ZBateson\MbWrapper\MbWrapper;
/**
* Holds a running value for an RFC-2231 split header parameter.
*
* ParameterConsumer creates SplitParameterTokens when a split header parameter
* is first found, and adds subsequent split parts to an already created one if
* the parameter name matches.
*
* @author Zaahid Bateson
*/
class SplitParameterPart extends ParameterPart
{
/**
* @var HeaderPartFactory used to create combined MimeToken parts.
*/
protected HeaderPartFactory $partFactory;
/**
* Initializes a SplitParameterToken.
*
* @param ParameterPart[] $children
*/
public function __construct(
LoggerInterface $logger,
MbWrapper $charsetConverter,
HeaderPartFactory $headerPartFactory,
array $children
) {
$this->partFactory = $headerPartFactory;
NameValuePart::__construct($logger, $charsetConverter, [$children[0]], $children);
$this->children = $children;
}
protected function getNameFromParts(array $parts) : string
{
return $parts[0]->getName();
}
private function getMimeTokens(string $value) : array
{
$pattern = MimeToken::MIME_PART_PATTERN;
// remove whitespace between two adjacent mime encoded parts
$normed = \preg_replace("/($pattern)\\s+(?=$pattern)/", '$1', $value);
// with PREG_SPLIT_DELIM_CAPTURE, matched and unmatched parts are returned
$aMimeParts = \preg_split("/($pattern)/", $normed, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
return \array_map(
fn ($p) => (\preg_match("/$pattern/", $p)) ? $this->partFactory->newMimeToken($p) : $this->partFactory->newToken($p, true, true),
$aMimeParts
);
}
private function combineAdjacentUnencodedParts(array $parts) : array
{
$runningValue = '';
$returnedParts = [];
foreach ($parts as $part) {
if (!$part->encoded) {
$runningValue .= $part->value;
continue;
}
if (!empty($runningValue)) {
$returnedParts = \array_merge($returnedParts, $this->getMimeTokens($runningValue));
$runningValue = '';
}
$returnedParts[] = $part;
}
if (!empty($runningValue)) {
$returnedParts = \array_merge($returnedParts, $this->getMimeTokens($runningValue));
}
return $returnedParts;
}
protected function getValueFromParts(array $parts) : string
{
$sorted = $parts;
\usort($sorted, fn ($a, $b) => $a->index <=> $b->index);
$first = $sorted[0];
$this->language = $first->language;
$charset = $this->charset = $first->charset;
$combined = $this->combineAdjacentUnencodedParts($sorted);
return \implode('', \array_map(
fn ($p) => ($p instanceof ParameterPart && $p->encoded)
? $this->decodePartValue($p->getValue(), ($p->charset === null) ? $charset : $p->charset)
: $p->getValue(),
$combined
));
}
}

View File

@@ -0,0 +1,40 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Part;
use Psr\Log\LoggerInterface;
use ZBateson\MbWrapper\MbWrapper;
/**
* Specialized token for subjects that preserves whitespace, except for new
* lines.
*
* New lines are either discarded if followed by a whitespace as should happen
* with folding whitespace, or replaced by a single space character if somehow
* aren't followed by whitespace.
*
* @author Zaahid Bateson
*/
class SubjectToken extends Token
{
public function __construct(
LoggerInterface $logger,
MbWrapper $charsetConverter,
string $value
) {
parent::__construct($logger, $charsetConverter, $value, true);
$this->value = \preg_replace(['/(\r|\n)+(\s)\s*/', '/(\r|\n)+/'], ['$2', ' '], $value);
$this->isSpace = (\preg_match('/^\s*$/m', $this->value) === 1);
$this->canIgnoreSpacesBefore = $this->canIgnoreSpacesAfter = $this->isSpace;
}
public function getValue() : string
{
return $this->convertEncoding($this->value);
}
}

View File

@@ -0,0 +1,66 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header\Part;
use Psr\Log\LoggerInterface;
use ZBateson\MbWrapper\MbWrapper;
/**
* Holds a string value token that will require additional processing by a
* consumer prior to returning to a client.
*
* A Token is meant to hold a value for further processing -- for instance when
* consuming an address list header (like From or To) -- before it's known what
* type of IHeaderPart it is (could be an email address, could be a name, or
* could be a group.)
*
* @author Zaahid Bateson
*/
class Token extends HeaderPart
{
/**
* @var string the raw value of the part.
*/
protected string $rawValue;
public function __construct(
LoggerInterface $logger,
MbWrapper $charsetConverter,
string $value,
bool $isLiteral = false,
bool $preserveSpaces = false
) {
parent::__construct($logger, $charsetConverter, $value);
$this->rawValue = $value;
if (!$isLiteral) {
$this->value = \preg_replace(['/(\r|\n)+(\s)/', '/(\r|\n)+/'], ['$2', ' '], $value);
if (!$preserveSpaces) {
$this->value = \preg_replace('/^\s+$/m', ' ', $this->value);
}
}
$this->isSpace = ($this->value === '' || (!$isLiteral && \preg_match('/^\s*$/m', $this->value) === 1));
$this->canIgnoreSpacesBefore = $this->canIgnoreSpacesAfter = $this->isSpace;
}
/**
* Returns the part's representative value after any necessary processing
* has been performed. For the raw value, call getRawValue().
*/
public function getValue() : string
{
return $this->convertEncoding($this->value);
}
/**
* Returns the part's raw value.
*/
public function getRawValue() : string
{
return $this->rawValue;
}
}

View File

@@ -0,0 +1,235 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header;
use DateTime;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Header\Consumer\ReceivedConsumerService;
use ZBateson\MailMimeParser\Header\Part\DatePart;
use ZBateson\MailMimeParser\MailMimeParser;
/**
* Represents a Received header.
*
* The returned header value (as returned by a call to {@see
* ReceivedHeader::getValue()}) for a
* ReceivedHeader is the same as the raw value (as returned by a call to
* {@see ReceivedHeader::getRawValue()}) since the header doesn't have a single
* 'value' to consider 'the value'.
*
* The parsed parts of a Received header can be accessed as parameters. To
* check if a part exists, call {@see ReceivedHeader::hasParameter()} with the
* name of the part, for example: ```php $header->hasParameter('from') ``` or
* ```php $header->hasParameter('id') ```. The value of the part can be obtained
* by calling {@see ReceivedHeader::getValueFor()}, for example
* ```php $header->getValueFor('with'); ```.
*
* Additional parsing is performed on the "FROM" and "BY" parts of a received
* header in an attempt to extract the self-identified name of the server, its
* hostname, and its address (depending on what's included). These can be
* accessed directly from the ReceivedHeader object by calling one of the
* following methods:
*
* o {@see ReceivedHeader::getFromName()} -- the name portion of the FROM part
* o {@see ReceivedHeader::getFromHostname()} -- the hostname of the FROM part
* o {@see ReceivedHeader::getFromAddress()} -- the adddress portion of the FROM
* part
* o {@see ReceivedHeader::getByName()} -- same as getFromName, but for the BY
* part, and etc... below
* o {@see ReceivedHeader::getByHostname()}
* o {@see ReceivedHeader::getByAddress()}
*
* The parsed parts of the FROM and BY parts are determined as follows:
*
* o Anything outside and before a parenthesized expression is considered "the
* name", for example "FROM AlainDeBotton", "AlainDeBotton" would be the name,
* but also if the name is an address, but exists outside the parenthesized
* expression, it's still considered "the name". For example:
* "From [1.2.3.4]", getFromName would return "[1.2.3.4]".
* o A parenthesized expression MUST match what looks like either a domain name
* on its own, or a domain name and an address. Otherwise the parenthesized
* expression is considered a comment, and not parsed into hostname and
* address. The rules are defined loosely because many implementations differ
* in how strictly they follow the standard. For a domain, it's enough that
* the expression starts with any alphanumeric character and contains at least
* one '.', followed by any number of '.', '-' and alphanumeric characters.
* The address portion must be surrounded in square brackets, and contain any
* sequence of '.', ':', numbers, and characters 'a' through 'f'. In addition
* the string 'ipv6' may start the expression (for instance, '[ipv6:::1]'
* would be valid). A port number may also be considered valid as part of the
* address, for example: [1.2.3.4:3231]. No additional validation on the
* address is done, and so an invalid address such as '....' could be
* returned, so users using the 'address' header are encouraged to validate it
* before using it. The square brackets are parsed out of the returned
* address, so the value returned by getFromAddress() would be "2.2.2.2", not
* "[2.2.2.2]".
*
* The date/time stamp can be accessed as a DateTime object by calling
* {@see ReceivedHeader::getDateTime()}.
*
* Parsed comments can be accessed by calling {@see
* ReceivedHeader::getComments()}. Some implementations may include connection
* encryption information or other details in non-standardized comments.
*
* @author Zaahid Bateson
*/
class ReceivedHeader extends ParameterHeader
{
/**
* @var DateTime the date/time stamp in the header.
*/
private ?DateTime $date = null;
/**
* @var bool set to true once $date has been looked for
*/
private bool $dateSet = false;
public function __construct(
string $name,
string $value,
?LoggerInterface $logger = null,
?ReceivedConsumerService $consumerService = null
) {
$di = MailMimeParser::getGlobalContainer();
AbstractHeader::__construct(
$logger ?? $di->get(LoggerInterface::class),
$consumerService ?? $di->get(ReceivedConsumerService::class),
$name,
$value
);
}
/**
* Returns the raw, unparsed header value, same as {@see
* ReceivedHeader::getRawValue()}.
*/
public function getValue() : ?string
{
return $this->rawValue;
}
/**
* Returns the name identified in the FROM part of the header or null if not
* defined or the format wasn't parsed.
*
* The returned value may either be a name or an address in the form
* "[1.2.3.4]". Validation is not performed on this value, and so whatever
* exists in this position is returned -- be it contains spaces, or invalid
* characters, etc...
*
* @return ?string The 'FROM' name.
*/
public function getFromName() : ?string
{
return (isset($this->parameters['from'])) ?
$this->parameters['from']->getEhloName() : null;
}
/**
* Returns the hostname part of a parenthesized FROM part or null if not
* defined or the format wasn't parsed.
*
* For example, "FROM name (host.name)" would return the string "host.name".
* Validation of the hostname is not performed, and the returned value may
* not be valid. More details on how the value is parsed and extracted can
* be found in the class description for {@see ReceivedHeader}.
*
* @return ?string The 'FROM' hostname.
*/
public function getFromHostname() : ?string
{
return (isset($this->parameters['from'])) ?
$this->parameters['from']->getHostname() : null;
}
/**
* Returns the address part of a parenthesized FROM part or null if not
* defined or the format wasn't parsed.
*
* For example, "FROM name ([1.2.3.4])" would return the string "1.2.3.4".
* Validation of the address is not performed, and the returned value may
* not be valid. More details on how the value is parsed and extracted can
* be found in the class description for {@see ReceivedHeader}.
*
* @return ?string The 'FROM' address.
*/
public function getFromAddress() : ?string
{
return (isset($this->parameters['from'])) ?
$this->parameters['from']->getAddress() : null;
}
/**
* Returns the name identified in the BY part of the header or null if not
* defined or the format wasn't parsed.
*
* The returned value may either be a name or an address in the form
* "[1.2.3.4]". Validation is not performed on this value, and so whatever
* exists in this position is returned -- be it contains spaces, or invalid
* characters, etc...
*
* @return ?string The 'BY' name.
*/
public function getByName() : ?string
{
return (isset($this->parameters['by'])) ?
$this->parameters['by']->getEhloName() : null;
}
/**
* Returns the hostname part of a parenthesized BY part or null if not
* defined or the format wasn't parsed.
*
* For example, "BY name (host.name)" would return the string "host.name".
* Validation of the hostname is not performed, and the returned value may
* not be valid. More details on how the value is parsed and extracted can
* be found in the class description for {@see ReceivedHeader}.
*
* @return ?string The 'BY' hostname.
*/
public function getByHostname() : ?string
{
return (isset($this->parameters['by'])) ?
$this->parameters['by']->getHostname() : null;
}
/**
* Returns the address part of a parenthesized BY part or null if not
* defined or the format wasn't parsed.
*
* For example, "BY name ([1.2.3.4])" would return the string "1.2.3.4".
* Validation of the address is not performed, and the returned value may
* not be valid. More details on how the value is parsed and extracted can
* be found in the class description for {@see ReceivedHeader}.
*
* @return ?string The 'BY' address.
*/
public function getByAddress() : ?string
{
return (isset($this->parameters['by'])) ?
$this->parameters['by']->getAddress() : null;
}
/**
* Returns the date/time stamp for the received header if set, or null
* otherwise.
*/
public function getDateTime() : ?DateTime
{
if ($this->dateSet === false) {
foreach ($this->parts as $part) {
if ($part instanceof DatePart) {
$this->date = $part->getDateTime();
}
}
$this->dateSet = true;
}
return $this->date;
}
}

View File

@@ -0,0 +1,38 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Header;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Header\Consumer\SubjectConsumerService;
use ZBateson\MailMimeParser\MailMimeParser;
/**
* Reads a subject header.
*
* The subject header is unique in that it doesn't include comments or quoted
* parts.
*
* @author Zaahid Bateson
*/
class SubjectHeader extends AbstractHeader
{
public function __construct(
string $name,
string $value,
?LoggerInterface $logger = null,
?SubjectConsumerService $consumerService = null
) {
$di = MailMimeParser::getGlobalContainer();
parent::__construct(
$logger ?? $di->get(LoggerInterface::class),
$consumerService ?? $di->get(SubjectConsumerService::class),
$name,
$value
);
}
}

View File

@@ -0,0 +1,86 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser;
use Psr\Log\LogLevel;
use Throwable;
/**
* Defines an object that may contain a set of errors, and optionally perform
* additional validation.
*
* @author Zaahid Bateson
*/
interface IErrorBag
{
/**
* Returns a context name for the current object to help identify it in
* logs.
*/
public function getErrorLoggingContextName() : string;
/**
* Creates and adds an Error object to this ErrorBag.
*/
public function addError(string $message, string $psrLogLevel, ?Throwable $exception = null) : static;
/**
* Returns true if this object has an error in its error bag at or above
* the passed $minPsrLevel (defaults to ERROR). If $validate is true,
* additional validation may be performed.
*
* The PSR levels are defined in Psr\Log\LogLevel.
*/
public function hasErrors(bool $validate = false, string $minPsrLevel = LogLevel::ERROR) : bool;
/**
* Returns any local errors this object has at or above the passed PSR log
* level in Psr\Log\LogLevel (defaulting to LogLevel::ERROR).
*
* If $validate is true, additional validation may be performed on the
* object to check for errors.
*
* @return Error[]
*/
public function getErrors(bool $validate = false, string $minPsrLevel = LogLevel::ERROR) : array;
/**
* Returns true if there are errors on this object, or any IErrorBag child
* of this object at or above the passed PSR log level in Psr\Log\LogLevel
* (defaulting to LogLevel::ERROR). Note that this will stop after finding
* the first error and return, so may be slightly more performant if an
* error actually exists over calling getAllErrors if only interested in
* whether an error exists.
*
* Care should be taken using this if the intention is to only 'preview' a
* message without parsing it entirely, since this will cause the whole
* message to be parsed as it traverses children, and could be slow on
* messages with large attachments, etc...
*
* If $validate is true, additional validation may be performed to check for
* errors.
*/
public function hasAnyErrors(bool $validate = false, string $minPsrLevel = LogLevel::ERROR) : bool;
/**
* Returns any errors on this object, and all IErrorBag children of this
* object at or above the passed PSR log level from Psr\Log\LogLevel
* (defaulting to LogLevel::ERROR).
*
* Care should be taken using this if the intention is to only 'preview' a
* message without parsing it entirely, since this will cause the whole
* message to be parsed as it traverses children, and could be slow on
* messages with large attachments, etc...
*
* If $validate is true, additional validation may be performed on children
* to check for errors.
*
* @return Error[]
*/
public function getAllErrors(bool $validate = false, string $minPsrLevel = LogLevel::ERROR) : array;
}

View File

@@ -0,0 +1,433 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser;
use Psr\Http\Message\StreamInterface;
use ZBateson\MailMimeParser\Message\IMessagePart;
use ZBateson\MailMimeParser\Message\IMimePart;
/**
* An interface representing an email message.
*
* Defines an interface to retrieve content, attachments and other parts of an
* email message.
*
* @author Zaahid Bateson
*/
interface IMessage extends IMimePart
{
/**
* Returns the subject of the message, retrieved from the 'Subject' header,
* or null if the message has none set.
*/
public function getSubject() : ?string;
/**
* Returns the inline text/plain IMessagePart for a message.
*
* If the message contains more than one text/plain 'inline' part, the
* default behavior is to return the first part. Additional parts can be
* returned by passing a 0-based index.
*
* If there are no inline text/plain parts in this message, null is
* returned.
*
* @see IMessage::getTextPartCount() to get a count of text parts.
* @see IMessage::getTextStream() to get the text content stream directly.
* @see IMessage::getTextContent() to get the text content in a string.
* @see IMessage::getHtmlPart() to get the HTML part(s).
* @see IMessage::getHtmlPartCount() to get a count of html parts.
* @param int $index Optional index of part to return.
*/
public function getTextPart(int $index = 0) : ?IMessagePart;
/**
* Returns the number of inline text/plain parts this message contains.
*
* @see IMessage::getTextPart() to get the text part(s).
* @see IMessage::getHtmlPart() to get the HTML part(s).
* @see IMessage::getHtmlPartCount() to get a count of html parts.
*/
public function getTextPartCount() : int;
/**
* Returns the inline text/html IMessagePart for a message.
*
* If the message contains more than one text/html 'inline' part, the
* default behavior is to return the first part. Additional parts can be
* returned by passing a 0-based index.
*
* If there are no inline text/html parts in this message, null is
* returned.
*
* @see IMessage::getHtmlStream() to get the html content stream directly.
* @see IMessage::getHtmlStream() to get the html content in a string.
* @see IMessage::getTextPart() to get the text part(s).
* @see IMessage::getTextPartCount() to get a count of text parts.
* @see IMessage::getHtmlPartCount() to get a count of html parts.
* @param int $index Optional index of part to return.
*/
public function getHtmlPart(int $index = 0) : ?IMessagePart;
/**
* Returns the number of inline text/html parts this message contains.
*
* @see IMessage::getTextPart() to get the text part(s).
* @see IMessage::getTextPartCount() to get a count of text parts.
* @see IMessage::getHtmlPart() to get the HTML part(s).
*/
public function getHtmlPartCount() : int;
/**
* Returns a Psr7 Stream for the 'inline' text/plain content.
*
* If the message contains more than one text/plain 'inline' part, the
* default behavior is to return the first part. The streams for additional
* parts can be returned by passing a 0-based index.
*
* If a part at the passed index doesn't exist, null is returned.
*
* @see IMessage::getTextPart() to get the text part(s).
* @see IMessage::getTextContent() to get the text content in a string.
* @param int $index Optional 0-based index of inline text part stream.
* @param string $charset Optional charset to encode the stream with.
*/
public function getTextStream(int $index = 0, string $charset = MailMimeParser::DEFAULT_CHARSET) : ?StreamInterface;
/**
* Returns the content of the inline text/plain part as a string.
*
* If the message contains more than one text/plain 'inline' part, the
* default behavior is to return the first part. The content for additional
* parts can be returned by passing a 0-based index.
*
* If a part at the passed index doesn't exist, null is returned.
*
* @see IMessage::getTextPart() to get the text part(s).
* @see IMessage::getTextStream() to get the text content stream directly.
* @param int $index Optional 0-based index of inline text part content.
* @param string $charset Optional charset for the returned string to be
* encoded in.
*/
public function getTextContent(int $index = 0, string $charset = MailMimeParser::DEFAULT_CHARSET) : ?string;
/**
* Returns a Psr7 Stream for the 'inline' text/html content.
*
* If the message contains more than one text/html 'inline' part, the
* default behavior is to return the first part. The streams for additional
* parts can be returned by passing a 0-based index.
*
* If a part at the passed index doesn't exist, null is returned.
*
* @see IMessage::getHtmlPart() to get the html part(s).
* @see IMessage::getHtmlContent() to get the html content in a string.
* @param int $index Optional 0-based index of inline html part stream.
* @param string $charset Optional charset to encode the stream with.
*/
public function getHtmlStream(int $index = 0, string $charset = MailMimeParser::DEFAULT_CHARSET) : ?StreamInterface;
/**
* Returns the content of the inline text/html part as a string.
*
* If the message contains more than one text/html 'inline' part, the
* default behavior is to return the first part. The content for additional
* parts can be returned by passing a 0-based index.
*
* If a part at the passed index doesn't exist, null is returned.
*
* @see IMessage::getHtmlPart() to get the html part(s).
* @see IMessage::getHtmlStream() to get the html content stream directly.
* @param int $index Optional 0-based index of inline html part content.
* @param string $charset Optional charset for the returned string to be
* encoded in.
*/
public function getHtmlContent(int $index = 0, string $charset = MailMimeParser::DEFAULT_CHARSET) : ?string;
/**
* Sets the text/plain part of the message to the passed $resource, either
* creating a new part if one doesn't exist for text/plain, or assigning the
* value of $resource to an existing text/plain part.
*
* The optional $contentTypeCharset parameter is the charset for the
* text/plain part's Content-Type, not the charset of the passed $resource.
* $resource must be encoded in UTF-8 regardless of the target charset.
*
* @see IMessage::setHtmlPart() to set the html part
* @see IMessage::removeTextPart() to remove a text part
* @see IMessage::removeAllTextParts() to remove all text parts
* @param string|resource|\Psr\Http\Message\StreamInterface $resource UTF-8
* encoded content.
* @param string $contentTypeCharset the charset to use as the text/plain
* part's content-type header charset value.
*/
public function setTextPart(mixed $resource, string $contentTypeCharset = 'UTF-8') : static;
/**
* Sets the text/html part of the message to the passed $resource, either
* creating a new part if one doesn't exist for text/html, or assigning the
* value of $resource to an existing text/html part.
*
* The optional $contentTypeCharset parameter is the charset for the
* text/html part's Content-Type, not the charset of the passed $resource.
* $resource must be encoded in UTF-8 regardless of the target charset.
*
* @see IMessage::setTextPart() to set the text part
* @see IMessage::removeHtmlPart() to remove an html part
* @see IMessage::removeAllHtmlParts() to remove all html parts
* @param string|resource|\Psr\Http\Message\StreamInterface $resource UTF-8
* encoded content.
* @param string $contentTypeCharset the charset to use as the text/html
* part's content-type header charset value.
*/
public function setHtmlPart(mixed $resource, string $contentTypeCharset = 'UTF-8') : static;
/**
* Removes the text/plain part of the message at the passed index if one
* exists (defaults to first part if an index isn't passed).
*
* Returns true if a part exists at the passed index and has been removed.
*
* @see IMessage::setTextPart() to set the text part
* @see IMessage::removeHtmlPart() to remove an html part
* @see IMessage::removeAllTextParts() to remove all text parts
* @param int $index Optional 0-based index of inline text part to remove.
* @return bool true on success
*/
public function removeTextPart(int $index = 0) : bool;
/**
* Removes all text/plain inline parts in this message.
*
* If the message contains a multipart/alternative part, the text parts are
* removed from below the alternative part only. If there is only one
* remaining part after that, it is moved up, replacing the
* multipart/alternative part.
*
* If the multipart/alternative part further contains a multipart/related
* (or mixed) part which holds an inline text part, only parts from that
* child multipart are removed, and if the passed
* $moveRelatedPartsBelowMessage is true, any non-text parts are moved to be
* below the message directly (changing the message into a multipart/mixed
* message if need be).
*
* For more control, call
* {@see \ZBateson\MailMimeParser\Message\IMessagePart::removePart()} with
* parts you wish to remove.
*
* @see IMessage::setTextPart() to set the text part
* @see IMessage::removeTextPart() to remove a text part
* @see IMessage::removeAllHtmlParts() to remove all html parts
* @param bool $moveRelatedPartsBelowMessage Optionally pass false to remove
* related parts.
* @return bool true on success
*/
public function removeAllTextParts(bool $moveRelatedPartsBelowMessage = true) : bool;
/**
* Removes the text/html part of the message at the passed index if one
* exists (defaults to first part if an index isn't passed).
*
* Returns true if a part exists at the passed index and has been removed.
*
* @see IMessage::setHtmlPart() to set the html part
* @see IMessage::removeTextPart() to remove a text part
* @see IMessage::removeAllHtmlParts() to remove all html parts
* @param int $index Optional 0-based index of inline html part to remove.
* @return bool true on success
*/
public function removeHtmlPart(int $index = 0) : bool;
/**
* Removes all text/html inline parts in this message.
*
* If the message contains a multipart/alternative part, the html parts are
* removed from below the alternative part only. If there is only one
* remaining part after that, it is moved up, replacing the
* multipart/alternative part.
*
* If the multipart/alternative part further contains a multipart/related
* (or mixed) part which holds an inline html part, only parts from that
* child multipart are removed, and if the passed
* $moveRelatedPartsBelowMessage is true, any non-html parts are moved to be
* below the message directly (changing the message into a multipart/mixed
* message if need be).
*
* For more control, call
* {@see \ZBateson\MailMimeParser\Message\IMessagePart::removePart()} with
* parts you wish to remove.
*
* @see IMessage::setHtmlPart() to set the html part
* @see IMessage::removeHtmlPart() to remove an html part
* @see IMessage::removeAllTextParts() to remove all html parts
* @param bool $moveRelatedPartsBelowMessage Optionally pass false to remove
* related parts.
* @return bool true on success
*/
public function removeAllHtmlParts(bool $moveRelatedPartsBelowMessage = true) : bool;
/**
* Returns the attachment part at the given 0-based index, or null if none
* is set.
*
* The method returns all parts other than the main content part for a
* non-mime message, and all parts under a mime message except:
* - text/plain and text/html parts with a Content-Disposition not set to
* 'attachment'
* - all multipart/* parts
* - any signature part
*
* @see IMessage::getAllAttachmentParts() to get an array of all parts.
* @see IMessage::getAttachmentCount() to get the number of attachments.
* @param int $index the 0-based index of the attachment part to return.
*/
public function getAttachmentPart(int $index) : ?IMessagePart;
/**
* Returns all attachment parts.
*
* The method returns all parts other than the main content part for a
* non-mime message, and all parts under a mime message except:
* - text/plain and text/html parts with a Content-Disposition not set to
* 'attachment'
* - all multipart/* parts
* - any signature part
*
* @see IMessage::getAttachmentPart() to get a single attachment.
* @see IMessage::getAttachmentCount() to get the number of attachments.
* @return IMessagePart[]
*/
public function getAllAttachmentParts() : array;
/**
* Returns the number of attachments available.
*
* @see IMessage::getAttachmentPart() to get a single attachment.
* @see IMessage::getAllAttachmentParts() to get an array of all parts.
*/
public function getAttachmentCount() : int;
/**
* Adds an attachment part for the passed raw data string, handle, or stream
* and given parameters.
*
* Note that $disposition must be one of 'inline' or 'attachment', and will
* default to 'attachment' if a different value is passed.
*
* @param string|resource|\Psr\Http\Message\StreamInterface $resource the
* part's content
* @param string $mimeType the mime-type of the attachment
* @param string $filename Optional filename (to set relevant header params)
* @param string $disposition Optional Content-Disposition value.
* @param string $encoding defaults to 'base64', only applied for a mime
* email
*/
public function addAttachmentPart(mixed $resource, string $mimeType, ?string $filename = null, string $disposition = 'attachment', string $encoding = 'base64') : static;
/**
* Adds an attachment part using the passed file.
*
* Essentially creates a psr7 stream and calls
* {@see IMessage::addAttachmentPart}.
*
* Note that $disposition must be one of 'inline' or 'attachment', and will
* default to 'attachment' if a different value is passed.
*
* @param string $filePath file to attach
* @param string $mimeType the mime-type of the attachment
* @param string $filename Optional filename (to set relevant header params)
* @param string $disposition Optional Content-Disposition value.
* @param string $encoding defaults to 'base64', only applied for a mime
* email
*/
public function addAttachmentPartFromFile(string $filePath, string $mimeType, ?string $filename = null, string $disposition = 'attachment', string $encoding = 'base64') : static;
/**
* Removes the attachment at the given index.
*
* Attachments are considered to be all parts other than the main content
* part for a non-mime message, and all parts under a mime message except:
* - text/plain and text/html parts with a Content-Disposition not set to
* 'attachment'
* - all multipart/* parts
* - any signature part
*/
public function removeAttachmentPart(int $index) : static;
/**
* Returns a stream that can be used to read the content part of a signed
* message, which can be used to sign an email or verify a signature.
*
* The method simply returns the stream for the first child. No
* verification of whether the message is in fact a signed message is
* performed.
*
* Note that unlike getSignedMessageAsString, getSignedMessageStream doesn't
* replace new lines, and before calculating a signature, LFs not preceded
* by CR should be replaced with CRLFs.
*
* @see IMessage::getSignedMessageAsString to get a string with CRLFs
* normalized
* @return ?StreamInterface null if the message doesn't have any children
*/
public function getSignedMessageStream() : ?StreamInterface;
/**
* Returns a string containing the entire body of a signed message for
* verification or calculating a signature.
*
* Non-CRLF new lines are replaced to always be CRLF.
*
* @see IMessage::setAsMultipartSigned to make the message a
* multipart/signed message.
* @return ?string null if the message doesn't have any children
*/
public function getSignedMessageAsString() : ?string;
/**
* Returns the signature part of a multipart/signed message or null.
*
* The signature part is determined to always be the 2nd child of a
* multipart/signed message, the first being the 'body'.
*
* Using the 'protocol' parameter of the Content-Type header is unreliable
* in some instances (for instance a difference of x-pgp-signature versus
* pgp-signature).
*/
public function getSignaturePart() : ?IMessagePart;
/**
* Turns the message into a multipart/signed message, moving the actual
* message into a child part, sets the content-type of the main message to
* multipart/signed and adds an empty signature part as well.
*
* After calling setAsMultipartSigned, call getSignedMessageAsString to
* get the normalized string content to be used for calculated the message's
* hash.
*
* @see IMessage::getSignedMessageAsString
* @param string $micalg The Message Integrity Check algorithm being used
* @param string $protocol The mime-type of the signature body
*/
public function setAsMultipartSigned(string $micalg, string $protocol) : static;
/**
* Sets the signature body of the message to the passed $body for a
* multipart/signed message.
*
* @param string $body the message's hash
*/
public function setSignature(string $body) : static;
/**
* Returns the value of the 'Message-ID' header, or null if not set.
*
* @return string|null the ID.
*/
public function getMessageId() : ?string;
}

View File

@@ -0,0 +1,219 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser;
use DI\Container;
use DI\ContainerBuilder;
use DI\Definition\Source\DefinitionSource;
use GuzzleHttp\Psr7\CachingStream;
use GuzzleHttp\Psr7\Utils;
use Psr\Http\Message\StreamInterface;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Parser\MessageParserService;
/**
* Parses a MIME message into an {@see IMessage} object.
*
* The class sets up the dependency injection container (using PHP-DI) with the
* ability to override and/or provide specialized classes. To override you can:
*
* - Provide an array|string|DefinitionSource to the constructor to affect
* classes used on a single instance of MailMimeParser
* - Call MailMimeParser::addGlobalContainerDefinition with an
* array|string|DefinitionSource to to override it globally on all instances
* of MailMimeParser
* - Call MailMimeParser::getGlobalContainer(), and use set() to override
* individual definitions globally.
*
* You may also provide a LoggerInterface on the constructor for a single
* instance, or override it globally by calling setGlobalLogger. This is the
* same as setting up Psr\Log\LoggerInterface with your logger class in a Php-Di
* configuration in one of the above methods.
*
* To invoke the parser, call `parse` on a MailMimeParser object.
*
* ```php
* $parser = new MailMimeParser();
* // the resource is attached due to the second parameter being true and will
* // be closed when the returned IMessage is destroyed
* $message = $parser->parse(fopen('path/to/file.txt'), true);
* // use $message here
* ```
*
* @author Zaahid Bateson
*/
class MailMimeParser
{
/**
* @var string the default charset used to encode strings (or string content
* like streams) returned by MailMimeParser (for e.g. the string
* returned by calling $message->getTextContent()).
*/
public const DEFAULT_CHARSET = 'UTF-8';
/**
* @var string the default definition file.
*/
private const DEFAULT_DEFINITIONS_FILE = __DIR__ . '/di_config.php';
/**
* @var Container The instance's dependency injection container.
*/
protected Container $container;
/**
* @var MessageParserService for parsing messages
*/
protected MessageParserService $messageParser;
/**
* @var Container The static global container
*/
private static ?Container $globalContainer = null;
/**
* @var array<array|string|DefinitionSource> an array of global definitions
* being used.
*/
private static array $globalDefinitions = [self::DEFAULT_DEFINITIONS_FILE];
/**
* Returns the default ContainerBuilder with default loaded definitions.
*/
private static function getGlobalContainerBuilder() : ContainerBuilder
{
$builder = new ContainerBuilder();
foreach (self::$globalDefinitions as $def) {
$builder->addDefinitions($def);
}
return $builder;
}
/**
* Sets global configuration for php-di. Overrides all previously set
* definitions. You can optionally not use the default MMP definitions file
* by passing 'false' to the $useDefaultDefinitionsFile argument.
*
* @var array<array|string|DefinitionSource> array of definitions
*/
public static function setGlobalPhpDiConfigurations(array $phpDiConfigs, bool $useDefaultDefinitionsFile = true) : void
{
self::$globalDefinitions = \array_merge(
($useDefaultDefinitionsFile) ? [self::DEFAULT_DEFINITIONS_FILE] : [],
$phpDiConfigs
);
self::$globalContainer = null;
}
public static function addGlobalPhpDiContainerDefinition(array|string|DefinitionSource $phpDiConfig) : void
{
self::$globalDefinitions[] = $phpDiConfig;
self::$globalContainer = null;
}
public static function resetGlobalPhpDiContainerDefinitions() : void
{
self::$globalDefinitions = [self::DEFAULT_DEFINITIONS_FILE];
self::$globalContainer = null;
}
/**
* Returns the global php-di container instance.
*
*/
public static function getGlobalContainer() : Container
{
if (self::$globalContainer === null) {
$builder = self::getGlobalContainerBuilder();
self::$globalContainer = $builder->build();
}
return self::$globalContainer;
}
/**
* Registers the provided logger globally.
*/
public static function setGlobalLogger(LoggerInterface $logger) : void
{
self::$globalDefinitions[] = [LoggerInterface::class => $logger];
self::$globalContainer = null;
}
/**
* Provide custom php-di configuration to customize dependency injection, or
* provide a custom logger for the instance only.
*
* Note: this only affects instances created through this instance of the
* MailMimeParser, or the container itself. Calling 'new MimePart()'
* directly for instance, would use the global service locator to setup any
* dependencies MimePart needs. This applies to a provided $logger too --
* it would only affect instances of objects created through the provided
* MailMimeParser.
*
* Passing false to $useGlobalDefinitions will cause MMP to not use any
* global definitions. The default definitions file
* MailMimeParser::DEFAULT_DEFINITIONS_FILE will still be added though.
*
* @see MailMimeParser::setGlobalPhpDiConfiguration() to register
* configuration globally.
* @see MailMimeParser::setGlobalLogger() to set a global logger
*/
public function __construct(
?LoggerInterface $logger = null,
array|string|DefinitionSource|null $phpDiContainerConfig = null,
bool $useGlobalDefinitions = true
) {
if ($phpDiContainerConfig !== null || $logger !== null) {
if ($useGlobalDefinitions) {
$builder = self::getGlobalContainerBuilder();
} else {
$builder = new ContainerBuilder();
$builder->addDefinitions(self::DEFAULT_DEFINITIONS_FILE);
}
if ($phpDiContainerConfig !== null) {
$builder->addDefinitions($phpDiContainerConfig);
}
if ($logger !== null) {
$builder->addDefinitions([LoggerInterface::class => $logger]);
}
$this->container = $builder->build();
} else {
$this->container = self::getGlobalContainer();
}
$this->messageParser = $this->container->get(MessageParserService::class);
}
/**
* Parses the passed stream handle or string into an {@see IMessage} object
* and returns it.
*
* If the passed $resource is a resource handle or StreamInterface, the
* resource must remain open while the returned IMessage object exists.
* Pass true as the second argument to have the resource attached to the
* IMessage and closed for you when it's destroyed, or pass false to
* manually close it if it should remain open after the IMessage object is
* destroyed.
*
* @param resource|StreamInterface|string $resource The resource handle to
* the input stream of the mime message, or a string containing a
* mime message.
* @param bool $attached pass true to have it attached to the returned
* IMessage and destroyed with it.
*/
public function parse(mixed $resource, bool $attached) : IMessage
{
$stream = Utils::streamFor(
$resource,
['metadata' => ['mmp-detached-stream' => ($attached !== true)]]
);
if (!$stream->isSeekable()) {
$stream = new CachingStream($stream);
}
return $this->messageParser->parse($stream);
}
}

View File

@@ -0,0 +1,342 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser;
use GuzzleHttp\Psr7;
use Psr\Http\Message\StreamInterface;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Header\HeaderConsts;
use ZBateson\MailMimeParser\Message\Helper\MultipartHelper;
use ZBateson\MailMimeParser\Message\Helper\PrivacyHelper;
use ZBateson\MailMimeParser\Message\IMessagePart;
use ZBateson\MailMimeParser\Message\MimePart;
use ZBateson\MailMimeParser\Message\PartChildrenContainer;
use ZBateson\MailMimeParser\Message\PartFilter;
use ZBateson\MailMimeParser\Message\PartHeaderContainer;
use ZBateson\MailMimeParser\Message\PartStreamContainer;
/**
* An email message.
*
* The message could represent a simple text email, a multipart message with
* children, or a non-mime message containing UUEncoded parts.
*
* @author Zaahid Bateson
*/
class Message extends MimePart implements IMessage
{
/**
* @var MultipartHelper service providing functions for multipart messages.
*/
private MultipartHelper $multipartHelper;
/**
* @var PrivacyHelper service providing functions for multipart/signed
* messages.
*/
private PrivacyHelper $privacyHelper;
public function __construct(
?LoggerInterface $logger = null,
?PartStreamContainer $streamContainer = null,
?PartHeaderContainer $headerContainer = null,
?PartChildrenContainer $partChildrenContainer = null,
?MultipartHelper $multipartHelper = null,
?PrivacyHelper $privacyHelper = null
) {
parent::__construct(
null,
$logger,
$streamContainer,
$headerContainer,
$partChildrenContainer
);
$di = MailMimeParser::getGlobalContainer();
$this->multipartHelper = $multipartHelper ?? $di->get(MultipartHelper::class);
$this->privacyHelper = $privacyHelper ?? $di->get(PrivacyHelper::class);
}
/**
* Convenience method to parse a handle or string into an IMessage without
* requiring including MailMimeParser, instantiating it, and calling parse.
*
* If the passed $resource is a resource handle or StreamInterface, the
* resource must remain open while the returned IMessage object exists.
* Pass true as the second argument to have the resource attached to the
* IMessage and closed for you when it's destroyed, or pass false to
* manually close it if it should remain open after the IMessage object is
* destroyed.
*
* @param resource|StreamInterface|string $resource The resource handle to
* the input stream of the mime message, or a string containing a
* mime message.
* @param bool $attached pass true to have it attached to the returned
* IMessage and destroyed with it.
*/
public static function from(mixed $resource, bool $attached) : IMessage
{
static $mmp = null;
if ($mmp === null) {
$mmp = new MailMimeParser();
}
return $mmp->parse($resource, $attached);
}
/**
* Returns true if the current part is a mime part.
*
* The message is considered 'mime' if it has either a Content-Type or
* MIME-Version header defined.
*
*/
public function isMime() : bool
{
$contentType = $this->getHeaderValue(HeaderConsts::CONTENT_TYPE);
$mimeVersion = $this->getHeaderValue(HeaderConsts::MIME_VERSION);
return ($contentType !== null || $mimeVersion !== null);
}
public function getSubject() : ?string
{
return $this->getHeaderValue(HeaderConsts::SUBJECT);
}
public function getTextPart(int $index = 0) : ?IMessagePart
{
return $this->getPart(
$index,
PartFilter::fromInlineContentType('text/plain')
);
}
public function getTextPartCount() : int
{
return $this->getPartCount(
PartFilter::fromInlineContentType('text/plain')
);
}
public function getHtmlPart(int $index = 0) : ?IMessagePart
{
return $this->getPart(
$index,
PartFilter::fromInlineContentType('text/html')
);
}
public function getHtmlPartCount() : int
{
return $this->getPartCount(
PartFilter::fromInlineContentType('text/html')
);
}
public function getTextStream(int $index = 0, string $charset = MailMimeParser::DEFAULT_CHARSET) : ?StreamInterface
{
$textPart = $this->getTextPart($index);
if ($textPart !== null) {
return $textPart->getContentStream($charset);
}
return null;
}
public function getTextContent(int $index = 0, string $charset = MailMimeParser::DEFAULT_CHARSET) : ?string
{
$part = $this->getTextPart($index);
if ($part !== null) {
return $part->getContent($charset);
}
return null;
}
public function getHtmlStream(int $index = 0, string $charset = MailMimeParser::DEFAULT_CHARSET) : ?StreamInterface
{
$htmlPart = $this->getHtmlPart($index);
if ($htmlPart !== null) {
return $htmlPart->getContentStream($charset);
}
return null;
}
public function getHtmlContent(int $index = 0, string $charset = MailMimeParser::DEFAULT_CHARSET) : ?string
{
$part = $this->getHtmlPart($index);
if ($part !== null) {
return $part->getContent($charset);
}
return null;
}
public function setTextPart(mixed $resource, string $charset = 'UTF-8') : static
{
$this->multipartHelper
->setContentPartForMimeType(
$this,
'text/plain',
$resource,
$charset
);
return $this;
}
public function setHtmlPart(mixed $resource, string $charset = 'UTF-8') : static
{
$this->multipartHelper
->setContentPartForMimeType(
$this,
'text/html',
$resource,
$charset
);
return $this;
}
public function removeTextPart(int $index = 0) : bool
{
return $this->multipartHelper
->removePartByMimeType(
$this,
'text/plain',
$index
);
}
public function removeAllTextParts(bool $moveRelatedPartsBelowMessage = true) : bool
{
return $this->multipartHelper
->removeAllContentPartsByMimeType(
$this,
'text/plain',
$moveRelatedPartsBelowMessage
);
}
public function removeHtmlPart(int $index = 0) : bool
{
return $this->multipartHelper
->removePartByMimeType(
$this,
'text/html',
$index
);
}
public function removeAllHtmlParts(bool $moveRelatedPartsBelowMessage = true) : bool
{
return $this->multipartHelper
->removeAllContentPartsByMimeType(
$this,
'text/html',
$moveRelatedPartsBelowMessage
);
}
public function getAttachmentPart(int $index) : ?IMessagePart
{
return $this->getPart(
$index,
PartFilter::fromAttachmentFilter()
);
}
public function getAllAttachmentParts() : array
{
return $this->getAllParts(
PartFilter::fromAttachmentFilter()
);
}
public function getAttachmentCount() : int
{
return \count($this->getAllAttachmentParts());
}
public function addAttachmentPart(mixed $resource, string $mimeType, ?string $filename = null, string $disposition = 'attachment', string $encoding = 'base64') : static
{
$this->multipartHelper
->createAndAddPartForAttachment(
$this,
$resource,
$mimeType,
(\strcasecmp($disposition, 'inline') === 0) ? 'inline' : 'attachment',
$filename,
$encoding
);
return $this;
}
public function addAttachmentPartFromFile(string $filePath, string $mimeType, ?string $filename = null, string $disposition = 'attachment', string $encoding = 'base64') : static
{
$handle = Psr7\Utils::streamFor(\fopen($filePath, 'r'));
if ($filename === null) {
$filename = \basename($filePath);
}
$this->addAttachmentPart($handle, $mimeType, $filename, $disposition, $encoding);
return $this;
}
public function removeAttachmentPart(int $index) : static
{
$part = $this->getAttachmentPart($index);
$this->removePart($part);
return $this;
}
public function getSignedMessageStream() : ?StreamInterface
{
return $this
->privacyHelper
->getSignedMessageStream($this);
}
public function getSignedMessageAsString() : ?string
{
return $this
->privacyHelper
->getSignedMessageAsString($this);
}
public function getSignaturePart() : ?IMessagePart
{
if (\strcasecmp($this->getContentType(), 'multipart/signed') === 0) {
return $this->getChild(1);
}
return null;
}
public function setAsMultipartSigned(string $micalg, string $protocol) : static
{
$this->privacyHelper
->setMessageAsMultipartSigned($this, $micalg, $protocol);
return $this;
}
public function setSignature(string $body) : static
{
$this->privacyHelper
->setSignature($this, $body);
return $this;
}
public function getMessageId() : ?string
{
return $this->getHeaderValue(HeaderConsts::MESSAGE_ID);
}
public function getErrorLoggingContextName() : string
{
$params = '';
if (!empty($this->getMessageId())) {
$params .= ', message-id=' . $this->getContentId();
}
$params .= ', content-type=' . $this->getContentType();
$nsClass = static::class;
$class = \substr($nsClass, (\strrpos($nsClass, '\\') ?? -1) + 1);
return $class . '(' . \spl_object_id($this) . $params . ')';
}
}

View File

@@ -0,0 +1,42 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Message\Factory;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Message\IMessagePart;
use ZBateson\MailMimeParser\Message\IMimePart;
use ZBateson\MailMimeParser\Stream\StreamFactory;
/**
* Abstract factory for subclasses of IMessagePart.
*
* @author Zaahid Bateson
*/
abstract class IMessagePartFactory
{
protected LoggerInterface $logger;
protected StreamFactory $streamFactory;
protected PartStreamContainerFactory $partStreamContainerFactory;
public function __construct(
LoggerInterface $logger,
StreamFactory $streamFactory,
PartStreamContainerFactory $partStreamContainerFactory
) {
$this->logger = $logger;
$this->streamFactory = $streamFactory;
$this->partStreamContainerFactory = $partStreamContainerFactory;
}
/**
* Constructs a new IMessagePart object and returns it
*/
abstract public function newInstance(?IMimePart $parent = null) : IMessagePart;
}

View File

@@ -0,0 +1,55 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Message\Factory;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Message\IMimePart;
use ZBateson\MailMimeParser\Message\MimePart;
use ZBateson\MailMimeParser\Stream\StreamFactory;
/**
* Responsible for creating IMimePart instances.
*
* @author Zaahid Bateson
*/
class IMimePartFactory extends IMessagePartFactory
{
protected PartHeaderContainerFactory $partHeaderContainerFactory;
protected PartChildrenContainerFactory $partChildrenContainerFactory;
public function __construct(
LoggerInterface $logger,
StreamFactory $streamFactory,
PartStreamContainerFactory $partStreamContainerFactory,
PartHeaderContainerFactory $partHeaderContainerFactory,
PartChildrenContainerFactory $partChildrenContainerFactory
) {
parent::__construct($logger, $streamFactory, $partStreamContainerFactory);
$this->partHeaderContainerFactory = $partHeaderContainerFactory;
$this->partChildrenContainerFactory = $partChildrenContainerFactory;
}
/**
* Constructs a new IMimePart object and returns it
*/
public function newInstance(?IMimePart $parent = null) : IMimePart
{
$streamContainer = $this->partStreamContainerFactory->newInstance();
$headerContainer = $this->partHeaderContainerFactory->newInstance();
$part = new MimePart(
$parent,
$this->logger,
$streamContainer,
$headerContainer,
$this->partChildrenContainerFactory->newInstance()
);
$streamContainer->setStream($this->streamFactory->newMessagePartStream($part));
return $part;
}
}

View File

@@ -0,0 +1,37 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Message\Factory;
use ZBateson\MailMimeParser\Message\IMimePart;
use ZBateson\MailMimeParser\Message\IUUEncodedPart;
use ZBateson\MailMimeParser\Message\UUEncodedPart;
/**
* Responsible for creating UUEncodedPart instances.
*
* @author Zaahid Bateson
*/
class IUUEncodedPartFactory extends IMessagePartFactory
{
/**
* Constructs a new UUEncodedPart object and returns it
*/
public function newInstance(?IMimePart $parent = null) : IUUEncodedPart
{
$streamContainer = $this->partStreamContainerFactory->newInstance();
$part = new UUEncodedPart(
null,
null,
$parent,
$this->logger,
$streamContainer
);
$streamContainer->setStream($this->streamFactory->newMessagePartStream($part));
return $part;
}
}

View File

@@ -0,0 +1,23 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Message\Factory;
use ZBateson\MailMimeParser\Message\PartChildrenContainer;
/**
* Creates PartChildrenContainer instances.
*
* @author Zaahid Bateson
*/
class PartChildrenContainerFactory
{
public function newInstance() : PartChildrenContainer
{
return new PartChildrenContainer();
}
}

View File

@@ -0,0 +1,45 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Message\Factory;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Header\HeaderFactory;
use ZBateson\MailMimeParser\Message\PartHeaderContainer;
/**
* Creates PartHeaderContainer instances.
*
* @author Zaahid Bateson
*/
class PartHeaderContainerFactory
{
protected LoggerInterface $logger;
/**
* @var HeaderFactory the HeaderFactory passed to HeaderContainer instances.
*/
protected HeaderFactory $headerFactory;
/**
* Constructor
*
*/
public function __construct(LoggerInterface $logger, HeaderFactory $headerFactory)
{
$this->logger = $logger;
$this->headerFactory = $headerFactory;
}
/**
* Creates and returns a PartHeaderContainer.
*/
public function newInstance(?PartHeaderContainer $from = null) : PartHeaderContainer
{
return new PartHeaderContainer($this->logger, $this->headerFactory, $from);
}
}

View File

@@ -0,0 +1,51 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Message\Factory;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\Message\PartStreamContainer;
use ZBateson\MailMimeParser\Stream\StreamFactory;
use ZBateson\MbWrapper\MbWrapper;
/**
* Creates PartStreamContainer instances.
*
* @author Zaahid Bateson
*/
class PartStreamContainerFactory
{
protected LoggerInterface $logger;
protected StreamFactory $streamFactory;
protected MbWrapper $mbWrapper;
protected bool $throwExceptionReadingPartContentFromUnsupportedCharsets;
public function __construct(
LoggerInterface $logger,
StreamFactory $streamFactory,
MbWrapper $mbWrapper,
bool $throwExceptionReadingPartContentFromUnsupportedCharsets
) {
$this->logger = $logger;
$this->streamFactory = $streamFactory;
$this->mbWrapper = $mbWrapper;
$this->throwExceptionReadingPartContentFromUnsupportedCharsets = $throwExceptionReadingPartContentFromUnsupportedCharsets;
}
public function newInstance() : PartStreamContainer
{
return new PartStreamContainer(
$this->logger,
$this->streamFactory,
$this->mbWrapper,
$this->throwExceptionReadingPartContentFromUnsupportedCharsets
);
}
}

View File

@@ -0,0 +1,37 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Message\Helper;
use ZBateson\MailMimeParser\Message\Factory\IMimePartFactory;
use ZBateson\MailMimeParser\Message\Factory\IUUEncodedPartFactory;
/**
* Base class for message helpers.
*
* @author Zaahid Bateson
*/
abstract class AbstractHelper
{
/**
* @var IMimePartFactory to create parts for attachments/content
*/
protected IMimePartFactory $mimePartFactory;
/**
* @var IUUEncodedPartFactory to create parts for attachments
*/
protected IUUEncodedPartFactory $uuEncodedPartFactory;
public function __construct(
IMimePartFactory $mimePartFactory,
IUUEncodedPartFactory $uuEncodedPartFactory
) {
$this->mimePartFactory = $mimePartFactory;
$this->uuEncodedPartFactory = $uuEncodedPartFactory;
}
}

View File

@@ -0,0 +1,161 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Message\Helper;
use ZBateson\MailMimeParser\Header\HeaderConsts;
use ZBateson\MailMimeParser\Header\IHeader;
use ZBateson\MailMimeParser\IMessage;
use ZBateson\MailMimeParser\MailMimeParser;
use ZBateson\MailMimeParser\Message\IMimePart;
/**
* Provides common Message helper routines for Message manipulation.
*
* @author Zaahid Bateson
*/
class GenericHelper extends AbstractHelper
{
/**
* @var string[] non mime content fields that are not related to the content
* of a part.
*/
private static array $nonMimeContentFields = ['contentreturn', 'contentidentifier'];
/**
* Returns true if the passed header's name is a Content-* header other than
* one defined in the static $nonMimeContentFields
*
*/
private function isMimeContentField(IHeader $header, array $exceptions = []) : bool
{
return (\stripos($header->getName(), 'Content') === 0
&& !\in_array(\strtolower(\str_replace('-', '', $header->getName())), \array_merge(self::$nonMimeContentFields, $exceptions)));
}
/**
* Copies the passed $header from $from, to $to or sets the header to
* $default if it doesn't exist in $from.
*
* @param string $header
* @param string $default
*/
public function copyHeader(IMimePart $from, IMimePart $to, $header, $default = null) : static
{
$fromHeader = $from->getHeader($header);
$set = ($fromHeader !== null) ? $fromHeader->getRawValue() : $default;
if ($set !== null) {
$to->setRawHeader($header, $set);
}
return $this;
}
/**
* Removes Content-* headers from the passed part, then detaches its content
* stream.
*
* An exception is made for the obsolete Content-Return header, which isn't
* isn't a MIME content field and so isn't removed.
*/
public function removeContentHeadersAndContent(IMimePart $part) : static
{
foreach ($part->getAllHeaders() as $header) {
if ($this->isMimeContentField($header)) {
$part->removeHeader($header->getName());
}
}
$part->detachContentStream();
return $this;
}
/**
* Copies Content-* headers from the $from header into the $to header. If
* the Content-Type header isn't defined in $from, defaults to text/plain
* with utf-8 and quoted-printable as its Content-Transfer-Encoding.
*
* An exception is made for the obsolete Content-Return header, which isn't
* isn't a MIME content field and so isn't copied.
*
* @param bool $move
*/
public function copyContentHeadersAndContent(IMimePart $from, IMimePart $to, $move = false) : static
{
$this->copyHeader($from, $to, HeaderConsts::CONTENT_TYPE, 'text/plain; charset=utf-8');
if ($from->getHeader(HeaderConsts::CONTENT_TYPE) === null) {
$this->copyHeader($from, $to, HeaderConsts::CONTENT_TRANSFER_ENCODING, 'quoted-printable');
} else {
$this->copyHeader($from, $to, HeaderConsts::CONTENT_TRANSFER_ENCODING);
}
foreach ($from->getAllHeaders() as $header) {
if ($this->isMimeContentField($header, ['contenttype', 'contenttransferencoding'])) {
$this->copyHeader($from, $to, $header->getName());
}
}
if ($from->hasContent()) {
$to->attachContentStream($from->getContentStream(), MailMimeParser::DEFAULT_CHARSET);
}
if ($move) {
$this->removeContentHeadersAndContent($from);
}
return $this;
}
/**
* Creates a new content part from the passed part, allowing the part to be
* used for something else (e.g. changing a non-mime message to a multipart
* mime message).
*
* @return IMimePart the newly-created IMimePart
*/
public function createNewContentPartFrom(IMimePart $part) : IMimePart
{
$mime = $this->mimePartFactory->newInstance();
$this->copyContentHeadersAndContent($part, $mime, true);
return $mime;
}
/**
* Copies type headers (Content-Type, Content-Disposition,
* Content-Transfer-Encoding) from the $from MimePart to $to. Attaches the
* content resource handle of $from to $to, and loops over child parts,
* removing them from $from and adding them to $to.
*
*/
public function movePartContentAndChildren(IMimePart $from, IMimePart $to) : static
{
$this->copyContentHeadersAndContent($from, $to, true);
if ($from->getChildCount() > 0) {
foreach ($from->getChildIterator() as $child) {
$from->removePart($child);
$to->addChild($child);
}
}
return $this;
}
/**
* Replaces the $part IMimePart with $replacement.
*
* Essentially removes $part from its parent, and adds $replacement in its
* same position. If $part is the IMessage, then $part can't be removed and
* replaced, and instead $replacement's type headers are copied to $message,
* and any children below $replacement are added directly below $message.
*/
public function replacePart(IMessage $message, IMimePart $part, IMimePart $replacement) : static
{
$position = $message->removePart($replacement);
if ($part === $message) {
$this->movePartContentAndChildren($replacement, $message);
return $this;
}
$parent = $part->getParent();
$parent->addChild($replacement, $position);
$parent->removePart($part);
return $this;
}
}

View File

@@ -0,0 +1,396 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Message\Helper;
use ZBateson\MailMimeParser\Header\HeaderConsts;
use ZBateson\MailMimeParser\IMessage;
use ZBateson\MailMimeParser\Message\Factory\IMimePartFactory;
use ZBateson\MailMimeParser\Message\Factory\IUUEncodedPartFactory;
use ZBateson\MailMimeParser\Message\IMessagePart;
use ZBateson\MailMimeParser\Message\IMimePart;
use ZBateson\MailMimeParser\Message\IMultiPart;
use ZBateson\MailMimeParser\Message\PartFilter;
/**
* Provides various routines to manipulate and create multipart messages from an
* existing message (e.g. to make space for attachments in a message, or to
* change a simple message to a multipart/alternative one, etc...)
*
* @author Zaahid Bateson
*/
class MultipartHelper extends AbstractHelper
{
/**
* @var GenericHelper a GenericHelper instance
*/
private GenericHelper $genericHelper;
public function __construct(
IMimePartFactory $mimePartFactory,
IUUEncodedPartFactory $uuEncodedPartFactory,
GenericHelper $genericHelper
) {
parent::__construct($mimePartFactory, $uuEncodedPartFactory);
$this->genericHelper = $genericHelper;
}
/**
* Creates and returns a unique boundary.
*
* @param string $mimeType first 3 characters of a multipart type are used,
* e.g. REL for relative or ALT for alternative
*/
public function getUniqueBoundary(string $mimeType) : string
{
$type = \ltrim(\strtoupper(\preg_replace('/^(multipart\/(.{3}).*|.*)$/i', '$2-', $mimeType)), '-');
return \uniqid('----=MMP-' . $type . '-', true);
}
/**
* Creates a unique mime boundary and assigns it to the passed part's
* Content-Type header with the passed mime type.
*/
public function setMimeHeaderBoundaryOnPart(IMimePart $part, string $mimeType) : static
{
$part->setRawHeader(
HeaderConsts::CONTENT_TYPE,
"$mimeType;\r\n\tboundary=\""
. $this->getUniqueBoundary($mimeType) . '"'
);
$part->notify();
return $this;
}
/**
* Sets the passed message as multipart/mixed.
*
* If the message has content, a new part is created and added as a child of
* the message. The message's content and content headers are moved to the
* new part.
*/
public function setMessageAsMixed(IMessage $message) : static
{
if ($message->hasContent()) {
$part = $this->genericHelper->createNewContentPartFrom($message);
$message->addChild($part, 0);
}
$this->setMimeHeaderBoundaryOnPart($message, 'multipart/mixed');
$atts = $message->getAllAttachmentParts();
if (!empty($atts)) {
foreach ($atts as $att) {
$att->notify();
}
}
return $this;
}
/**
* Sets the passed message as multipart/alternative.
*
* If the message has content, a new part is created and added as a child of
* the message. The message's content and content headers are moved to the
* new part.
*/
public function setMessageAsAlternative(IMessage $message) : static
{
if ($message->hasContent()) {
$part = $this->genericHelper->createNewContentPartFrom($message);
$message->addChild($part, 0);
}
$this->setMimeHeaderBoundaryOnPart($message, 'multipart/alternative');
return $this;
}
/**
* Searches the passed $alternativePart for a part with the passed mime type
* and returns its parent.
*
* Used for alternative mime types that have a multipart/mixed or
* multipart/related child containing a content part of $mimeType, where
* the whole mixed/related part should be removed.
*
* @param string $mimeType the content-type to find below $alternativePart
* @param IMimePart $alternativePart The multipart/alternative part to look
* under
* @return bool|IMimePart false if a part is not found
*/
public function getContentPartContainerFromAlternative($mimeType, IMimePart $alternativePart) : bool|IMimePart
{
$part = $alternativePart->getPart(0, PartFilter::fromInlineContentType($mimeType));
$contPart = null;
do {
if ($part === null) {
return false;
}
$contPart = $part;
$part = $part->getParent();
} while ($part !== $alternativePart);
return $contPart;
}
/**
* Removes all parts of $mimeType from $alternativePart.
*
* If $alternativePart contains a multipart/mixed or multipart/relative part
* with other parts of different content-types, the multipart part is
* removed, and parts of different content-types can optionally be moved to
* the main message part.
*/
public function removeAllContentPartsFromAlternative(
IMessage $message,
string $mimeType,
IMimePart $alternativePart,
bool $keepOtherContent
) : bool {
$rmPart = $this->getContentPartContainerFromAlternative($mimeType, $alternativePart);
if ($rmPart === false) {
return false;
}
if ($keepOtherContent && $rmPart->getChildCount() > 0) {
$this->moveAllNonMultiPartsToMessageExcept($message, $rmPart, $mimeType);
$alternativePart = $message->getPart(0, PartFilter::fromInlineContentType('multipart/alternative'));
}
$message->removePart($rmPart);
if ($alternativePart !== null && $alternativePart instanceof IMultiPart) {
if ($alternativePart->getChildCount() === 1) {
$this->genericHelper->replacePart($message, $alternativePart, $alternativePart->getChild(0));
} elseif ($alternativePart->getChildCount() === 0) {
$message->removePart($alternativePart);
}
}
while ($message->getChildCount() === 1) {
$this->genericHelper->replacePart($message, $message, $message->getChild(0));
}
return true;
}
/**
* Creates a new mime part as a multipart/alternative and assigns the passed
* $contentPart as a part below it before returning it.
*
* @return IMimePart the alternative part
*/
public function createAlternativeContentPart(IMessage $message, IMessagePart $contentPart) : IMimePart
{
$altPart = $this->mimePartFactory->newInstance();
$this->setMimeHeaderBoundaryOnPart($altPart, 'multipart/alternative');
$message->removePart($contentPart);
$message->addChild($altPart, 0);
$altPart->addChild($contentPart, 0);
return $altPart;
}
/**
* Moves all parts under $from into this message except those with a
* content-type equal to $exceptMimeType. If the message is not a
* multipart/mixed message, it is set to multipart/mixed first.
*/
public function moveAllNonMultiPartsToMessageExcept(IMessage $message, IMimePart $from, string $exceptMimeType) : static
{
$parts = $from->getAllParts(function(IMessagePart $part) use ($exceptMimeType) {
if ($part instanceof IMimePart && $part->isMultiPart()) {
return false;
}
return \strcasecmp($part->getContentType(), $exceptMimeType) !== 0;
});
if (\strcasecmp($message->getContentType(), 'multipart/mixed') !== 0) {
$this->setMessageAsMixed($message);
}
foreach ($parts as $key => $part) {
$from->removePart($part);
$message->addChild($part);
}
return $this;
}
/**
* Enforces the message to be a mime message for a non-mime (e.g. uuencoded
* or unspecified) message. If the message has uuencoded attachments, sets
* up the message as a multipart/mixed message and creates a separate
* content part.
*/
public function enforceMime(IMessage $message) : static
{
if (!$message->isMime()) {
if ($message->getAttachmentCount()) {
$this->setMessageAsMixed($message);
} else {
$message->setRawHeader(HeaderConsts::CONTENT_TYPE, "text/plain;\r\n\tcharset=\"iso-8859-1\"");
}
$message->setRawHeader(HeaderConsts::MIME_VERSION, '1.0');
}
return $this;
}
/**
* Creates a multipart/related part out of 'inline' children of $parent and
* returns it.
*/
public function createMultipartRelatedPartForInlineChildrenOf(IMimePart $parent) : IMimePart
{
$relatedPart = $this->mimePartFactory->newInstance();
$this->setMimeHeaderBoundaryOnPart($relatedPart, 'multipart/related');
foreach ($parent->getChildParts(PartFilter::fromDisposition('inline')) as $part) {
$parent->removePart($part);
$relatedPart->addChild($part);
}
$parent->addChild($relatedPart, 0);
return $relatedPart;
}
/**
* Finds an alternative inline part in the message and returns it if one
* exists.
*
* If the passed $mimeType is text/plain, searches for a text/html part.
* Otherwise searches for a text/plain part to return.
*
* @return IMimePart or null if not found
*/
public function findOtherContentPartFor(IMessage $message, string $mimeType) : ?IMimePart
{
$altPart = $message->getPart(
0,
PartFilter::fromInlineContentType(($mimeType === 'text/plain') ? 'text/html' : 'text/plain')
);
if ($altPart !== null && $altPart->getParent() !== null && $altPart->getParent()->isMultiPart()) {
$altPartParent = $altPart->getParent();
if ($altPartParent->getChildCount(PartFilter::fromDisposition('inline')) !== 1) {
$altPart = $this->createMultipartRelatedPartForInlineChildrenOf($altPartParent);
}
}
return $altPart;
}
/**
* Creates a new content part for the passed mimeType and charset, making
* space by creating a multipart/alternative if needed
*/
public function createContentPartForMimeType(IMessage $message, string $mimeType, string $charset) : IMimePart
{
$mimePart = $this->mimePartFactory->newInstance();
$mimePart->setRawHeader(HeaderConsts::CONTENT_TYPE, "$mimeType;\r\n\tcharset=\"$charset\"");
$mimePart->setRawHeader(HeaderConsts::CONTENT_TRANSFER_ENCODING, 'quoted-printable');
$this->enforceMime($message);
$altPart = $this->findOtherContentPartFor($message, $mimeType);
if ($altPart === $message) {
$this->setMessageAsAlternative($message);
$message->addChild($mimePart);
} elseif ($altPart !== null) {
$mimeAltPart = $this->createAlternativeContentPart($message, $altPart);
$mimeAltPart->addChild($mimePart, 1);
} else {
$message->addChild($mimePart, 0);
}
return $mimePart;
}
/**
* Creates and adds a IMimePart for the passed content and options as an
* attachment.
*
* @param string|resource|\Psr\Http\Message\StreamInterface $resource
*/
public function createAndAddPartForAttachment(
IMessage $message,
$resource,
string $mimeType,
string $disposition,
?string $filename = null,
string $encoding = 'base64'
) : IMessagePart {
if ($filename === null) {
$filename = 'file' . \uniqid();
}
$safe = \iconv('UTF-8', 'US-ASCII//translit//ignore', $filename);
if ($message->isMime()) {
$part = $this->mimePartFactory->newInstance();
$part->setRawHeader(HeaderConsts::CONTENT_TRANSFER_ENCODING, $encoding);
if (\strcasecmp($message->getContentType(), 'multipart/mixed') !== 0) {
$this->setMessageAsMixed($message);
}
$part->setRawHeader(HeaderConsts::CONTENT_TYPE, "$mimeType;\r\n\tname=\"$safe\"");
$part->setRawHeader(HeaderConsts::CONTENT_DISPOSITION, "$disposition;\r\n\tfilename=\"$safe\"");
} else {
$part = $this->uuEncodedPartFactory->newInstance();
$part->setFilename($safe);
}
$part->setContent($resource);
$message->addChild($part);
return $part;
}
/**
* Removes the content part of the message with the passed mime type. If
* there is a remaining content part and it is an alternative part of the
* main message, the content part is moved to the message part.
*
* If the content part is part of an alternative part beneath the message,
* the alternative part is replaced by the remaining content part,
* optionally keeping other parts if $keepOtherContent is set to true.
*
* @return bool true on success
*/
public function removeAllContentPartsByMimeType(IMessage $message, string $mimeType, bool $keepOtherContent = false) : bool
{
$alt = $message->getPart(0, PartFilter::fromInlineContentType('multipart/alternative'));
if ($alt !== null) {
return $this->removeAllContentPartsFromAlternative($message, $mimeType, $alt, $keepOtherContent);
}
$message->removeAllParts(PartFilter::fromInlineContentType($mimeType));
return true;
}
/**
* Removes the 'inline' part with the passed contentType, at the given index
* defaulting to the first
*
* @return bool true on success
*/
public function removePartByMimeType(IMessage $message, string $mimeType, int $index = 0) : bool
{
$parts = $message->getAllParts(PartFilter::fromInlineContentType($mimeType));
$alt = $message->getPart(0, PartFilter::fromInlineContentType('multipart/alternative'));
if ($parts === null || !isset($parts[$index])) {
return false;
} elseif (\count($parts) === 1) {
return $this->removeAllContentPartsByMimeType($message, $mimeType, true);
}
$part = $parts[$index];
$message->removePart($part);
if ($alt !== null && $alt->getChildCount() === 1) {
$this->genericHelper->replacePart($message, $alt, $alt->getChild(0));
}
return true;
}
/**
* Either creates a mime part or sets the existing mime part with the passed
* mimeType to $strongOrHandle.
*
* @param string|resource $stringOrHandle
*/
public function setContentPartForMimeType(IMessage $message, string $mimeType, mixed $stringOrHandle, string $charset) : static
{
$part = ($mimeType === 'text/html') ? $message->getHtmlPart() : $message->getTextPart();
if ($part === null) {
$part = $this->createContentPartForMimeType($message, $mimeType, $charset);
} else {
$contentType = $part->getContentType();
if ($part instanceof IMimePart) {
$part->setRawHeader(HeaderConsts::CONTENT_TYPE, "$contentType;\r\n\tcharset=\"$charset\"");
}
}
$part->setContent($stringOrHandle);
return $this;
}
}

View File

@@ -0,0 +1,159 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Message\Helper;
use Psr\Http\Message\StreamInterface;
use ZBateson\MailMimeParser\Header\HeaderConsts;
use ZBateson\MailMimeParser\IMessage;
use ZBateson\MailMimeParser\Message\Factory\IMimePartFactory;
use ZBateson\MailMimeParser\Message\Factory\IUUEncodedPartFactory;
use ZBateson\MailMimeParser\Message\IMessagePart;
/**
* Provides routines to set or retrieve the signature part of a signed message.
*
* @author Zaahid Bateson
*/
class PrivacyHelper extends AbstractHelper
{
/**
* @var GenericHelper a GenericHelper instance
*/
private GenericHelper $genericHelper;
/**
* @var MultipartHelper a MultipartHelper instance
*/
private MultipartHelper $multipartHelper;
public function __construct(
IMimePartFactory $mimePartFactory,
IUUEncodedPartFactory $uuEncodedPartFactory,
GenericHelper $genericHelper,
MultipartHelper $multipartHelper
) {
parent::__construct($mimePartFactory, $uuEncodedPartFactory);
$this->genericHelper = $genericHelper;
$this->multipartHelper = $multipartHelper;
}
/**
* The passed message is set as multipart/signed, and a new part is created
* below it with content headers, content and children copied from the
* message.
*
* @param string $micalg
* @param string $protocol
*/
public function setMessageAsMultipartSigned(IMessage $message, $micalg, $protocol) : static
{
if (\strcasecmp($message->getContentType(), 'multipart/signed') !== 0) {
$this->multipartHelper->enforceMime($message);
$messagePart = $this->mimePartFactory->newInstance();
$this->genericHelper->movePartContentAndChildren($message, $messagePart);
$message->addChild($messagePart);
$boundary = $this->multipartHelper->getUniqueBoundary('multipart/signed');
$message->setRawHeader(
HeaderConsts::CONTENT_TYPE,
"multipart/signed;\r\n\tboundary=\"$boundary\";\r\n\tmicalg=\"$micalg\"; protocol=\"$protocol\""
);
}
$this->overwrite8bitContentEncoding($message);
$this->setSignature($message, 'Empty');
return $this;
}
/**
* Sets the signature of the message to $body, creating a signature part if
* one doesn't exist.
*
* @param string $body
*/
public function setSignature(IMessage $message, $body) : static
{
$signedPart = $message->getSignaturePart();
if ($signedPart === null) {
$signedPart = $this->mimePartFactory->newInstance();
$message->addChild($signedPart);
}
$signedPart->setRawHeader(
HeaderConsts::CONTENT_TYPE,
$message->getHeaderParameter(HeaderConsts::CONTENT_TYPE, 'protocol')
);
$signedPart->setContent($body);
return $this;
}
/**
* Loops over parts of the message and sets the content-transfer-encoding
* header to quoted-printable for text/* mime parts, and to base64
* otherwise for parts that are '8bit' encoded.
*
* Used for multipart/signed messages which doesn't support 8bit transfer
* encodings.
*/
public function overwrite8bitContentEncoding(IMessage $message) : static
{
$parts = $message->getAllParts(function(IMessagePart $part) {
return \strcasecmp($part->getContentTransferEncoding(), '8bit') === 0;
});
foreach ($parts as $part) {
$contentType = \strtolower($part->getContentType());
$part->setRawHeader(
HeaderConsts::CONTENT_TRANSFER_ENCODING,
($contentType === 'text/plain' || $contentType === 'text/html') ?
'quoted-printable' :
'base64'
);
}
return $this;
}
/**
* Returns a stream that can be used to read the content part of a signed
* message, which can be used to sign an email or verify a signature.
*
* The method simply returns the stream for the first child. No
* verification of whether the message is in fact a signed message is
* performed.
*
* Note that unlike getSignedMessageAsString, getSignedMessageStream doesn't
* replace new lines.
*
* @return ?StreamInterface null if the message doesn't have any children
*/
public function getSignedMessageStream(IMessage $message) : ?StreamInterface
{
$child = $message->getChild(0);
if ($child !== null) {
return $child->getStream();
}
return null;
}
/**
* Returns a string containing the entire body (content) of a signed message
* for verification or calculating a signature.
*
* Non-CRLF new lines are replaced to always be CRLF.
*
* @return ?string null if the message doesn't have any children
*/
public function getSignedMessageAsString(IMessage $message) : ?string
{
$stream = $this->getSignedMessageStream($message);
if ($stream !== null) {
return \preg_replace(
'/\r\n|\r|\n/',
"\r\n",
$stream->getContents()
);
}
return null;
}
}

View File

@@ -0,0 +1,386 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Message;
use Psr\Http\Message\StreamInterface;
use SplSubject;
use ZBateson\MailMimeParser\IErrorBag;
use ZBateson\MailMimeParser\MailMimeParser;
/**
* An interface representing a single part of an email.
*
* The base type for a message or any child part of a message. The part may
* contain content, have a parent, and identify the type of content (e.g.
* mime-type or charset) agnostically.
*
* The interface extends SplSubject -- any modifications to a message must
* notify any attached observers.
*
* @author Zaahid Bateson
*/
interface IMessagePart extends IErrorBag, SplSubject
{
/**
* Returns this part's parent.
*/
public function getParent() : ?IMimePart;
/**
* Returns true if the part contains a 'body' (content).
*
*/
public function hasContent() : bool;
/**
* Returns true if the content of this part is plain text.
*
*/
public function isTextPart() : bool;
/**
* Returns the mime type of the content, or $default if one is not set.
*
* @param string $default Optional override for the default return value of
* 'text/plain.
* @return string the mime type
*/
public function getContentType(string $default = 'text/plain') : ?string;
/**
* Returns the charset of the content, or null if not applicable/defined.
*
* @return string|null the charset
*/
public function getCharset() : ?string;
/**
* Returns the content's disposition, or returns the value of $default if
* not defined.
*
* @param string $default Optional default value to return if not
* applicable/defined
* @return string|null the disposition.
*/
public function getContentDisposition(?string $default = null) : ?string;
/**
* Returns the content transfer encoding used to encode the content on this
* part, or the value of $default if not defined.
*
* @param ?string $default Optional default value to return if not
* applicable/defined
* @return string|null the transfer encoding defined for the part.
*/
public function getContentTransferEncoding(?string $default = null) : ?string;
/**
* Returns the Content ID of the part, or null if not defined.
*
* @return string|null the content ID.
*/
public function getContentId() : ?string;
/**
* Returns a filename for the part if one is defined, or null otherwise.
*
* @return string|null the file name
*/
public function getFilename() : ?string;
/**
* Returns true if the current part is a mime part.
*
*/
public function isMime() : bool;
/**
* Overrides the default character set used for reading content from content
* streams in cases where a user knows the source charset is not what is
* specified.
*
* If set, the returned value from {@see IMessagePart::getCharset()} must be
* ignored during subsequent read operations and streams created out of this
* part's content.
*
* Note that setting an override on an
* {@see \ZBateson\MailMimeParser\IMessage} and calling getTextStream,
* getTextContent, getHtmlStream or getHtmlContent will not be applied to
* those sub-parts, unless the text/html part is the IMessage itself.
* Instead, {@see \ZBateson\MailMimeParser\IMessage::getTextPart()} should
* be called, and setCharsetOverride called on the returned IMessagePart.
*
* @see IMessagePart::getContentStream() to get the content stream.
* @param string $charsetOverride the actual charset of the content.
* @param bool $onlyIfNoCharset if true, $charsetOverride is used only if
* getCharset returns null.
*/
public function setCharsetOverride(string $charsetOverride, bool $onlyIfNoCharset = false) : static;
/**
* Returns the StreamInterface for the part's content or null if the part
* doesn't have a content section.
*
* To get a stream without charset conversion if you know the part's content
* contains a binary stream, call {@see self::getBinaryContentStream()}
* instead.
*
* The library automatically handles decoding and charset conversion (to the
* target passed $charset) based on the part's transfer encoding as returned
* by {@see IMessagePart::getContentTransferEncoding()} and the part's
* charset as returned by {@see IMessagePart::getCharset()}. The returned
* stream is ready to be read from directly.
*
* Note that the returned Stream is a shared object. If called multiple
* times with the same $charset, and the value of the part's
* Content-Transfer-Encoding header has not changed, the stream will be
* rewound. This would affect other existing variables referencing the
* stream, for example:
*
* ```php
* // assuming $part is a part containing the following
* // string for its content: '12345678'
* $stream = $part->getContentStream();
* $someChars = $part->read(4);
*
* $stream2 = $part->getContentStream();
* $moreChars = $part->read(4);
* echo ($someChars === $moreChars); //1
* ```
*
* In this case the Stream was rewound, and $stream's second call to read 4
* bytes reads the same first 4.
*
* @see IMessagePart::getBinaryContentStream() to get the content stream
* without any charset conversions.
* @see IMessagePart::saveContent() to save the binary contents to file.
* @see IMessagePart::setCharsetOverride() to override the charset of the
* content and ignore the charset returned from calling
* IMessagePart::getCharset() when reading.
* @param string $charset Optional charset for the returned stream.
* @return StreamInterface|null the stream
*/
public function getContentStream(string $charset = MailMimeParser::DEFAULT_CHARSET) : ?StreamInterface;
/**
* Returns the raw data stream for the current part, if it exists, or null
* if there's no content associated with the stream.
*
* This is basically the same as calling
* {@see IMessagePart::getContentStream()}, except no automatic charset
* conversion is done. Note that for non-text streams, this doesn't have an
* effect, as charset conversion is not performed in that case, and is
* useful only when:
*
* - The charset defined is not correct, and the conversion produces errors;
* or
* - You'd like to read the raw contents without conversion, for instance to
* save it to file or allow a user to download it as-is (in a download
* link for example).
*
* @see IMessagePart::getContentStream() to get the content stream with
* charset conversions applied.
* @see IMessagePart::getBinaryContentResourceHandle() to get a resource
* handle instead.
* @see IMessagePart::saveContent() to save the binary contents to file.
* @return StreamInterface|null the stream
*/
public function getBinaryContentStream() : ?StreamInterface;
/**
* Returns a resource handle for the content's raw data stream, or null if
* the part doesn't have a content stream.
*
* The method wraps a call to {@see IMessagePart::getBinaryContentStream()}
* and returns a resource handle for the returned Stream.
*
* @see IMessagePart::getBinaryContentStream() to get a stream instead.
* @see IMessagePart::saveContent() to save the binary contents to file.
* @return resource|null the resource
*/
public function getBinaryContentResourceHandle() : mixed;
/**
* Saves the binary content of the stream to the passed file, resource or
* stream.
*
* Note that charset conversion is not performed in this case, and the
* contents of the part are saved in their binary format as transmitted (but
* after any content-transfer decoding is performed). {@see
* IMessagePart::getBinaryContentStream()} for a more detailed description
* of the stream.
*
* If the passed parameter is a string, it's assumed to be a filename to
* write to. The file is opened in 'w+' mode, and closed before returning.
*
* When passing a resource or Psr7 Stream, the resource is not closed, nor
* rewound.
*
* @see IMessagePart::getContentStream() to get the content stream with
* charset conversions applied.
* @see IMessagePart::getBinaryContentStream() to get the content as a
* binary stream.
* @see IMessagePart::getBinaryContentResourceHandle() to get the content as
* a resource handle.
* @param string|resource|StreamInterface $filenameResourceOrStream
*/
public function saveContent($filenameResourceOrStream) : static;
/**
* Shortcut to reading stream content and assigning it to a string. Returns
* null if the part doesn't have a content stream.
*
* The returned string is encoded to the passed $charset character encoding.
*
* @see IMessagePart::getContentStream()
* @param string $charset the target charset for the returned string
* @return ?string the content
*/
public function getContent(string $charset = MailMimeParser::DEFAULT_CHARSET) : ?string;
/**
* Attaches the stream or resource handle for the part's content. The
* stream is closed when another stream is attached, or the MimePart is
* destroyed.
*
* @see IMessagePart::setContent() to pass a string as the content.
* @see IMessagePart::getContentStream() to get the content stream.
* @see IMessagePart::detachContentStream() to detach the content stream.
* @param StreamInterface $stream the content
* @param string $streamCharset the charset of $stream
*/
public function attachContentStream(StreamInterface $stream, string $streamCharset = MailMimeParser::DEFAULT_CHARSET) : static;
/**
* Detaches the content stream.
*
* @see IMessagePart::getContentStream() to get the content stream.
* @see IMessagePart::attachContentStream() to attach a content stream.
*/
public function detachContentStream() : static;
/**
* Sets the content of the part to the passed string, resource, or stream.
*
* @see IMessagePart::getContentStream() to get the content stream.
* @see IMessagePart::attachContentStream() to attach a content stream.
* @see IMessagePart::detachContentStream() to detach the content stream.
* @param string|resource|StreamInterface $resource the content.
* @param string $resourceCharset the charset of the passed $resource.
*/
public function setContent($resource, string $resourceCharset = MailMimeParser::DEFAULT_CHARSET) : static;
/**
* Returns a resource handle for the string representation of this part,
* containing its headers, content and children. For an IMessage, this
* would be the entire RFC822 (or greater) email.
*
* If the part has not been modified and represents a parsed part, the
* original stream should be returned. Otherwise a stream representation of
* the part including its modifications should be returned. This insures
* that an unmodified, signed message could be passed on that way even after
* parsing and reading.
*
* The returned stream is not guaranteed to be RFC822 (or greater) compliant
* for the following reasons:
*
* - The original email or part, if not modified, is returned as-is and may
* not be compliant.
* - Although certain parts may have been modified, an original unmodified
* header from the original email or part may not be compliant.
* - A user may set headers in a non-compliant format.
*
* @see IMessagePart::getStream() to get a Psr7 StreamInterface instead of a
* resource handle.
* @see IMessagePart::__toString() to write the part to a string and return
* it.
* @see IMessage::save() to write the part to a file, resource handle or
* Psr7 stream.
* @return resource the resource handle containing the part.
*/
public function getResourceHandle() : mixed;
/**
* Returns a Psr7 StreamInterface for the string representation of this
* part, containing its headers, content and children.
*
* If the part has not been modified and represents a parsed part, the
* original stream should be returned. Otherwise a stream representation of
* the part including its modifications should be returned. This insures
* that an unmodified, signed message could be passed on that way even after
* parsing and reading.
*
* The returned stream is not guaranteed to be RFC822 (or greater) compliant
* for the following reasons:
*
* - The original email or part, if not modified, is returned as-is and may
* not be compliant.
* - Although certain parts may have been modified, an original unmodified
* header from the original email or part may not be compliant.
* - A user may set headers in a non-compliant format.
*
* @see IMessagePart::getResourceHandle() to get a resource handle.
* @see IMessagePart::__toString() to write the part to a string and return
* it.
* @see IMessage::save() to write the part to a file, resource handle or
* Psr7 stream.
* @return StreamInterface the stream containing the part.
*/
public function getStream() : StreamInterface;
/**
* Writes a string representation of this part, including its headers,
* content and children to the passed file, resource, or stream.
*
* If the part has not been modified and represents a parsed part, the
* original stream should be written to the file. Otherwise a stream
* representation of the part including its modifications should be written.
* This insures that an unmodified, signed message could be passed on this
* way even after parsing and reading.
*
* The written stream is not guaranteed to be RFC822 (or greater) compliant
* for the following reasons:
*
* - The original email or part, if not modified, is returned as-is and may
* not be compliant.
* - Although certain parts may have been modified, an original unmodified
* header from the original email or part may not be compliant.
* - A user may set headers in a non-compliant format.
*
* If the passed $filenameResourceOrStream is a string, it's assumed to be a
* filename to write to.
*
* When passing a resource or Psr7 Stream, the resource is not closed, nor
* rewound after being written to.
*
* @see IMessagePart::getResourceHandle() to get a resource handle.
* @see IMessagePart::__toString() to get the part in a string.
* @see IMessage::save() to write the part to a file, resource handle or
* Psr7 stream.
* @param string|resource|StreamInterface $filenameResourceOrStream the
* file, resource, or stream to write to.
* @param string $filemode Optional filemode to open a file in (if
* $filenameResourceOrStream is a string)
*/
public function save(mixed $filenameResourceOrStream, string $filemode = 'w+') : static;
/**
* Returns the message/part as a string, containing its headers, content and
* children.
*
* Convenience method for calling getContents() on
* {@see IMessagePart::getStream()}.
*
* @see IMessagePart::getStream() to get a Psr7 StreamInterface instead of a
* string.
* @see IMessagePart::getResourceHandle() to get a resource handle.
* @see IMessage::save() to write the part to a file, resource handle or
* Psr7 stream.
*/
public function __toString() : string;
}

View File

@@ -0,0 +1,323 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Message;
use Traversable;
use ZBateson\MailMimeParser\Header\IHeader;
/**
* An interface representation of any MIME email part.
*
* A MIME part may contain any combination of headers, content and children.
*
* @author Zaahid Bateson
*/
interface IMimePart extends IMultiPart
{
/**
* Returns true if this part's content type matches multipart/*
*/
public function isMultiPart() : bool;
/**
* Returns true if this part is the 'signature' part of a signed message.
*/
public function isSignaturePart() : bool;
/**
* Returns the IHeader object for the header with the given $name.
*
* If the optional $offset is passed, and multiple headers exist with the
* same name, the one at the passed offset is returned.
*
* Note that mime header names aren't case sensitive, and the '-' character
* is ignored, so ret
*
* If a header with the given $name and $offset doesn't exist, null is
* returned.
*
* @see IMimePart::getHeaderAs() to parse a header into a provided IHeader
* type and return it.
* @see IMimePart::getHeaderValue() to get the string value portion of a
* specific header only.
* @see IMimePart::getHeaderParameter() to get the string value portion of a
* specific header's parameter only.
* @see IMimePart::getAllHeaders() to retrieve an array of all header
* objects for this part.
* @see IMimePart::getAllHeadersByName() to retrieve an array of all headers
* with a certain name.
* @see IMimePart::getRawHeaders() to retrieve a two-dimensional string[][]
* array of raw headers in this part.
* @see IMimePart::getRawHeaderIterator() to retrieve an iterator traversing
* a two-dimensional string[] array of raw headers.
* @param string $name The name of the header to retrieve.
* @param int $offset Optional offset if there are multiple headers with the
* given name.
* @return ?IHeader the header object if it exists, or null if not
*/
public function getHeader(string $name, int $offset = 0) : ?IHeader;
/**
* Returns the IHeader object for the header with the given $name, using the
* passed $iHeaderClass to construct it.
*
* If the optional $offset is passed, and multiple headers exist with the
* same name, the one at the passed offset is returned.
*
* Note that mime headers aren't case sensitive, and the '-' character is
*
* If a header with the given $name and $offset doesn't exist, null is
* returned.
*
* @see IMimePart::getHeaderValue() to get the string value portion of a
* specific header only.
* @see IMimePart::getHeaderParameter() to get the string value portion of a
* specific header's parameter only.
* @see IMimePart::getAllHeaders() to retrieve an array of all header
* objects for this part.
* @see IMimePart::getAllHeadersByName() to retrieve an array of all headers
* with a certain name.
* @see IMimePart::getRawHeaders() to retrieve a two-dimensional string[][]
* array of raw headers in this part.
* @see IMimePart::getRawHeaderIterator() to retrieve an iterator traversing
* a two-dimensional string[] array of raw headers.
* @param string $name The name of the header to retrieve.
* @param int $offset Optional offset if there are multiple headers with the
* given name.
* @return ?IHeader the header object
*/
public function getHeaderAs(string $name, string $iHeaderClass, int $offset = 0) : ?IHeader;
/**
* Returns an array of all headers in this part.
*
* @see IMimePart::getHeader() to retrieve a single header object.
* @see IMimePart::getHeaderValue() to get the string value portion of a
* specific header only.
* @see IMimePart::getHeaderParameter() to get the string value portion of a
* specific header's parameter only.
* @see IMimePart::getAllHeadersByName() to retrieve an array of all headers
* with a certain name.
* @see IMimePart::getRawHeaders() to retrieve a two-dimensional string[][]
* array of raw headers in this part.
* @see IMimePart::getRawHeaderIterator() to retrieve an iterator traversing
* a two-dimensional string[] array of raw headers.
* @return IHeader[] an array of header objects
*/
public function getAllHeaders() : array;
/**
* Returns an array of headers that match the passed name.
*
* @see IMimePart::getHeader() to retrieve a single header object.
* @see IMimePart::getHeaderValue() to get the string value portion of a
* specific header only.
* @see IMimePart::getHeaderParameter() to get the string value portion of a
* specific header's parameter only.
* @see IMimePart::getAllHeaders() to retrieve an array of all header
* objects for this part.
* @see IMimePart::getRawHeaders() to retrieve a two-dimensional string[][]
* array of raw headers in this part.
* @see IMimePart::getRawHeaderIterator() to retrieve an iterator traversing
* a two-dimensional string[] array of raw headers.
* @return IHeader[] an array of header objects
*/
public function getAllHeadersByName(string $name) : array;
/**
* Returns a two dimensional string array of all headers for the mime part
* with the first element holding the name, and the second its raw string
* value:
*
* [ [ '1st-Header-Name', 'Header Value' ], [ '2nd-Header-Name', 'Header Value' ] ]
*
*
* @see IMimePart::getHeader() to retrieve a single header object.
* @see IMimePart::getHeaderValue() to get the string value portion of a
* specific header only.
* @see IMimePart::getHeaderParameter() to get the string value portion of a
* specific header's parameter only.
* @see IMimePart::getAllHeaders() to retrieve an array of all header
* objects for this part.
* @see IMimePart::getAllHeadersByName() to retrieve an array of all headers
* with a certain name.
* @see IMimePart::getRawHeaderIterator() to retrieve an iterator instead of
* the returned two-dimensional array
* @return string[][] an array of raw headers
*/
public function getRawHeaders() : array;
/**
* Returns an iterator to all headers in this part. Each returned element
* is an array with its first element set to the header's name, and the
* second to its raw value:
*
* [ 'Header-Name', 'Header Value' ]
*
* @see IMimePart::getHeader() to retrieve a single header object.
* @see IMimePart::getHeaderValue() to get the string value portion of a
* specific header only.
* @see IMimePart::getHeaderParameter() to get the string value portion of a
* specific header's parameter only.
* @see IMimePart::getAllHeaders() to retrieve an array of all header
* objects for this part.
* @see IMimePart::getAllHeadersByName() to retrieve an array of all headers
* with a certain name.
* @see IMimePart::getRawHeaders() to retrieve the array the returned
* iterator iterates over.
* @return Traversable<array<string>> an iterator for raw headers
*/
public function getRawHeaderIterator() : Traversable;
/**
* Returns the string value for the header with the given $name, or null if
* the header doesn't exist and no alternative $defaultValue is passed.
*
* Note that mime headers aren't case sensitive.
*
* @see IMimePart::getHeader() to retrieve a single header object.
* @see IMimePart::getHeaderParameter() to get the string value portion of a
* specific header's parameter only.
* @see IMimePart::getAllHeaders() to retrieve an array of all header
* objects for this part.
* @see IMimePart::getAllHeadersByName() to retrieve an array of all headers
* with a certain name.
* @see IMimePart::getRawHeaders() to retrieve the array the returned
* iterator iterates over.
* @see IMimePart::getRawHeaderIterator() to retrieve an iterator instead of
* the returned two-dimensional array
* @param string $name The name of the header
* @param ?string $defaultValue Optional default value to return if the
* header doesn't exist on this part.
* @return string|null the value of the header
*/
public function getHeaderValue(string $name, ?string $defaultValue = null) : ?string;
/**
* Returns the value of the parameter named $param on a header with the
* passed $header name, or null if the parameter doesn't exist and a
* $defaultValue isn't passed.
*
* Only headers of type
* {@see \ZBateson\MailMimeParser\Header\ParameterHeader} have parameters.
* Content-Type and Content-Disposition are examples of headers with
* parameters. "Charset" is a common parameter of Content-Type.
*
* @see IMimePart::getHeader() to retrieve a single header object.
* @see IMimePart::getHeaderValue() to get the string value portion of a
* specific header only.
* @see IMimePart::getAllHeaders() to retrieve an array of all header
* objects for this part.
* @see IMimePart::getAllHeadersByName() to retrieve an array of all headers
* with a certain name.
* @see IMimePart::getRawHeaders() to retrieve the array the returned
* iterator iterates over.
* @see IMimePart::getRawHeaderIterator() to retrieve an iterator instead of
* the returned two-dimensional array
* @param string $header The name of the header.
* @param string $param The name of the parameter.
* @param ?string $defaultValue Optional default value to return if the
* parameter doesn't exist.
* @return string|null The value of the parameter.
*/
public function getHeaderParameter(string $header, string $param, ?string $defaultValue = null) : ?string;
/**
* Adds a header with the given $name and $value. An optional $offset may
* be passed, which will overwrite a header if one exists with the given
* name and offset only. Otherwise a new header is added. The passed
* $offset may be ignored in that case if it doesn't represent the next
* insert position for the header with the passed name... instead it would
* be 'pushed' on at the next position.
*
* ```
* $part = $myMimePart;
* $part->setRawHeader('New-Header', 'value');
* echo $part->getHeaderValue('New-Header'); // 'value'
*
* $part->setRawHeader('New-Header', 'second', 4);
* echo is_null($part->getHeader('New-Header', 4)); // '1' (true)
* echo $part->getHeader('New-Header', 1)
* ->getValue(); // 'second'
* ```
*
* A new {@see \ZBateson\MailMimeParser\Header\IHeader} object is created
* from the passed value. No processing on the passed string is performed,
* and so the passed name and value must be formatted correctly according to
* related RFCs. In particular, be careful to encode non-ascii data, to
* keep lines under 998 characters in length, and to follow any special
* formatting required for the type of header.
*
* @see IMimePart::addRawHeader() Adds a header to the part regardless of
* whether or not a header with that name already exists.
* @see IMimePart::removeHeader() Removes all headers on this part with the
* passed name
* @see IMimePart::removeSingleHeader() Removes a single header if more than
* one with the passed name exists.
* @param string $name The name of the new header, e.g. 'Content-Type'.
* @param ?string $value The raw value of the new header.
* @param int $offset An optional offset, defaulting to '0' and therefore
* overriding the first header of the given $name if one exists.
*/
public function setRawHeader(string $name, ?string $value, int $offset = 0) : static;
/**
* Adds a header with the given $name and $value.
*
* Note: If a header with the passed name already exists, a new header is
* created with the same name. This should only be used when that is
* intentional - in most cases {@see IMimePart::setRawHeader()} should be
* called instead.
*
* A new {@see \ZBateson\MailMimeParser\Header\IHeader} object is created
* from the passed value. No processing on the passed string is performed,
* and so the passed name and value must be formatted correctly according to
* related RFCs. In particular, be careful to encode non-ascii data, to
* keep lines under 998 characters in length, and to follow any special
* formatting required for the type of header.
*
* @see IMimePart::setRawHeader() Sets a header, potentially overwriting one
* if it already exists.
* @see IMimePart::removeHeader() Removes all headers on this part with the
* passed name
* @see IMimePart::removeSingleHeader() Removes a single header if more than
* one with the passed name exists.
* @param string $name The name of the header
* @param string $value The raw value of the header.
*/
public function addRawHeader(string $name, string $value) : static;
/**
* Removes all headers from this part with the passed name.
*
* @see IMimePart::addRawHeader() Adds a header to the part regardless of
* whether or not a header with that name already exists.
* @see IMimePart::setRawHeader() Sets a header, potentially overwriting one
* if it already exists.
* @see IMimePart::removeSingleHeader() Removes a single header if more than
* one with the passed name exists.
* @param string $name The name of the header(s) to remove.
*/
public function removeHeader(string $name) : static;
/**
* Removes a single header with the passed name (in cases where more than
* one may exist, and others should be preserved).
*
* @see IMimePart::addRawHeader() Adds a header to the part regardless of
* whether or not a header with that name already exists.
* @see IMimePart::setRawHeader() Sets a header, potentially overwriting one
* if it already exists.
* @see IMimePart::removeHeader() Removes all headers on this part with the
* passed name
* @param string $name The name of the header to remove
* @param int $offset Optional offset of the header to remove (defaults to
* 0 -- the first header).
*/
public function removeSingleHeader(string $name, int $offset = 0) : static;
}

View File

@@ -0,0 +1,292 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Message;
use RecursiveIterator;
/**
* An interface representing a message part that contains children.
*
* An IMultiPart object may have any number of child parts, or may be a child
* itself with its own parent or parents.
*
* @author Zaahid Bateson
*/
interface IMultiPart extends IMessagePart
{
/**
* Returns the part at the given 0-based index for this part (part 0) and
* all parts under it, or null if not found with the passed filter function.
*
* Note that the first part returned is the current part itself. This is
* usually desirable for queries with a passed filter, e.g. looking for an
* part with a specific Content-Type that may be satisfied by the current
* part.
*
* The passed callable must accept an {@see IMessagePart} as an argument,
* and return true if it should be accepted, or false to filter the part
* out. Some default filters are provided by static functions returning
* callables in {@see PartFilter}.
*
* @see IMultiPart::getAllParts() to get an array of all parts with an
* optional filter.
* @see IMultiPart::getPartCount() to get the number of parts with an
* optional filter.
* @see IMultiPart::getChild() to get a direct child of the current part.
* @param int $index The 0-based index (0 being this part if $fnFilter is
* null or this part is satisfied by the filter).
* @param callable $fnFilter Optional function accepting an IMessagePart and
* returning true if the part should be included.
* @return IMessagePart|null A matching part, or null if not found.
*/
public function getPart(int $index, ?callable $fnFilter = null) : ?IMessagePart;
/**
* Returns the current part, all child parts, and child parts of all
* children optionally filtering them with the provided PartFilter.
*
* Note that the first part returned is the current part itself. This is
* often desirable for queries with a passed filter, e.g. looking for an
* IMessagePart with a specific Content-Type that may be satisfied by the
* current part.
*
* The passed callable must accept an {@see IMessagePart} as an argument,
* and return true if it should be accepted, or false to filter the part
* out. Some default filters are provided by static functions returning
* callables in {@see PartFilter}.
*
* @see IMultiPart::getPart() to find a part at a specific 0-based index
* with an optional filter.
* @see IMultiPart::getPartCount() to get the number of parts with an
* optional filter.
* @see IMultiPart::getChildParts() to get an array of all direct children
* of the current part.
* @param callable $fnFilter Optional function accepting an IMessagePart and
* returning true if the part should be included.
* @return IMessagePart[] An array of matching parts.
*/
public function getAllParts(?callable $fnFilter = null) : array;
/**
* Returns the total number of parts in this and all children.
*
* Note that the current part is considered, so the minimum getPartCount is
* 1 without a filter.
*
* The passed callable must accept an {@see IMessagePart} as an argument,
* and return true if it should be accepted, or false to filter the part
* out. Some default filters are provided by static functions returning
* callables in {@see PartFilter}.
*
* @see IMultiPart::getPart() to find a part at a specific 0-based index
* with an optional filter.
* @see IMultiPart::getAllParts() to get an array of all parts with an
* optional filter.
* @see IMultiPart::getChildCount() to get a count of direct children of
* this part.
* @param callable $fnFilter Optional function accepting an IMessagePart and
* returning true if the part should be included.
* @return int The number of matching parts.
*/
public function getPartCount(?callable $fnFilter = null) : int;
/**
* Returns the direct child at the given 0-based index and optional filter,
* or null if none exist or do not match.
*
* The passed callable must accept an {@see IMessagePart} as an argument,
* and return true if it should be accepted, or false to filter the part
* out. Some default filters are provided by static functions returning
* callables in {@see PartFilter}.
*
* @see IMultiPart::getChildParts() to get an array of all direct children
* of the current part.
* @see IMultiPart::getChildCount() to get a count of direct children of
* this part.
* @see IMultiPart::getChildIterator() to get an iterator of children of
* this part.
* @see IMultiPart::getPart() to find a part at a specific 0-based index
* with an optional filter.
* @param int $index 0-based index
* @param callable $fnFilter Optional function accepting an IMessagePart and
* returning true if the part should be included.
* @return IMessagePart|null The matching direct child part or null if not
* found.
*/
public function getChild(int $index, ?callable $fnFilter = null) : ?IMessagePart;
/**
* Returns an array of all direct child parts, optionally filtering them
* with a passed callable.
*
* The passed callable must accept an {@see IMessagePart} as an argument,
* and return true if it should be accepted, or false to filter the part
* out. Some default filters are provided by static functions returning
* callables in {@see PartFilter}.
*
* @see IMultiPart::getChild() to get a direct child of the current part.
* @see IMultiPart::getChildCount() to get a count of direct children of
* this part.
* @see IMultiPart::getChildIterator() to get an iterator of children of
* this part.
* @see IMultiPart::getAllParts() to get an array of all parts with an
* optional filter.
* @param callable $fnFilter Optional function accepting an IMessagePart and
* returning true if the part should be included.
* @return IMessagePart[] An array of matching child parts.
*/
public function getChildParts(?callable $fnFilter = null) : array;
/**
* Returns the number of direct children under this part (optionally
* counting only filtered items if a callable filter is passed).
*
* The passed callable must accept an {@see IMessagePart} as an argument,
* and return true if it should be accepted, or false to filter the part
* out. Some default filters are provided by static functions returning
* callables in {@see PartFilter}.
*
* @see IMultiPart::getChild() to get a direct child of the current part.
* @see IMultiPart::getChildParts() to get an array of all direct children
* of the current part.
* @see IMultiPart::getChildIterator() to get an iterator of children of
* this part.
* @see IMultiPart::getPartCount() to get the number of parts with an
* optional filter.
* @param callable $fnFilter Optional function accepting an IMessagePart and
* returning true if the part should be included.
* @return int The number of children, or number of children matching the
* the passed filtering callable.
*/
public function getChildCount(?callable $fnFilter = null) : int;
/**
* Returns a \RecursiveIterator of child parts.
*
* The {@see https://www.php.net/manual/en/class.recursiveiterator.php \RecursiveIterator}
* allows iterating over direct children, or using
* a {@see https://www.php.net/manual/en/class.recursiveiteratoriterator.php \RecursiveIteratorIterator}
* to iterate over direct children, and all their children.
*
* @see https://www.php.net/manual/en/class.recursiveiterator.php
* RecursiveIterator
* @see https://www.php.net/manual/en/class.recursiveiteratoriterator.php
* RecursiveIteratorIterator
* @see IMultiPart::getChild() to get a direct child of the current part.
* @see IMultiPart::getChildParts() to get an array of all direct children
* of the current part.
* @see IMultiPart::getChildCount() to get a count of direct children of
* this part.
* @see IMultiPart::getAllParts() to get an array of all parts with an
* optional filter.
* @return RecursiveIterator<IMessagePart>
*/
public function getChildIterator() : RecursiveIterator;
/**
* Returns the part that has a content type matching the passed mime type at
* the given index, or null if there are no matching parts.
*
* Creates a filter that looks at the return value of
* {@see IMessagePart::getContentType()} for all parts (including the
* current part) and returns a matching one at the given 0-based index.
*
* @see IMultiPart::getAllPartsByMimeType() to get all parts that match a
* mime type.
* @see IMultiPart::getCountOfPartsByMimeType() to get a count of parts with
* a mime type.
* @param string $mimeType The mime type to find.
* @param int $index Optional 0-based index (defaulting to '0').
* @return IMessagePart|null The part.
*/
public function getPartByMimeType(string $mimeType, int $index = 0) : ?IMessagePart;
/**
* Returns an array of all parts that have a content type matching the
* passed mime type.
*
* Creates a filter that looks at the return value of
* {@see IMessagePart::getContentType()} for all parts (including the
* current part), returning an array of matching parts.
*
* @see IMultiPart::getPartByMimeType() to get a part by mime type.
* @see IMultiPart::getCountOfPartsByMimeType() to get a count of parts with
* a mime type.
* @param string $mimeType The mime type to find.
* @return IMessagePart[] An array of matching parts.
*/
public function getAllPartsByMimeType(string $mimeType) : array;
/**
* Returns the number of parts that have content types matching the passed
* mime type.
*
* @see IMultiPart::getPartByMimeType() to get a part by mime type.
* @see IMultiPart::getAllPartsByMimeType() to get all parts that match a
* mime type.
* @param string $mimeType The mime type to find.
* @return int The number of matching parts.
*/
public function getCountOfPartsByMimeType(string $mimeType) : int;
/**
* Returns a part that has the given Content ID, or null if not found.
*
* Calls {@see IMessagePart::getContentId()} to find a matching part.
*
* @param string $contentId The content ID to find a part for.
* @return IMessagePart|null The matching part.
*/
public function getPartByContentId(string $contentId) : ?IMessagePart;
/**
* Registers the passed part as a child of the current part.
*
* If the $position parameter is non-null, adds the part at the passed
* position index, otherwise adds it as the last child.
*
* @param MessagePart $part The part to add.
* @param int $position Optional insertion position 0-based index.
*/
public function addChild(MessagePart $part, ?int $position = null) : static;
/**
* Removes the child part from this part and returns its previous position
* or null if it wasn't found.
*
* Note that if the part is not a direct child of this part, the returned
* position is its index within its parent (calls removePart on its direct
* parent).
*
* This also means that parts from unrelated parts/messages could be removed
* by a call to removePart -- it will always remove the part from its parent
* if it has one, essentially calling
* ```php $part->getParent()->removePart(); ```.
*
* @param IMessagePart $part The part to remove
* @return int|null The previous index position of the part within its old
* parent.
*/
public function removePart(IMessagePart $part) : ?int;
/**
* Removes all parts below the current part. If a callable filter is
* passed, removes only those matching the passed filter. The number of
* removed parts is returned.
*
* Note: the current part will not be removed. Although the function naming
* matches getAllParts, which returns the current part, it also doesn't only
* remove direct children like getChildParts. Internally this function uses
* getAllParts but the current part is filtered out if returned.
*
* @param callable $fnFilter Optional function accepting an IMessagePart and
* returning true if the part should be included.
* @return int The number of removed parts.
*/
public function removeAllParts(?callable $fnFilter = null) : int;
}

View File

@@ -0,0 +1,45 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Message;
/**
* An interface representing a non-mime uuencoded part.
*
* Prior to the MIME standard, a plain text email may have included attachments
* below it surrounded by 'begin' and 'end' lines and uuencoded data between
* them. Those attachments are captured as 'IUUEncodedPart' objects.
*
* The 'begin' line included a file name and unix file mode. IUUEncodedPart
* allows reading/setting those parameters.
*
* IUUEncodedPart returns a Content-Transfer-Encoding of x-uuencode, a
* Content-Type of application-octet-stream, and a Content-Disposition of
* 'attachment'. It also expects a mode and filename to initialize it, and
* adds 'filename' parts to the Content-Disposition and a 'name' parameter to
* Content-Type.
*
* @author Zaahid Bateson
*/
interface IUUEncodedPart extends IMessagePart
{
/**
* Sets the filename included in the uuencoded 'begin' line.
*/
public function setFilename(string $filename) : static;
/**
* Returns the file mode included in the uuencoded 'begin' line for this
* part.
*/
public function getUnixFileMode() : ?int;
/**
* Sets the unix file mode for the uuencoded 'begin' line.
*/
public function setUnixFileMode(int $mode) : static;
}

View File

@@ -0,0 +1,253 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Message;
use GuzzleHttp\Psr7\StreamWrapper;
use GuzzleHttp\Psr7\Utils;
use Psr\Http\Message\StreamInterface;
use Psr\Log\LoggerInterface;
use SplObjectStorage;
use SplObserver;
use ZBateson\MailMimeParser\ErrorBag;
use ZBateson\MailMimeParser\MailMimeParser;
use ZBateson\MailMimeParser\Stream\MessagePartStreamDecorator;
/**
* Most basic representation of a single part of an email.
*
* @author Zaahid Bateson
*/
abstract class MessagePart extends ErrorBag implements IMessagePart
{
/**
* @var ?IMimePart parent part
*/
protected ?IMimePart $parent;
/**
* @var PartStreamContainer holds 'stream' and 'content stream'.
*/
protected PartStreamContainer $partStreamContainer;
/**
* @var ?string can be used to set an override for content's charset in cases
* where a user knows the charset on the content is not what it claims
* to be.
*/
protected ?string $charsetOverride = null;
/**
* @var bool set to true when a user attaches a stream manually, it's
* assumed to already be decoded or to have relevant transfer encoding
* decorators attached already.
*/
protected bool $ignoreTransferEncoding = false;
/**
* @var SplObjectStorage attached observers that need to be notified of
* modifications to this part.
*/
protected SplObjectStorage $observers;
public function __construct(
LoggerInterface $logger,
PartStreamContainer $streamContainer,
?IMimePart $parent = null
) {
parent::__construct($logger);
$this->partStreamContainer = $streamContainer;
$this->parent = $parent;
$this->observers = new SplObjectStorage();
}
public function attach(SplObserver $observer) : void
{
$this->observers->offsetSet($observer);
}
public function detach(SplObserver $observer) : void
{
$this->observers->offsetUnset($observer);
}
public function notify() : void
{
foreach ($this->observers as $observer) {
$observer->update($this);
}
if ($this->parent !== null) {
$this->parent->notify();
}
}
public function getParent() : ?IMimePart
{
return $this->parent;
}
public function hasContent() : bool
{
return $this->partStreamContainer->hasContent();
}
public function getFilename() : ?string
{
return null;
}
public function setCharsetOverride(string $charsetOverride, bool $onlyIfNoCharset = false) : static
{
if (!$onlyIfNoCharset || $this->getCharset() === null) {
$this->charsetOverride = $charsetOverride;
}
return $this;
}
public function getContentStream(string $charset = MailMimeParser::DEFAULT_CHARSET) : ?MessagePartStreamDecorator
{
if ($this->hasContent()) {
$tr = ($this->ignoreTransferEncoding) ? '' : $this->getContentTransferEncoding();
$ch = $this->charsetOverride ?? $this->getCharset();
return $this->partStreamContainer->getContentStream(
$this,
$tr,
$ch,
$charset
);
}
return null;
}
public function getBinaryContentStream() : ?MessagePartStreamDecorator
{
if ($this->hasContent()) {
$tr = ($this->ignoreTransferEncoding) ? '' : $this->getContentTransferEncoding();
return $this->partStreamContainer->getBinaryContentStream($this, $tr);
}
return null;
}
public function getBinaryContentResourceHandle() : mixed
{
$stream = $this->getBinaryContentStream();
if ($stream !== null) {
return StreamWrapper::getResource($stream);
}
return null;
}
public function saveContent($filenameResourceOrStream) : static
{
$resourceOrStream = $filenameResourceOrStream;
if (\is_string($filenameResourceOrStream)) {
$resourceOrStream = \fopen($filenameResourceOrStream, 'w+');
}
$stream = Utils::streamFor($resourceOrStream);
Utils::copyToStream($this->getBinaryContentStream(), $stream);
if (!\is_string($filenameResourceOrStream)
&& !($filenameResourceOrStream instanceof StreamInterface)) {
// only detach if it wasn't a string or StreamInterface, so the
// fopen call can be properly closed if it was
$stream->detach();
}
return $this;
}
public function getContent(string $charset = MailMimeParser::DEFAULT_CHARSET) : ?string
{
$stream = $this->getContentStream($charset);
if ($stream !== null) {
return $stream->getContents();
}
return null;
}
public function attachContentStream(StreamInterface $stream, string $streamCharset = MailMimeParser::DEFAULT_CHARSET) : static
{
$ch = $this->charsetOverride ?? $this->getCharset();
if ($ch !== null && $streamCharset !== $ch) {
$this->charsetOverride = $streamCharset;
}
$this->ignoreTransferEncoding = true;
$this->partStreamContainer->setContentStream($stream);
$this->notify();
return $this;
}
public function detachContentStream() : static
{
$this->partStreamContainer->setContentStream(null);
$this->notify();
return $this;
}
public function setContent($resource, string $charset = MailMimeParser::DEFAULT_CHARSET) : static
{
$stream = Utils::streamFor($resource);
$this->attachContentStream($stream, $charset);
// this->notify() called in attachContentStream
return $this;
}
public function getResourceHandle() : mixed
{
return StreamWrapper::getResource($this->getStream());
}
public function getStream() : StreamInterface
{
return $this->partStreamContainer->getStream();
}
public function save($filenameResourceOrStream, string $filemode = 'w+') : static
{
$resourceOrStream = $filenameResourceOrStream;
if (\is_string($filenameResourceOrStream)) {
$resourceOrStream = \fopen($filenameResourceOrStream, $filemode);
}
$partStream = $this->getStream();
$partStream->rewind();
$stream = Utils::streamFor($resourceOrStream);
Utils::copyToStream($partStream, $stream);
if (!\is_string($filenameResourceOrStream)
&& !($filenameResourceOrStream instanceof StreamInterface)) {
// only detach if it wasn't a string or StreamInterface, so the
// fopen call can be properly closed if it was
$stream->detach();
}
return $this;
}
public function __toString() : string
{
return $this->getStream()->getContents();
}
public function getErrorLoggingContextName() : string
{
$params = '';
if (!empty($this->getContentId())) {
$params .= ', content-id=' . $this->getContentId();
}
$params .= ', content-type=' . $this->getContentType();
$nsClass = static::class;
$class = \substr($nsClass, (\strrpos($nsClass, '\\') ?? -1) + 1);
return $class . '(' . \spl_object_id($this) . $params . ')';
}
protected function getErrorBagChildren() : array
{
return [
$this->partStreamContainer
];
}
}

View File

@@ -0,0 +1,302 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Message;
use Psr\Log\LoggerInterface;
use Traversable;
use ZBateson\MailMimeParser\Header\HeaderConsts;
use ZBateson\MailMimeParser\Header\IHeader;
use ZBateson\MailMimeParser\Header\ParameterHeader;
use ZBateson\MailMimeParser\IMessage;
use ZBateson\MailMimeParser\MailMimeParser;
/**
* A mime email message part.
*
* A MIME part may contain any combination of headers, content and children.
*
* @author Zaahid Bateson
*/
class MimePart extends MultiPart implements IMimePart
{
/**
* @var PartHeaderContainer Container for this part's headers.
*/
protected PartHeaderContainer $headerContainer;
public function __construct(
?IMimePart $parent = null,
?LoggerInterface $logger = null,
?PartStreamContainer $streamContainer = null,
?PartHeaderContainer $headerContainer = null,
?PartChildrenContainer $partChildrenContainer = null
) {
$di = MailMimeParser::getGlobalContainer();
parent::__construct(
$logger ?? $di->get(LoggerInterface::class),
$streamContainer ?? $di->get(PartStreamContainer::class),
$partChildrenContainer ?? $di->get(PartChildrenContainer::class),
$parent
);
$this->headerContainer = $headerContainer ?? $di->get(PartHeaderContainer::class);
}
/**
* Returns a filename for the part if one is defined, or null otherwise.
*
* Uses the 'filename' parameter of the Content-Disposition header if it
* exists, or the 'name' parameter of the 'Content-Type' header if it
* doesn't.
*
* @return string|null the file name of the part or null.
*/
public function getFilename() : ?string
{
return $this->getHeaderParameter(
HeaderConsts::CONTENT_DISPOSITION,
'filename',
$this->getHeaderParameter(
HeaderConsts::CONTENT_TYPE,
'name'
)
);
}
/**
* Returns true.
*
*/
public function isMime() : bool
{
return true;
}
public function isMultiPart() : bool
{
// casting to bool, preg_match returns 1 for true
return (bool) (\preg_match(
'~multipart/.*~i',
$this->getContentType()
));
}
/**
* Returns true if this part has a defined 'charset' on its Content-Type
* header.
*
* This may result in some false positives if charset is set on a part that
* is not plain text which has been seen. If a part is known to be binary,
* it's better to use {@see IMessagePart::getBinaryContentStream()} to
* avoid issues, or to call {@see IMessagePart::saveContent()} directly if
* saving a part's content.
*
*/
public function isTextPart() : bool
{
return ($this->getCharset() !== null);
}
/**
* Returns the mime type of the content, or $default if one is not set.
*
* Looks at the part's Content-Type header and returns its value if set, or
* defaults to 'text/plain'.
*
* Note that the returned value is converted to lower case, and may not be
* identical to calling {@see MimePart::getHeaderValue('Content-Type')} in
* some cases.
*
* @param string $default Optional default value to specify a default other
* than text/plain if needed.
* @return string the mime type
*/
public function getContentType(string $default = 'text/plain') : ?string
{
return \strtolower($this->getHeaderValue(HeaderConsts::CONTENT_TYPE, $default));
}
/**
* Returns the charset of the content, or null if not applicable/defined.
*
* Looks for a 'charset' parameter under the 'Content-Type' header of this
* part and returns it if set, defaulting to 'ISO-8859-1' if the
* Content-Type header exists and is of type text/plain or text/html.
*
* Note that the returned value is also converted to upper case.
*
* @return string|null the charset
*/
public function getCharset() : ?string
{
$charset = $this->getHeaderParameter(HeaderConsts::CONTENT_TYPE, 'charset');
if ($charset === null || \strcasecmp($charset, 'binary') === 0) {
$contentType = $this->getContentType();
if ($contentType === 'text/plain' || $contentType === 'text/html') {
return 'ISO-8859-1';
}
return null;
}
return \strtoupper($charset);
}
/**
* Returns the content's disposition, or returns the value of $default if
* not defined.
*
* Looks at the 'Content-Disposition' header, which should only contain
* either 'inline' or 'attachment'. If the header is not one of those
* values, $default is returned, which defaults to 'inline' unless passed
* something else.
*
* @param string $default Optional default value if not set or does not
* match 'inline' or 'attachment'.
* @return string the content disposition
*/
public function getContentDisposition(?string $default = 'inline') : ?string
{
$value = $this->getHeaderValue(HeaderConsts::CONTENT_DISPOSITION);
if ($value === null || !\in_array($value, ['inline', 'attachment'])) {
return $default;
}
return \strtolower($value);
}
/**
* Returns the content transfer encoding used to encode the content on this
* part, or the value of $default if not defined.
*
* Looks up and returns the value of the 'Content-Transfer-Encoding' header
* if set, defaulting to '7bit' if an alternate $default param is not
* passed.
*
* The returned value is always lowercase, and header values of 'x-uue',
* 'uue' and 'uuencode' will return 'x-uuencode' instead.
*
* @param string $default Optional default value to return if the header
* isn't set.
* @return string the content transfer encoding.
*/
public function getContentTransferEncoding(?string $default = '7bit') : ?string
{
static $translated = [
'x-uue' => 'x-uuencode',
'uue' => 'x-uuencode',
'uuencode' => 'x-uuencode'
];
$type = \strtolower($this->getHeaderValue(HeaderConsts::CONTENT_TRANSFER_ENCODING, $default));
if (isset($translated[$type])) {
return $translated[$type];
}
return $type;
}
/**
* Returns the Content ID of the part, or null if not defined.
*
* Looks up and returns the value of the 'Content-ID' header.
*
* @return string|null the content ID or null if not defined.
*/
public function getContentId() : ?string
{
return $this->getHeaderValue(HeaderConsts::CONTENT_ID);
}
/**
* Returns true if this part's parent is an IMessage, and is the same part
* returned by {@see IMessage::getSignaturePart()}.
*/
public function isSignaturePart() : bool
{
if ($this->parent === null || !$this->parent instanceof IMessage) {
return false;
}
return $this->parent->getSignaturePart() === $this;
}
public function getHeader(string $name, int $offset = 0) : ?IHeader
{
return $this->headerContainer->get($name, $offset);
}
public function getHeaderAs(string $name, string $iHeaderClass, int $offset = 0) : ?IHeader
{
return $this->headerContainer->getAs($name, $iHeaderClass, $offset);
}
public function getAllHeaders() : array
{
return $this->headerContainer->getHeaderObjects();
}
public function getAllHeadersByName(string $name) : array
{
return $this->headerContainer->getAll($name);
}
public function getRawHeaders() : array
{
return $this->headerContainer->getHeaders();
}
public function getRawHeaderIterator() : Traversable
{
return $this->headerContainer->getIterator();
}
public function getHeaderValue(string $name, ?string $defaultValue = null) : ?string
{
$header = $this->getHeader($name);
if ($header !== null) {
return $header->getValue() ?: $defaultValue;
}
return $defaultValue;
}
public function getHeaderParameter(string $header, string $param, ?string $defaultValue = null) : ?string
{
$obj = $this->getHeader($header);
if ($obj && $obj instanceof ParameterHeader) {
return $obj->getValueFor($param, $defaultValue);
}
return $defaultValue;
}
public function setRawHeader(string $name, ?string $value, int $offset = 0) : static
{
$this->headerContainer->set($name, $value, $offset);
$this->notify();
return $this;
}
public function addRawHeader(string $name, string $value) : static
{
$this->headerContainer->add($name, $value);
$this->notify();
return $this;
}
public function removeHeader(string $name) : static
{
$this->headerContainer->removeAll($name);
$this->notify();
return $this;
}
public function removeSingleHeader(string $name, int $offset = 0) : static
{
$this->headerContainer->remove($name, $offset);
$this->notify();
return $this;
}
public function getErrorBagChildren() : array
{
return \array_merge(parent::getErrorBagChildren(), [$this->headerContainer]);
}
}

View File

@@ -0,0 +1,178 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Message;
use AppendIterator;
use ArrayIterator;
use Iterator;
use Psr\Log\LoggerInterface;
use RecursiveIterator;
use RecursiveIteratorIterator;
/**
* A message part that contains children.
*
* @author Zaahid Bateson
*/
abstract class MultiPart extends MessagePart implements IMultiPart
{
/**
* @var PartChildrenContainer child part container
*/
protected PartChildrenContainer $partChildrenContainer;
public function __construct(
LoggerInterface $logger,
PartStreamContainer $streamContainer,
PartChildrenContainer $partChildrenContainer,
?IMimePart $parent = null
) {
parent::__construct($logger, $streamContainer, $parent);
$this->partChildrenContainer = $partChildrenContainer;
}
private function getAllPartsIterator() : AppendIterator
{
$iter = new AppendIterator();
$iter->append(new ArrayIterator([$this]));
$iter->append(new RecursiveIteratorIterator($this->partChildrenContainer, RecursiveIteratorIterator::SELF_FIRST));
return $iter;
}
private function iteratorFindAt(Iterator $iter, int $index, ?callable $fnFilter = null) : ?IMessagePart
{
$pos = 0;
foreach ($iter as $part) {
if (($fnFilter === null || $fnFilter($part))) {
if ($index === $pos) {
return $part;
}
++$pos;
}
}
return null;
}
public function getPart(int $index, ?callable $fnFilter = null) : ?IMessagePart
{
return $this->iteratorFindAt(
$this->getAllPartsIterator(),
$index,
$fnFilter
);
}
public function getAllParts(?callable $fnFilter = null) : array
{
$array = \iterator_to_array($this->getAllPartsIterator(), false);
if ($fnFilter !== null) {
return \array_values(\array_filter($array, $fnFilter));
}
return $array;
}
public function getPartCount(?callable $fnFilter = null) : int
{
return \count($this->getAllParts($fnFilter));
}
public function getChild(int $index, ?callable $fnFilter = null) : ?IMessagePart
{
return $this->iteratorFindAt(
$this->partChildrenContainer,
$index,
$fnFilter
);
}
public function getChildIterator() : RecursiveIterator
{
return $this->partChildrenContainer;
}
public function getChildParts(?callable $fnFilter = null) : array
{
$array = \iterator_to_array($this->partChildrenContainer, false);
if ($fnFilter !== null) {
return \array_values(\array_filter($array, $fnFilter));
}
return $array;
}
public function getChildCount(?callable $fnFilter = null) : int
{
return \count($this->getChildParts($fnFilter));
}
public function getPartByMimeType(string $mimeType, int $index = 0) : ?IMessagePart
{
return $this->getPart($index, PartFilter::fromContentType($mimeType));
}
public function getAllPartsByMimeType(string $mimeType) : array
{
return $this->getAllParts(PartFilter::fromContentType($mimeType));
}
public function getCountOfPartsByMimeType(string $mimeType) : int
{
return $this->getPartCount(PartFilter::fromContentType($mimeType));
}
public function getPartByContentId(string $contentId) : ?IMessagePart
{
$sanitized = \preg_replace('/^\s*<|>\s*$/', '', $contentId);
return $this->getPart(0, function(IMessagePart $part) use ($sanitized) {
$cid = $part->getContentId();
return ($cid !== null && \strcasecmp($cid, $sanitized) === 0);
});
}
public function addChild(MessagePart $part, ?int $position = null) : static
{
if ($part !== $this) {
$part->parent = $this;
$this->partChildrenContainer->add($part, $position);
$this->notify();
}
return $this;
}
public function removePart(IMessagePart $part) : ?int
{
$parent = $part->getParent();
if ($this !== $parent && $parent !== null) {
return $parent->removePart($part);
}
$position = $this->partChildrenContainer->remove($part);
if ($position !== null) {
$this->notify();
}
return $position;
}
public function removeAllParts(?callable $fnFilter = null) : int
{
$parts = $this->getAllParts($fnFilter);
$count = \count($parts);
foreach ($parts as $part) {
if ($part === $this) {
--$count;
continue;
}
$this->removePart($part);
}
return $count;
}
protected function getErrorBagChildren() : array
{
return \array_merge(parent::getErrorBagChildren(), $this->getChildParts());
}
}

View File

@@ -0,0 +1,77 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Message;
/**
* Represents part of a non-mime message.
*
* @author Zaahid Bateson
*/
abstract class NonMimePart extends MessagePart
{
/**
* Returns true.
*
*/
public function isTextPart() : bool
{
return true;
}
/**
* Returns text/plain
*/
public function getContentType(string $default = 'text/plain') : ?string
{
return $default;
}
/**
* Returns ISO-8859-1
*/
public function getCharset() : ?string
{
return 'ISO-8859-1';
}
/**
* Returns 'inline'.
*/
public function getContentDisposition(?string $default = 'inline') : ?string
{
return 'inline';
}
/**
* Returns '7bit'.
*/
public function getContentTransferEncoding(?string $default = '7bit') : ?string
{
return '7bit';
}
/**
* Returns false.
*
*/
public function isMime() : bool
{
return false;
}
/**
* Returns the Content ID of the part.
*
* NonMimeParts do not have a Content ID, and so this simply returns null.
*
*/
public function getContentId() : ?string
{
return null;
}
}

View File

@@ -0,0 +1,174 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Message;
use ArrayAccess;
use InvalidArgumentException;
use RecursiveIterator;
/**
* Container of IMessagePart items for a parent IMultiPart.
*
* @author Zaahid Bateson
*/
class PartChildrenContainer implements ArrayAccess, RecursiveIterator
{
/**
* @var IMessagePart[] array of child parts of the IMultiPart object that is
* holding this container.
*/
protected array $children;
/**
* @var int current key position within $children for iteration.
*/
protected $position = 0;
/**
* @param IMessagePart[] $children
*/
public function __construct(array $children = [])
{
$this->children = $children;
}
/**
* Returns true if the current element is an IMultiPart. Note that the
* iterator may still be empty.
*/
public function hasChildren() : bool
{
return ($this->current() instanceof IMultiPart);
}
/**
* If the current element points to an IMultiPart, its child iterator is
* returned by calling {@see IMultiPart::getChildIterator()}.
*
* @return RecursiveIterator<IMessagePart>|null the iterator
*/
public function getChildren() : ?RecursiveIterator
{
if ($this->current() instanceof IMultiPart) {
return $this->current()->getChildIterator();
}
return null;
}
/**
* @return IMessagePart
*/
public function current() : mixed
{
return $this->offsetGet($this->position);
}
public function key() : int
{
return $this->position;
}
public function next() : void
{
++$this->position;
}
public function rewind() : void
{
$this->position = 0;
}
public function valid() : bool
{
return $this->offsetExists($this->position);
}
/**
* Adds the passed IMessagePart to the container in the passed position.
*
* If position is not passed or null, the part is added to the end, as the
* last child in the container.
*
* @param IMessagePart $part The part to add
* @param int $position An optional index position (0-based) to add the
* child at.
*/
public function add(IMessagePart $part, $position = null) : static
{
$index = $position ?? \count($this->children);
\array_splice(
$this->children,
$index,
0,
[$part]
);
return $this;
}
/**
* Removes the passed part, and returns the integer position it occupied.
*
* @param IMessagePart $part The part to remove.
* @return int the 0-based position it previously occupied.
*/
public function remove(IMessagePart $part) : ?int
{
foreach ($this->children as $key => $child) {
if ($child === $part) {
$this->offsetUnset($key);
return $key;
}
}
return null;
}
/**
* @param int $offset
*/
public function offsetExists(mixed $offset) : bool
{
return isset($this->children[$offset]);
}
/**
* @param int $offset
*/
public function offsetGet(mixed $offset) : mixed
{
return $this->offsetExists($offset) ? $this->children[$offset] : null;
}
/**
* @param int $offset
* @param IMessagePart $value
*/
public function offsetSet(mixed $offset, mixed $value) : void
{
if (!$value instanceof IMessagePart) {
throw new InvalidArgumentException(
\get_class($value) . ' is not a ZBateson\MailMimeParser\Message\IMessagePart'
);
}
$index = $offset ?? \count($this->children);
$this->children[$index] = $value;
if ($index < $this->position) {
++$this->position;
}
}
/**
* @param int $offset
*/
public function offsetUnset($offset) : void
{
\array_splice($this->children, $offset, 1);
if ($this->position >= $offset) {
--$this->position;
}
}
}

View File

@@ -0,0 +1,114 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Message;
/**
* Collection of static methods that return callables for common IMultiPart
* child filters.
*
* @author Zaahid Bateson
*/
abstract class PartFilter
{
/**
* Provides an 'attachment' filter used by Message::getAttachmentPart.
*
* The method filters out the following types of parts:
* - text/plain and text/html parts that do not have an 'attachment'
* disposition
* - any part that returns true for isMultiPart()
* - any part that returns true for isSignaturePart()
*/
public static function fromAttachmentFilter() : callable
{
return function(IMessagePart $part) {
$type = $part->getContentType();
$disp = $part->getContentDisposition();
if (\in_array($type, ['text/plain', 'text/html']) && $disp !== null && \strcasecmp($disp, 'inline') === 0) {
return false;
}
return !(($part instanceof IMimePart)
&& ($part->isMultiPart() || $part->isSignaturePart()));
};
}
/**
* Provides a filter that keeps parts that contain a header of $name with a
* value that matches $value (case insensitive).
*
* By default signed parts are excluded. Pass FALSE to the third parameter
* to include them.
*
* @param string $name The header name to look up
* @param string $value The value to match
* @param bool $excludeSignedParts Optional signed parts exclusion (defaults
* to true).
*/
public static function fromHeaderValue(string $name, string $value, bool $excludeSignedParts = true) : callable
{
return function(IMessagePart $part) use ($name, $value, $excludeSignedParts) {
if ($part instanceof IMimePart) {
if ($excludeSignedParts && $part->isSignaturePart()) {
return false;
}
return (\strcasecmp($part->getHeaderValue($name, ''), $value) === 0);
}
return false;
};
}
/**
* Includes only parts that match the passed $mimeType in the return value
* of a call to 'getContentType()'.
*
* @param string $mimeType Mime type of parts to find.
*/
public static function fromContentType(string $mimeType) : callable
{
return function(IMessagePart $part) use ($mimeType) {
return \strcasecmp($part->getContentType() ?: '', $mimeType) === 0;
};
}
/**
* Returns parts matching $mimeType that do not have a Content-Disposition
* set to 'attachment'.
*
* @param string $mimeType Mime type of parts to find.
*/
public static function fromInlineContentType(string $mimeType) : callable
{
return function(IMessagePart $part) use ($mimeType) {
$disp = $part->getContentDisposition();
return (\strcasecmp($part->getContentType() ?: '', $mimeType) === 0) && ($disp === null
|| \strcasecmp($disp, 'attachment') !== 0);
};
}
/**
* Finds parts with the passed disposition (matching against
* IMessagePart::getContentDisposition()), optionally including
* multipart parts and signed parts.
*
* @param string $disposition The disposition to find.
* @param bool $includeMultipart Optionally include multipart parts by
* passing true (defaults to false).
* @param bool $includeSignedParts Optionally include signed parts (defaults
* to false).
*/
public static function fromDisposition(string $disposition, bool $includeMultipart = false, bool $includeSignedParts = false) : callable
{
return function(IMessagePart $part) use ($disposition, $includeMultipart, $includeSignedParts) {
if (($part instanceof IMimePart) && ((!$includeMultipart && $part->isMultiPart()) || (!$includeSignedParts && $part->isSignaturePart()))) {
return false;
}
$disp = $part->getContentDisposition();
return ($disp !== null && \strcasecmp($disp, $disposition) === 0);
};
}
}

View File

@@ -0,0 +1,325 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Message;
use ArrayIterator;
use IteratorAggregate;
use Psr\Log\LoggerInterface;
use Traversable;
use ZBateson\MailMimeParser\ErrorBag;
use ZBateson\MailMimeParser\Header\HeaderFactory;
use ZBateson\MailMimeParser\Header\IHeader;
/**
* Maintains a collection of headers for a part.
*
* @author Zaahid Bateson
*/
class PartHeaderContainer extends ErrorBag implements IteratorAggregate
{
/**
* @var HeaderFactory the HeaderFactory object used for created headers
*/
protected $headerFactory;
/**
* @var string[][] Each element in the array is an array with its first
* element set to the header's name, and the second its value.
*/
private $headers = [];
/**
* @var \ZBateson\MailMimeParser\Header\IHeader[] Each element is an IHeader
* representing the header at the same index in the $headers array. If
* an IHeader has not been constructed for the header at that index,
* the element would be set to null.
*/
private $headerObjects = [];
/**
* @var array Maps header names by their "normalized" (lower-cased,
* non-alphanumeric characters stripped) name to an array of indexes in
* the $headers array. For example:
* $headerMap['contenttype'] = [ 1, 4 ]
* would indicate that the headers in $headers[1] and $headers[4] are
* both headers with the name 'Content-Type' or 'contENTtype'.
*/
private $headerMap = [];
/**
* @var int the next index to use for $headers and $headerObjects.
*/
private $nextIndex = 0;
/**
* Pass a PartHeaderContainer as the second parameter. This is useful when
* creating a new MimePart with this PartHeaderContainer and the original
* container is needed for parsing and changes to the header in the part
* should not affect parsing.
*
* @param PartHeaderContainer $cloneSource the original container to clone
* from
*/
public function __construct(
LoggerInterface $logger,
HeaderFactory $headerFactory,
?PartHeaderContainer $cloneSource = null
) {
parent::__construct($logger);
$this->headerFactory = $headerFactory;
if ($cloneSource !== null) {
$this->headers = $cloneSource->headers;
$this->headerObjects = $cloneSource->headerObjects;
$this->headerMap = $cloneSource->headerMap;
$this->nextIndex = $cloneSource->nextIndex;
}
}
/**
* Returns true if the passed header exists in this collection.
*/
public function exists(string $name, int $offset = 0) : bool
{
$s = $this->headerFactory->getNormalizedHeaderName($name);
return isset($this->headerMap[$s][$offset]);
}
/**
* Returns an array of header indexes with names that more closely match
* the passed $name if available: for instance if there are two headers in
* an email, "Content-Type" and "ContentType", and the query is for a header
* with the name "Content-Type", only headers that match exactly
* "Content-Type" would be returned.
*
* @return int[]|null
*/
private function getAllWithOriginalHeaderNameIfSet(string $name) : ?array
{
$s = $this->headerFactory->getNormalizedHeaderName($name);
if (isset($this->headerMap[$s])) {
$self = $this;
$filtered = \array_filter($this->headerMap[$s], function($h) use ($name, $self) {
return (\strcasecmp($self->headers[$h][0], $name) === 0);
});
return (!empty($filtered)) ? $filtered : $this->headerMap[$s];
}
return null;
}
/**
* Returns the IHeader object for the header with the given $name, or null
* if none exist.
*
* An optional offset can be provided, which defaults to the first header in
* the collection when more than one header with the same name exists.
*
* Note that mime headers aren't case sensitive.
*/
public function get(string $name, int $offset = 0) : ?IHeader
{
$a = $this->getAllWithOriginalHeaderNameIfSet($name);
if (!empty($a) && isset($a[$offset])) {
return $this->getByIndex($a[$offset]);
}
return null;
}
/**
* Returns the IHeader object for the header with the given $name, or null
* if none exist, using the passed $iHeaderClass to construct it.
*
* An optional offset can be provided, which defaults to the first header in
* the collection when more than one header with the same name exists.
*
* Note that mime headers aren't case sensitive.
*/
public function getAs(string $name, string $iHeaderClass, int $offset = 0) : ?IHeader
{
$a = $this->getAllWithOriginalHeaderNameIfSet($name);
if (!empty($a) && isset($a[$offset])) {
return $this->getByIndexAs($a[$offset], $iHeaderClass);
}
return null;
}
/**
* Returns all headers with the passed name.
*
* @return IHeader[]
*/
public function getAll(string $name) : array
{
$a = $this->getAllWithOriginalHeaderNameIfSet($name);
if (!empty($a)) {
$self = $this;
return \array_map(function($index) use ($self) {
return $self->getByIndex($index);
}, $a);
}
return [];
}
/**
* Returns the header in the headers array at the passed 0-based integer
* index or null if one doesn't exist.
*/
private function getByIndex(int $index) : ?IHeader
{
if (!isset($this->headers[$index])) {
return null;
}
if ($this->headerObjects[$index] === null) {
$this->headerObjects[$index] = $this->headerFactory->newInstance(
$this->headers[$index][0],
$this->headers[$index][1]
);
}
return $this->headerObjects[$index];
}
/**
* Returns the header in the headers array at the passed 0-based integer
* index or null if one doesn't exist, using the passed $iHeaderClass to
* construct it.
*/
private function getByIndexAs(int $index, string $iHeaderClass) : ?IHeader
{
if (!isset($this->headers[$index])) {
return null;
}
if ($this->headerObjects[$index] !== null && \get_class($this->headerObjects[$index]) === $iHeaderClass) {
return $this->headerObjects[$index];
}
return $this->headerFactory->newInstanceOf(
$this->headers[$index][0],
$this->headers[$index][1],
$iHeaderClass
);
}
/**
* Removes the header from the collection with the passed name. Defaults to
* removing the first instance of the header for a collection that contains
* more than one with the same passed name.
*
* @return bool true if a header was found and removed.
*/
public function remove(string $name, int $offset = 0) : bool
{
$s = $this->headerFactory->getNormalizedHeaderName($name);
if (isset($this->headerMap[$s][$offset])) {
$index = $this->headerMap[$s][$offset];
\array_splice($this->headerMap[$s], $offset, 1);
unset($this->headers[$index], $this->headerObjects[$index]);
return true;
}
return false;
}
/**
* Removes all headers that match the passed name.
*
* @return bool true if one or more headers were removed.
*/
public function removeAll(string $name) : bool
{
$s = $this->headerFactory->getNormalizedHeaderName($name);
if (!empty($this->headerMap[$s])) {
foreach ($this->headerMap[$s] as $i) {
unset($this->headers[$i], $this->headerObjects[$i]);
}
$this->headerMap[$s] = [];
return true;
}
return false;
}
/**
* Adds the header to the collection.
*/
public function add(string $name, string $value) : static
{
$s = $this->headerFactory->getNormalizedHeaderName($name);
$this->headers[$this->nextIndex] = [$name, $value];
$this->headerObjects[$this->nextIndex] = null;
if (!isset($this->headerMap[$s])) {
$this->headerMap[$s] = [];
}
$this->headerMap[$s][] = $this->nextIndex;
$this->nextIndex++;
return $this;
}
/**
* If a header exists with the passed name, and at the passed offset if more
* than one exists, its value is updated.
*
* If a header with the passed name doesn't exist at the passed offset, it
* is created at the next available offset (offset is ignored when adding).
*/
public function set(string $name, string $value, int $offset = 0) : static
{
$s = $this->headerFactory->getNormalizedHeaderName($name);
if (!isset($this->headerMap[$s][$offset])) {
$this->add($name, $value);
return $this;
}
$i = $this->headerMap[$s][$offset];
$this->headers[$i] = [$name, $value];
$this->headerObjects[$i] = null;
return $this;
}
/**
* Returns an array of IHeader objects representing all headers in this
* collection.
*
* @return IHeader[]
*/
public function getHeaderObjects() : array
{
return \array_filter(\array_map([$this, 'getByIndex'], \array_keys($this->headers)));
}
/**
* Returns an array of headers in this collection. Each returned element in
* the array is an array with the first element set to the name, and the
* second its value:
*
* [
* [ 'Header-Name', 'Header Value' ],
* [ 'Second-Header-Name', 'Second-Header-Value' ],
* // etc...
* ]
*
* @return string[][]
*/
public function getHeaders() : array
{
return \array_values(\array_filter($this->headers));
}
/**
* Returns an iterator to the headers in this collection. Each returned
* element is an array with its first element set to the header's name, and
* the second to its value:
*
* [ 'Header-Name', 'Header Value' ]
*
* return Traversable<array<string>>
*/
public function getIterator() : Traversable
{
return new ArrayIterator($this->getHeaders());
}
public function getErrorBagChildren() : array
{
return \array_values(\array_filter($this->headerObjects));
}
}

View File

@@ -0,0 +1,335 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Message;
use GuzzleHttp\Psr7\CachingStream;
use Psr\Http\Message\StreamInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use ZBateson\MailMimeParser\ErrorBag;
use ZBateson\MailMimeParser\Stream\MessagePartStreamDecorator;
use ZBateson\MailMimeParser\Stream\StreamFactory;
use ZBateson\MbWrapper\MbWrapper;
use ZBateson\MbWrapper\UnsupportedCharsetException;
/**
* Holds the stream and content stream objects for a part.
*
* Note that streams are not explicitly closed or detached on destruction of the
* PartSreamContainer by design: the passed StreamInterfaces will be closed on
* their destruction when no references to them remain, which is useful when the
* streams are passed around.
*
* In addition, all the streams passed to PartStreamContainer should be wrapping
* a ZBateson\StreamDecorators\NonClosingStream unless attached to a part by a
* user, this is because MMP uses a single seekable stream for content and wraps
* it in ZBateson\StreamDecorators\SeekingLimitStream objects for each part.
*
* @author Zaahid Bateson
*/
class PartStreamContainer extends ErrorBag
{
/**
* @var MbWrapper to test charsets and see if they're supported.
*/
protected MbWrapper $mbWrapper;
/**
* @var bool if false, reading from a content stream with an unsupported
* charset will be tried with the default charset, otherwise the stream
* created with the unsupported charset, and an exception will be
* thrown when read from.
*/
protected bool $throwExceptionReadingPartContentFromUnsupportedCharsets;
/**
* @var StreamFactory used to apply psr7 stream decorators to the
* attached StreamInterface based on encoding.
*/
protected StreamFactory $streamFactory;
/**
* @var MessagePartStreamDecorator stream containing the part's headers,
* content and children wrapped in a MessagePartStreamDecorator
*/
protected MessagePartStreamDecorator $stream;
/**
* @var StreamInterface a stream containing this part's content
*/
protected ?StreamInterface $contentStream = null;
/**
* @var StreamInterface the content stream after attaching transfer encoding
* streams to $contentStream.
*/
protected ?StreamInterface $decodedStream = null;
/**
* @var StreamInterface attached charset stream to $decodedStream
*/
protected ?StreamInterface $charsetStream = null;
/**
* @var bool true if the stream should be detached when this container is
* destroyed.
*/
protected bool $detachParsedStream = false;
/**
* @var array<string, null> map of the active encoding filter on the current handle.
*/
private array $encoding = [
'type' => null,
'filter' => null
];
/**
* @var array<string, null> map of the active charset filter on the current handle.
*/
private array $charset = [
'from' => null,
'to' => null,
'filter' => null
];
public function __construct(
LoggerInterface $logger,
StreamFactory $streamFactory,
MbWrapper $mbWrapper,
bool $throwExceptionReadingPartContentFromUnsupportedCharsets
) {
parent::__construct($logger);
$this->streamFactory = $streamFactory;
$this->mbWrapper = $mbWrapper;
$this->throwExceptionReadingPartContentFromUnsupportedCharsets = $throwExceptionReadingPartContentFromUnsupportedCharsets;
}
/**
* Sets the part's stream containing the part's headers, content, and
* children.
*/
public function setStream(MessagePartStreamDecorator $stream) : static
{
$this->stream = $stream;
return $this;
}
/**
* Returns the part's stream containing the part's headers, content, and
* children.
*/
public function getStream() : MessagePartStreamDecorator
{
// error out if called before setStream, getStream should never return
// null.
$this->stream->rewind();
return $this->stream;
}
/**
* Returns true if there's a content stream associated with the part.
*/
public function hasContent() : bool
{
return ($this->contentStream !== null);
}
/**
* Attaches the passed stream as the content portion of this
* StreamContainer.
*
* The content stream would represent the content portion of $this->stream.
*
* If the content is overridden, $this->stream should point to a dynamic
* {@see ZBateson\Stream\MessagePartStream} that dynamically creates the
* RFC822 formatted message based on the IMessagePart this
* PartStreamContainer belongs to.
*
* setContentStream can be called with 'null' to indicate the IMessagePart
* does not contain any content.
*/
public function setContentStream(?StreamInterface $contentStream = null) : static
{
$this->contentStream = $contentStream;
$this->decodedStream = null;
$this->charsetStream = null;
return $this;
}
/**
* Returns true if the attached stream filter used for decoding the content
* on the current handle is different from the one passed as an argument.
*/
private function isTransferEncodingFilterChanged(?string $transferEncoding) : bool
{
return ($transferEncoding !== $this->encoding['type']);
}
/**
* Returns true if the attached stream filter used for charset conversion on
* the current handle is different from the one needed based on the passed
* arguments.
*
*/
private function isCharsetFilterChanged(string $fromCharset, string $toCharset) : bool
{
return ($fromCharset !== $this->charset['from']
|| $toCharset !== $this->charset['to']);
}
/**
* Attaches a decoding filter to the attached content handle, for the passed
* $transferEncoding.
*/
protected function attachTransferEncodingFilter(?string $transferEncoding) : static
{
if ($this->decodedStream !== null) {
$this->encoding['type'] = $transferEncoding;
$this->decodedStream = new CachingStream($this->streamFactory->getTransferEncodingDecoratedStream(
$this->decodedStream,
$transferEncoding
));
}
return $this;
}
/**
* Attaches a charset conversion filter to the attached content handle, for
* the passed arguments.
*
* @param string $fromCharset the character set the content is encoded in
* @param string $toCharset the target encoding to return
*/
protected function attachCharsetFilter(string $fromCharset, string $toCharset) : static
{
if ($this->charsetStream !== null) {
if (!$this->throwExceptionReadingPartContentFromUnsupportedCharsets) {
try {
$this->mbWrapper->convert('t', $fromCharset, $toCharset);
$this->charsetStream = new CachingStream($this->streamFactory->newCharsetStream(
$this->charsetStream,
$fromCharset,
$toCharset
));
} catch (UnsupportedCharsetException $ex) {
$this->addError('Unsupported character set found', LogLevel::ERROR, $ex);
$this->charsetStream = new CachingStream($this->charsetStream);
}
} else {
$this->charsetStream = new CachingStream($this->streamFactory->newCharsetStream(
$this->charsetStream,
$fromCharset,
$toCharset
));
}
$this->charsetStream->rewind();
$this->charset['from'] = $fromCharset;
$this->charset['to'] = $toCharset;
}
return $this;
}
/**
* Resets just the charset stream, and rewinds the decodedStream.
*/
private function resetCharsetStream() : static
{
$this->charset = [
'from' => null,
'to' => null,
'filter' => null
];
$this->decodedStream->rewind();
$this->charsetStream = $this->decodedStream;
return $this;
}
/**
* Resets cached encoding and charset streams, and rewinds the stream.
*/
public function reset() : static
{
$this->encoding = [
'type' => null,
'filter' => null
];
$this->charset = [
'from' => null,
'to' => null,
'filter' => null
];
$this->contentStream->rewind();
$this->decodedStream = $this->contentStream;
$this->charsetStream = $this->contentStream;
return $this;
}
/**
* Checks what transfer-encoding decoder stream and charset conversion
* stream are currently attached on the underlying contentStream, and resets
* them if the requested arguments differ from the currently assigned ones.
*
* @param IMessagePart $part the part the stream belongs to
* @param string $transferEncoding the transfer encoding
* @param string $fromCharset the character set the content is encoded in
* @param string $toCharset the target encoding to return
*/
public function getContentStream(
IMessagePart $part,
?string $transferEncoding,
?string $fromCharset,
?string $toCharset
) : ?MessagePartStreamDecorator {
if ($this->contentStream === null) {
return null;
}
if (empty($fromCharset) || empty($toCharset)) {
return $this->getBinaryContentStream($part, $transferEncoding);
}
if ($this->charsetStream === null
|| $this->isTransferEncodingFilterChanged($transferEncoding)
|| $this->isCharsetFilterChanged($fromCharset, $toCharset)) {
if ($this->charsetStream === null
|| $this->isTransferEncodingFilterChanged($transferEncoding)) {
$this->reset();
$this->attachTransferEncodingFilter($transferEncoding);
}
$this->resetCharsetStream();
$this->attachCharsetFilter($fromCharset, $toCharset);
}
$this->charsetStream->rewind();
return $this->streamFactory->newDecoratedMessagePartStream(
$part,
$this->charsetStream
);
}
/**
* Checks what transfer-encoding decoder stream is attached on the
* underlying stream, and resets it if the requested arguments differ.
*/
public function getBinaryContentStream(IMessagePart $part, ?string $transferEncoding = null) : ?MessagePartStreamDecorator
{
if ($this->contentStream === null) {
return null;
}
if ($this->decodedStream === null
|| $this->isTransferEncodingFilterChanged($transferEncoding)) {
$this->reset();
$this->attachTransferEncodingFilter($transferEncoding);
}
$this->decodedStream->rewind();
return $this->streamFactory->newDecoratedMessagePartStream($part, $this->decodedStream);
}
protected function getErrorBagChildren() : array
{
return [];
}
}

View File

@@ -0,0 +1,120 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Message;
use Psr\Log\LoggerInterface;
use ZBateson\MailMimeParser\MailMimeParser;
/**
* Implementation of a non-mime message's uuencoded attachment part.
*
* @author Zaahid Bateson
*/
class UUEncodedPart extends NonMimePart implements IUUEncodedPart
{
/**
* @var int the unix file permission
*/
protected ?int $mode = null;
/**
* @var string the name of the file in the uuencoding 'header'.
*/
protected ?string $filename = null;
public function __construct(
?int $mode = null,
?string $filename = null,
?IMimePart $parent = null,
?LoggerInterface $logger = null,
?PartStreamContainer $streamContainer = null
) {
$di = MailMimeParser::getGlobalContainer();
parent::__construct(
$logger ?? $di->get(LoggerInterface::class),
$streamContainer ?? $di->get(PartStreamContainer::class),
$parent
);
$this->mode = $mode;
$this->filename = $filename;
}
/**
* Returns the filename included in the uuencoded 'begin' line for this
* part.
*/
public function getFilename() : ?string
{
return $this->filename;
}
public function setFilename(string $filename) : static
{
$this->filename = $filename;
$this->notify();
return $this;
}
/**
* Returns false.
*
* Although the part may be plain text, there is no reliable way of
* determining its type since uuencoded 'begin' lines only include a file
* name and no mime type. The file name's extension may be a hint.
*
* @return false
*/
public function isTextPart() : bool
{
return false;
}
/**
* Returns 'application/octet-stream'.
*/
public function getContentType(string $default = 'application/octet-stream') : ?string
{
return 'application/octet-stream';
}
/**
* Returns null
*/
public function getCharset() : ?string
{
return null;
}
/**
* Returns 'attachment'.
*/
public function getContentDisposition(?string $default = 'attachment') : ?string
{
return 'attachment';
}
/**
* Returns 'x-uuencode'.
*/
public function getContentTransferEncoding(?string $default = 'x-uuencode') : ?string
{
return 'x-uuencode';
}
public function getUnixFileMode() : ?int
{
return $this->mode;
}
public function setUnixFileMode(int $mode) : static
{
$this->mode = $mode;
$this->notify();
return $this;
}
}

View File

@@ -0,0 +1,75 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Parser;
use ZBateson\MailMimeParser\Parser\Proxy\ParserPartProxyFactory;
/**
* Provides basic implementations for:
* - IParser::setParserManager
* - IParser::getParserMessageProxyFactory (returns $this->parserMessageProxyFactory
* which can be set via the default constructor)
* - IParser::getParserPartProxyFactory (returns $this->parserPartProxyFactory
* which can be set via the default constructor)
*
* @author Zaahid Bateson
*/
abstract class AbstractParserService implements IParserService
{
/**
* @var ParserPartProxyFactory the parser's message proxy factory service
* responsible for creating an IMessage part wrapped in a
* ParserPartProxy.
*/
protected ParserPartProxyFactory $parserMessageProxyFactory;
/**
* @var ParserPartProxyFactory the parser's part proxy factory service
* responsible for creating IMessagePart parts wrapped in a
* ParserPartProxy.
*/
protected ParserPartProxyFactory $parserPartProxyFactory;
/**
* @var PartBuilderFactory Service for creating PartBuilder objects for new
* children.
*/
protected PartBuilderFactory $partBuilderFactory;
/**
* @var ParserManagerService the ParserManager, which should call setParserManager
* when the parser is added.
*/
protected ParserManagerService $parserManager;
public function __construct(
ParserPartProxyFactory $parserMessageProxyFactory,
ParserPartProxyFactory $parserPartProxyFactory,
PartBuilderFactory $partBuilderFactory
) {
$this->parserMessageProxyFactory = $parserMessageProxyFactory;
$this->parserPartProxyFactory = $parserPartProxyFactory;
$this->partBuilderFactory = $partBuilderFactory;
}
public function setParserManager(ParserManagerService $pm) : static
{
$this->parserManager = $pm;
return $this;
}
public function getParserMessageProxyFactory() : ParserPartProxyFactory
{
return $this->parserMessageProxyFactory;
}
public function getParserPartProxyFactory() : ParserPartProxyFactory
{
return $this->parserPartProxyFactory;
}
}

View File

@@ -0,0 +1,22 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Parser;
use RuntimeException;
/**
* Exception thrown if the ParserManagerService doesn't contain a parser that
* can handle a given type of part. The default configuration of MailMimeParser
* uses NonMimeParserService that is a 'catch-all', so this would indicate a
* configuration error.
*
* @author Zaahid Bateson
*/
class CompatibleParserNotFoundException extends RuntimeException
{
}

View File

@@ -0,0 +1,67 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Parser;
use Psr\Log\LogLevel;
use ZBateson\MailMimeParser\Message\PartHeaderContainer;
/**
* Reads headers from an input stream, adding them to a PartHeaderContainer.
*
* @author Zaahid Bateson
*/
class HeaderParserService
{
/**
* Ensures the header isn't empty and contains a colon separator character,
* then splits it and adds it to the passed PartHeaderContainer.
*
* @param int $offset read offset for error reporting
* @param string $header the header line
* @param PartHeaderContainer $headerContainer the container
*/
private function addRawHeaderToPart(int $offset, string $header, PartHeaderContainer $headerContainer) : static
{
if ($header !== '') {
if (\strpos($header, ':') !== false) {
$a = \explode(':', $header, 2);
$headerContainer->add($a[0], \trim($a[1]));
} else {
$headerContainer->addError(
"Invalid header found at offset: $offset",
LogLevel::ERROR
);
}
}
return $this;
}
/**
* Reads header lines up to an empty line, adding them to the passed
* PartHeaderContainer.
*
* @param resource $handle The resource handle to read from.
* @param PartHeaderContainer $container the container to add headers to.
*/
public function parse($handle, PartHeaderContainer $container) : static
{
$header = '';
do {
$offset = \ftell($handle);
$line = MessageParserService::readLine($handle);
if ($line === false || $line === '' || $line[0] !== "\t" && $line[0] !== ' ') {
$this->addRawHeaderToPart($offset, $header, $container);
$header = '';
} else {
$line = "\r\n" . $line;
}
$header .= \rtrim($line, "\r\n");
} while ($header !== '');
return $this;
}
}

View File

@@ -0,0 +1,91 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Parser;
use ZBateson\MailMimeParser\Parser\Proxy\ParserMimePartProxy;
use ZBateson\MailMimeParser\Parser\Proxy\ParserPartProxy;
use ZBateson\MailMimeParser\Parser\Proxy\ParserPartProxyFactory;
/**
* Interface defining a message part parser.
*
* @author Zaahid Bateson
*/
interface IParserService
{
/**
* Sets up the passed ParserManager as the ParserManager for this part,
* which should be used when a new part is created (after its headers are
* read and a PartBuilder is created from it.)
*
* @param ParserManagerService $pm The ParserManager to set.
*/
public function setParserManager(ParserManagerService $pm) : static;
/**
* Called by the ParserManager to determine if the passed PartBuilder is a
* part handled by this IParser.
*/
public function canParse(PartBuilder $part) : bool;
/**
* Returns the ParserPartProxyFactory responsible for creating IMessage
* parts for this parser.
*
* This is called by ParserManager after 'canParse' if it returns true so
* a ParserPartProxy can be created out of the PartBuilder.
*/
public function getParserMessageProxyFactory() : ParserPartProxyFactory;
/**
* Returns the ParserPartProxyFactory responsible for creating IMessagePart
* parts for this parser.
*
* This is called by ParserManager after 'canParse' if it returns true so
* a ParserPartProxy can be created out of the PartBuilder.
*/
public function getParserPartProxyFactory() : ParserPartProxyFactory;
/**
* Performs read operations for content from the stream of the passed
* ParserPartProxy, and setting content bounds for the part in the passed
* ParserPartProxy.
*
* The implementation should call $proxy->setStreamContentStartPos() and
* $proxy->setStreamContentAndPartEndPos() so an IMessagePart can return
* content from the raw message.
*
* Reading should stop once the end of the current part's content has been
* reached or the end of the message has been reached. If the end of the
* message has been reached $proxy->setEof() should be called in addition to
* setStreamContentAndPartEndPos().
*/
public function parseContent(ParserPartProxy $proxy) : static;
/**
* Performs read operations to read children from the passed $proxy, using
* its stream, and reading up to (and not including) the beginning of the
* child's content if another child exists.
*
* The implementation should:
* 1. Return null if there are no more children.
* 2. Read headers
* 3. Create a PartBuilder (adding the passed $proxy as its parent)
* 4. Call ParserManager::createParserProxyFor() on the ParserManager
* previously set by a call to setParserManager(), which may determine
* that a different parser is responsible for parts represented by
* the headers and PartBuilder passed to it.
*
* The method should then return the ParserPartProxy returned by the
* ParserManager, or null if there are no more children to read.
*
* @return ParserPartProxy|null The child ParserPartProxy or null if there
* are no more children under $proxy.
*/
public function parseNextChild(ParserMimePartProxy $proxy) : ?ParserPartProxy;
}

View File

@@ -0,0 +1,95 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Parser;
use Psr\Http\Message\StreamInterface;
use ZBateson\MailMimeParser\IMessage;
use ZBateson\MailMimeParser\Message\Factory\PartHeaderContainerFactory;
/**
* Parses a mail mime message into its component parts. To invoke, call
* {@see MailMimeParser::parse()}.
*
* @author Zaahid Bateson
*/
class MessageParserService
{
/**
* @var PartHeaderContainerFactory To create a container to read the
* message's headers into.
*/
protected PartHeaderContainerFactory $partHeaderContainerFactory;
/**
* @var ParserManagerService To figure out what parser is responsible for parsing a
* message.
*/
protected ParserManagerService $parserManager;
/**
* @var PartBuilderFactory To create a PartBuilder representing this
* message, and to pass it to ParserManager.
*/
protected PartBuilderFactory $partBuilderFactory;
/**
* @var HeaderParserService To parse the headers into a PartHeaderContainer.
*/
protected HeaderParserService $headerParser;
public function __construct(
PartBuilderFactory $pbf,
PartHeaderContainerFactory $phcf,
ParserManagerService $pm,
HeaderParserService $hp
) {
$this->partBuilderFactory = $pbf;
$this->partHeaderContainerFactory = $phcf;
$this->parserManager = $pm;
$this->headerParser = $hp;
}
/**
* Convenience method to read a line of up to 4096 characters from the
* passed resource handle.
*
* If the line is larger than 4096 characters, the remaining characters in
* the line are read and discarded, and only the first 4096 characters are
* returned.
*
* @param resource $handle
* @return string|false the read line or false on EOF or on error.
*/
public static function readLine($handle) : string|false
{
$size = 4096;
$ret = $line = \fgets($handle, $size);
while (\strlen($line) === $size - 1 && \substr($line, -1) !== "\n") {
$line = \fgets($handle, $size);
}
return $ret;
}
/**
* Parses the passed stream into an {@see ZBateson\MailMimeParser\IMessage}
* object and returns it.
*
* @param StreamInterface $stream the stream to parse the message from
*/
public function parse(StreamInterface $stream) : IMessage
{
$headerContainer = $this->partHeaderContainerFactory->newInstance();
$partBuilder = $this->partBuilderFactory->newPartBuilder($headerContainer, $stream);
$this->headerParser->parse(
$partBuilder->getMessageResourceHandle(),
$headerContainer
);
$proxy = $this->parserManager->createParserProxyFor($partBuilder);
return $proxy->getPart();
}
}

View File

@@ -0,0 +1,167 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Parser;
use ZBateson\MailMimeParser\Message\Factory\PartHeaderContainerFactory;
use ZBateson\MailMimeParser\Message\PartHeaderContainer;
use ZBateson\MailMimeParser\Parser\Proxy\ParserMessageProxyFactory;
use ZBateson\MailMimeParser\Parser\Proxy\ParserMimePartProxy;
use ZBateson\MailMimeParser\Parser\Proxy\ParserMimePartProxyFactory;
use ZBateson\MailMimeParser\Parser\Proxy\ParserPartProxy;
/**
* Parses content and children of MIME parts.
*
* @author Zaahid Bateson
*/
class MimeParserService extends AbstractParserService
{
/**
* @var PartHeaderContainerFactory Factory service for creating
* PartHeaderContainers for headers.
*/
protected PartHeaderContainerFactory $partHeaderContainerFactory;
/**
* @var HeaderParserService The HeaderParser service.
*/
protected HeaderParserService $headerParser;
public function __construct(
ParserMessageProxyFactory $parserMessageProxyFactory,
ParserMimePartProxyFactory $parserMimePartProxyFactory,
PartBuilderFactory $partBuilderFactory,
PartHeaderContainerFactory $partHeaderContainerFactory,
HeaderParserService $headerParser
) {
parent::__construct($parserMessageProxyFactory, $parserMimePartProxyFactory, $partBuilderFactory);
$this->partHeaderContainerFactory = $partHeaderContainerFactory;
$this->headerParser = $headerParser;
}
/**
* Returns true if the passed PartBuilder::isMime() method returns true.
*
*/
public function canParse(PartBuilder $part) : bool
{
return $part->isMime();
}
/**
* Reads up to 2048 bytes of input from the passed resource handle,
* discarding portions of a line that are longer than that, and returning
* the read portions of the line.
*
* The method also calls $proxy->setLastLineEndingLength which is used in
* findContentBoundary() to set the exact end byte of a part.
*
* @param resource $handle
*/
private function readBoundaryLine($handle, ParserMimePartProxy $proxy) : string
{
$size = 2048;
$isCut = false;
$line = \fgets($handle, $size);
while (\strlen($line) === $size - 1 && \substr($line, -1) !== "\n") {
$line = \fgets($handle, $size);
$isCut = true;
}
$ret = \rtrim($line, "\r\n");
$proxy->setLastLineEndingLength(\strlen($line) - \strlen($ret));
return ($isCut) ? '' : $ret;
}
/**
* Reads 2048-byte lines from the passed $handle, calling
* $partBuilder->setEndBoundaryFound with the passed line until it returns
* true or the stream is at EOF.
*
* setEndBoundaryFound returns true if the passed line matches a boundary
* for the $partBuilder itself or any of its parents.
*
* Lines longer than 2048 bytes are returned as single lines of 2048 bytes,
* the longer line is not returned separately but is simply discarded.
*
* Once a boundary is found, setStreamPartAndContentEndPos is called with
* the passed $handle's read pos before the boundary and its line separator
* were read.
*/
private function findContentBoundary(ParserMimePartProxy $proxy) : static
{
$handle = $proxy->getMessageResourceHandle();
// last separator before a boundary belongs to the boundary, and is not
// part of the current part, if a part is immediately followed by a
// boundary, this could result in a '-1' or '-2' content length
while (!\feof($handle)) {
$endPos = \ftell($handle) - $proxy->getLastLineEndingLength();
$line = $this->readBoundaryLine($handle, $proxy);
if (\substr($line, 0, 2) === '--' && $proxy->setEndBoundaryFound($line)) {
$proxy->setStreamPartAndContentEndPos($endPos);
return $this;
}
}
$proxy->setStreamPartAndContentEndPos(\ftell($handle));
$proxy->setEof();
return $this;
}
public function parseContent(ParserPartProxy $proxy) : static
{
$proxy->setStreamContentStartPos($proxy->getMessageResourceHandlePos());
$this->findContentBoundary($proxy);
return $this;
}
/**
* Calls the header parser to fill the passed $headerContainer, then calls
* $this->parserManager->createParserProxyFor($child);
*
* The method first checks though if the 'part' represents hidden content
* past a MIME end boundary, which some messages like to include, for
* instance:
*
* ```
* --outer-boundary--
* --boundary
* content
* --boundary--
* some hidden information
* --outer-boundary--
* ```
*
* In this case, $this->parserPartProxyFactory is called directly to create
* a part, $this->parseContent is called immediately to parse it and discard
* it, and null is returned.
*/
private function createPart(ParserMimePartProxy $parent, PartHeaderContainer $headerContainer, PartBuilder $child) : ?ParserPartProxy
{
if (!$parent->isEndBoundaryFound()) {
$this->headerParser->parse(
$child->getMessageResourceHandle(),
$headerContainer
);
$parserProxy = $this->parserManager->createParserProxyFor($child);
return $parserProxy;
}
// reads content past an end boundary if there is any
$parserProxy = $this->parserPartProxyFactory->newInstance($child, $this);
$this->parseContent($parserProxy);
return null;
}
public function parseNextChild(ParserMimePartProxy $proxy) : ?ParserPartProxy
{
if ($proxy->isParentBoundaryFound()) {
return null;
}
$headerContainer = $this->partHeaderContainerFactory->newInstance();
$child = $this->partBuilderFactory->newChildPartBuilder($headerContainer, $proxy);
return $this->createPart($proxy, $headerContainer, $child);
}
}

View File

@@ -0,0 +1,111 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Parser;
use ZBateson\MailMimeParser\Parser\Part\UUEncodedPartHeaderContainerFactory;
use ZBateson\MailMimeParser\Parser\Proxy\ParserMimePartProxy;
use ZBateson\MailMimeParser\Parser\Proxy\ParserNonMimeMessageProxy;
use ZBateson\MailMimeParser\Parser\Proxy\ParserNonMimeMessageProxyFactory;
use ZBateson\MailMimeParser\Parser\Proxy\ParserPartProxy;
use ZBateson\MailMimeParser\Parser\Proxy\ParserUUEncodedPartProxy;
use ZBateson\MailMimeParser\Parser\Proxy\ParserUUEncodedPartProxyFactory;
/**
* Parses content for non-mime messages and uu-encoded child parts.
*
* @author Zaahid Bateson
*/
class NonMimeParserService extends AbstractParserService
{
protected UUEncodedPartHeaderContainerFactory $partHeaderContainerFactory;
public function __construct(
ParserNonMimeMessageProxyFactory $parserNonMimeMessageProxyFactory,
ParserUUEncodedPartProxyFactory $parserUuEncodedPartProxyFactory,
PartBuilderFactory $partBuilderFactory,
UUEncodedPartHeaderContainerFactory $uuEncodedPartHeaderContainerFactory
) {
parent::__construct($parserNonMimeMessageProxyFactory, $parserUuEncodedPartProxyFactory, $partBuilderFactory);
$this->partHeaderContainerFactory = $uuEncodedPartHeaderContainerFactory;
}
/**
* Always returns true, and should therefore be the last parser reached by
* a ParserManager.
*/
public function canParse(PartBuilder $part) : bool
{
return true;
}
/**
* Creates a UUEncodedPartHeaderContainer attached to a PartBuilder, and
* calls $this->parserManager->createParserProxyFor().
*
* It also sets the PartBuilder's stream part start pos and content start
* pos to that of $parent->getNextParStart() (since a 'begin' line is read
* prior to another child being created, see parseNextPart()).
*/
private function createPart(ParserNonMimeMessageProxy $parent) : ParserPartProxy
{
$hc = $this->partHeaderContainerFactory->newInstance($parent->getNextPartMode(), $parent->getNextPartFilename());
$pb = $this->partBuilderFactory->newChildPartBuilder($hc, $parent);
$proxy = $this->parserManager->createParserProxyFor($pb);
$pb->setStreamPartStartPos($parent->getNextPartStart());
$pb->setStreamContentStartPos($parent->getNextPartStart());
return $proxy;
}
/**
* Reads content from the passed ParserPartProxy's stream till a uu-encoded
* 'begin' line is found, setting $proxy->setStreamPartContentAndEndPos() to
* the last byte read before the begin line.
*
* @param ParserNonMimeMessageProxy|ParserUUEncodedPartProxy $proxy
*/
private function parseNextPart(ParserPartProxy $proxy) : static
{
$handle = $proxy->getMessageResourceHandle();
while (!\feof($handle)) {
$start = \ftell($handle);
$line = \trim(MessageParserService::readLine($handle));
if (\preg_match('/^begin ([0-7]{3}) (.*)$/', $line, $matches)) {
$proxy->setNextPartStart($start);
$proxy->setNextPartMode((int) $matches[1]);
$proxy->setNextPartFilename($matches[2]);
return $this;
}
$proxy->setStreamPartAndContentEndPos(\ftell($handle));
}
return $this;
}
public function parseContent(ParserPartProxy $proxy) : static
{
$handle = $proxy->getMessageResourceHandle();
if ($proxy->getNextPartStart() !== null || \feof($handle)) {
return $this;
}
if ($proxy->getStreamContentStartPos() === null) {
$proxy->setStreamContentStartPos(\ftell($handle));
}
$this->parseNextPart($proxy);
return $this;
}
public function parseNextChild(ParserMimePartProxy $proxy) : ?ParserPartProxy
{
$handle = $proxy->getMessageResourceHandle();
if ($proxy->getNextPartStart() === null || \feof($handle)) {
return null;
}
$child = $this->createPart($proxy);
$proxy->clearNextPart();
return $child;
}
}

View File

@@ -0,0 +1,92 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Parser;
use ZBateson\MailMimeParser\Parser\Proxy\ParserPartProxy;
/**
* Manages a prioritized list of IParser objects for parsing messages and parts
* and creating proxied parts.
*
* The default ParserManager sets up a MimeParser in priority 0, and a
* NonMimeParser in priority 1.
*
* @author Zaahid Bateson
*/
class ParserManagerService
{
/**
* @var IParserService[] List of parsers in order of priority (0 is highest
* priority).
*/
protected array $parsers = [];
public function __construct(MimeParserService $mimeParser, NonMimeParserService $nonMimeParser)
{
$this->setParsers([$mimeParser, $nonMimeParser]);
}
/**
* Overrides the internal prioritized list of parses with the passed list,
* calling $parser->setParserManager($this) on each one.
*
* @param IParserService[] $parsers
*/
public function setParsers(array $parsers) : static
{
foreach ($parsers as $parser) {
$parser->setParserManager($this);
}
$this->parsers = $parsers;
return $this;
}
/**
* Adds an IParser at the highest priority (up front), calling
* $parser->setParserManager($this) on it.
*
* @param IParserService $parser The parser to add.
*/
public function prependParser(IParserService $parser) : static
{
$parser->setParserManager($this);
\array_unshift($this->parsers, $parser);
return $this;
}
/**
* Creates a ParserPartProxy for the passed $partBuilder using a compatible
* IParser.
*
* Loops through registered IParsers calling 'canParse()' on each with the
* passed PartBuilder, then calling either 'getParserMessageProxyFactory()'
* or 'getParserPartProxyFactory()' depending on if the PartBuilder has a
* parent, and finally calling 'newInstance' on the returned
* ParserPartProxyFactory passing it the IParser, and returning the new
* ParserPartProxy instance that was created.
*
* @param PartBuilder $partBuilder The PartBuilder to wrap in a proxy with
* an IParser
* @throws CompatibleParserNotFoundException if a compatible parser for the
* type is not configured.
* @return ParserPartProxy The created ParserPartProxy tied to a new
* IMessagePart and associated IParser.
*/
public function createParserProxyFor(PartBuilder $partBuilder) : ParserPartProxy
{
foreach ($this->parsers as $parser) {
if ($parser->canParse($partBuilder)) {
$factory = ($partBuilder->getParent() === null) ?
$parser->getParserMessageProxyFactory() :
$parser->getParserPartProxyFactory();
return $factory->newInstance($partBuilder, $parser);
}
}
throw new CompatibleParserNotFoundException('Compatible parser for a part cannot be found with content-type: ' . $partBuilder->getHeaderContainer()->get('Content-Type'));
}
}

View File

@@ -0,0 +1,53 @@
<?php
/**
* This file is part of the ZBateson\MailMimeParser project.
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/
namespace ZBateson\MailMimeParser\Parser\Part;
use ZBateson\MailMimeParser\Message\PartChildrenContainer;
use ZBateson\MailMimeParser\Parser\Proxy\ParserMimePartProxy;
/**
* A child container that proxies calls to a parser when attempting to access
* child parts.
*
* @author Zaahid Bateson
*/
class ParserPartChildrenContainer extends PartChildrenContainer
{
/**
* @var ParserMimePartProxy The parser to proxy requests to when trying to
* get child parts.
*/
protected ParserMimePartProxy $parserProxy;
/**
* @var bool Set to true once all parts have been parsed, and requests to
* the proxy won't result in any more child parts.
*/
private bool $allParsed = false;
public function __construct(ParserMimePartProxy $parserProxy)
{
parent::__construct([]);
$this->parserProxy = $parserProxy;
}
public function offsetExists($offset) : bool
{
$exists = parent::offsetExists($offset);
while (!$exists && !$this->allParsed) {
$child = $this->parserProxy->popNextChild();
if ($child === null) {
$this->allParsed = true;
} else {
$this->add($child);
}
$exists = parent::offsetExists($offset);
}
return $exists;
}
}

Some files were not shown because too many files have changed in this diff Show More