diff --git a/src/Attachment.php b/src/Attachment.php index ddd50c9..06b5fc8 100644 --- a/src/Attachment.php +++ b/src/Attachment.php @@ -2,10 +2,12 @@ namespace DirectoryTree\ImapEngine; +use GuzzleHttp\Psr7\Utils; use Illuminate\Contracts\Support\Arrayable; use JsonSerializable; use Psr\Http\Message\StreamInterface; use Symfony\Component\Mime\MimeTypes; +use ZBateson\MailMimeParser\Message\IMessagePart; class Attachment implements Arrayable, JsonSerializable { @@ -20,6 +22,64 @@ public function __construct( protected StreamInterface $contentStream, ) {} + /** + * Get attachments from a parsed message. + * + * @return Attachment[] + */ + public static function parsed(MessageInterface $message): array + { + $attachments = []; + + foreach ($message->parse()->getAllAttachmentParts() as $part) { + if (static::isForwardedMessage($part)) { + $attachments = array_merge($attachments, (new FileMessage($part->getContent()))->attachments()); + } else { + $attachments[] = new Attachment( + $part->getFilename(), + $part->getContentId(), + $part->getContentType(), + $part->getContentDisposition(), + $part->getBinaryContentStream() ?? Utils::streamFor(''), + ); + } + } + + return $attachments; + } + + /** + * Get attachments from a message's body structure using lazy streams. + * + * @return Attachment[] + */ + public static function lazy(Message $message): array + { + $attachments = []; + + foreach ($message->bodyStructure(fetch: true)?->attachments() ?? [] as $part) { + $attachments[] = new Attachment( + $part->filename(), + $part->id(), + $part->contentType(), + $part->disposition()?->type()?->value, + new Support\LazyBodyPartStream($message, $part), + ); + } + + return $attachments; + } + + /** + * Determine if the attachment should be treated as an embedded forwarded message. + */ + protected static function isForwardedMessage(IMessagePart $part): bool + { + return empty($part->getFilename()) + && strtolower((string) $part->getContentType()) === 'message/rfc822' + && strtolower((string) $part->getContentDisposition()) !== 'attachment'; + } + /** * Get the attachment's filename. */ diff --git a/src/FileMessage.php b/src/FileMessage.php index 1360d07..b3b7cba 100644 --- a/src/FileMessage.php +++ b/src/FileMessage.php @@ -7,7 +7,7 @@ class FileMessage implements MessageInterface { - use HasFlags, HasParsedMessage; + use HasFlags, HasMessageAccessors, HasParsedMessage; /** * Constructor. diff --git a/src/HasMessageAccessors.php b/src/HasMessageAccessors.php new file mode 100644 index 0000000..3308968 --- /dev/null +++ b/src/HasMessageAccessors.php @@ -0,0 +1,177 @@ +header(HeaderConsts::DATE)) { + return null; + } + + if (! $header instanceof DateHeader) { + return null; + } + + if (! $date = $header->getDateTime()) { + return null; + } + + return Carbon::instance($date); + } + + /** + * Get the message's message-id. + */ + public function messageId(): ?string + { + return $this->header(HeaderConsts::MESSAGE_ID)?->getValue(); + } + + /** + * Get the message's subject. + */ + public function subject(): ?string + { + return $this->header(HeaderConsts::SUBJECT)?->getValue(); + } + + /** + * Get the FROM address. + */ + public function from(): ?Address + { + return head($this->addresses(HeaderConsts::FROM)) ?: null; + } + + /** + * Get the SENDER address. + */ + public function sender(): ?Address + { + return head($this->addresses(HeaderConsts::SENDER)) ?: null; + } + + /** + * Get the REPLY-TO address. + */ + public function replyTo(): ?Address + { + return head($this->addresses(HeaderConsts::REPLY_TO)) ?: null; + } + + /** + * Get the IN-REPLY-TO message identifier(s). + * + * @return string[] + */ + public function inReplyTo(): array + { + $parts = $this->header(HeaderConsts::IN_REPLY_TO)?->getParts() ?? []; + + $values = array_map(fn (IHeaderPart $part) => $part->getValue(), $parts); + + return array_values(array_filter($values)); + } + + /** + * Get the TO addresses. + * + * @return Address[] + */ + public function to(): array + { + return $this->addresses(HeaderConsts::TO); + } + + /** + * Get the CC addresses. + * + * @return Address[] + */ + public function cc(): array + { + return $this->addresses(HeaderConsts::CC); + } + + /** + * Get the BCC addresses. + * + * @return Address[] + */ + public function bcc(): array + { + return $this->addresses(HeaderConsts::BCC); + } + + /** + * Get the message's HTML content. + */ + public function html(): ?string + { + return $this->parse()->getHtmlContent(); + } + + /** + * Get the message's text content. + */ + public function text(): ?string + { + return $this->parse()->getTextContent(); + } + + /** + * Get the message's attachments. + * + * @return Attachment[] + */ + public function attachments(): array + { + return Attachment::parsed($this); + } + + /** + * Determine if the message has attachments. + */ + public function hasAttachments(): bool + { + return $this->attachmentCount() > 0; + } + + /** + * Get the count of attachments. + */ + public function attachmentCount(): int + { + return $this->parse()->getAttachmentCount(); + } +} diff --git a/src/HasParsedMessage.php b/src/HasParsedMessage.php index 014d92b..8ecbb17 100644 --- a/src/HasParsedMessage.php +++ b/src/HasParsedMessage.php @@ -2,19 +2,13 @@ namespace DirectoryTree\ImapEngine; -use Carbon\Carbon; -use Carbon\CarbonInterface; use DirectoryTree\ImapEngine\Exceptions\RuntimeException; -use GuzzleHttp\Psr7\Utils; -use ZBateson\MailMimeParser\Header\DateHeader; -use ZBateson\MailMimeParser\Header\HeaderConsts; use ZBateson\MailMimeParser\Header\IHeader; use ZBateson\MailMimeParser\Header\IHeaderPart; use ZBateson\MailMimeParser\Header\Part\AddressPart; use ZBateson\MailMimeParser\Header\Part\ContainerPart; use ZBateson\MailMimeParser\Header\Part\NameValuePart; use ZBateson\MailMimeParser\IMessage; -use ZBateson\MailMimeParser\Message\IMessagePart; trait HasParsedMessage { @@ -24,163 +18,19 @@ trait HasParsedMessage protected ?IMessage $message = null; /** - * Get the message date and time. - */ - public function date(): ?CarbonInterface - { - if (! $header = $this->header(HeaderConsts::DATE)) { - return null; - } - - if (! $header instanceof DateHeader) { - return null; - } - - if (! $date = $header->getDateTime()) { - return null; - } - - return Carbon::instance($date); - } - - /** - * Get the message's message-id. - */ - public function messageId(): ?string - { - return $this->header(HeaderConsts::MESSAGE_ID)?->getValue(); - } - - /** - * Get the message's subject. - */ - public function subject(): ?string - { - return $this->header(HeaderConsts::SUBJECT)?->getValue(); - } - - /** - * Get the FROM address. - */ - public function from(): ?Address - { - return head($this->addresses(HeaderConsts::FROM)) ?: null; - } - - /** - * Get the SENDER address. - */ - public function sender(): ?Address - { - return head($this->addresses(HeaderConsts::SENDER)) ?: null; - } - - /** - * Get the REPLY-TO address. - */ - public function replyTo(): ?Address - { - return head($this->addresses(HeaderConsts::REPLY_TO)) ?: null; - } - - /** - * Get the IN-REPLY-TO message identifier(s). - * - * @return string[] - */ - public function inReplyTo(): array - { - $parts = $this->header(HeaderConsts::IN_REPLY_TO)?->getParts() ?? []; - - $values = array_map(function (IHeaderPart $part) { - return $part->getValue(); - }, $parts); - - return array_values(array_filter($values)); - } - - /** - * Get the TO addresses. - * - * @return Address[] - */ - public function to(): array - { - return $this->addresses(HeaderConsts::TO); - } - - /** - * Get the CC addresses. - * - * @return Address[] - */ - public function cc(): array - { - return $this->addresses(HeaderConsts::CC); - } - - /** - * Get the BCC addresses. - * - * @return Address[] - */ - public function bcc(): array - { - return $this->addresses(HeaderConsts::BCC); - } - - /** - * Get the message's attachments. - * - * @return Attachment[] - */ - public function attachments(): array - { - $attachments = []; - - foreach ($this->parse()->getAllAttachmentParts() as $part) { - if ($this->isForwardedMessage($part)) { - $message = new FileMessage($part->getContent()); - - $attachments = array_merge($attachments, $message->attachments()); - } else { - $attachments[] = new Attachment( - $part->getFilename(), - $part->getContentId(), - $part->getContentType(), - $part->getContentDisposition(), - $part->getBinaryContentStream() ?? Utils::streamFor(''), - ); - } - } - - return $attachments; - } - - /** - * Determine if the message has attachments. - */ - public function hasAttachments(): bool - { - return $this->attachmentCount() > 0; - } - - /** - * Get the count of attachments. + * Get all headers from the message. */ - public function attachmentCount(): int + public function headers(): array { - return $this->parse()->getAttachmentCount(); + return $this->parse()->getAllHeaders(); } /** - * Determine if the attachment should be treated as an embedded forwarded message. + * Get a header from the message. */ - protected function isForwardedMessage(IMessagePart $part): bool + public function header(string $name, int $offset = 0): ?IHeader { - return empty($part->getFilename()) - && strtolower((string) $part->getContentType()) === 'message/rfc822' - && strtolower((string) $part->getContentDisposition()) !== 'attachment'; + return $this->parse()->getHeader($name, $offset); } /** @@ -202,38 +52,6 @@ public function addresses(string $header): array return array_filter($addresses); } - /** - * Get the message's HTML content. - */ - public function html(): ?string - { - return $this->parse()->getHtmlContent(); - } - - /** - * Get the message's text content. - */ - public function text(): ?string - { - return $this->parse()->getTextContent(); - } - - /** - * Get all headers from the message. - */ - public function headers(): array - { - return $this->parse()->getAllHeaders(); - } - - /** - * Get a header from the message. - */ - public function header(string $name, int $offset = 0): ?IHeader - { - return $this->parse()->getHeader($name, $offset); - } - /** * Parse the message into a MailMimeMessage instance. */ diff --git a/src/Message.php b/src/Message.php index 954a8e1..9938f9f 100644 --- a/src/Message.php +++ b/src/Message.php @@ -3,20 +3,25 @@ namespace DirectoryTree\ImapEngine; use BackedEnum; +use Carbon\Carbon; +use Carbon\CarbonInterface; use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData; use DirectoryTree\ImapEngine\Connection\Responses\MessageResponseParser; use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException; use DirectoryTree\ImapEngine\Support\Str; use Illuminate\Contracts\Support\Arrayable; use JsonSerializable; +use ZBateson\MailMimeParser\Header\DateHeader; +use ZBateson\MailMimeParser\Header\HeaderConsts; +use ZBateson\MailMimeParser\Header\IHeader; +use ZBateson\MailMimeParser\Header\IHeaderPart; +use ZBateson\MailMimeParser\Header\Part\AddressPart; +use ZBateson\MailMimeParser\Header\Part\ContainerPart; +use ZBateson\MailMimeParser\Header\Part\NameValuePart; class Message implements Arrayable, JsonSerializable, MessageInterface { - use HasFlags, HasParsedMessage { - text as protected getParsedText; - html as protected getParsedHtml; - attachments as protected getParsedAttachments; - } + use HasFlags, HasParsedMessage; /** * The parsed body structure. @@ -80,8 +85,12 @@ public function flags(): array /** * Get the message's raw headers. */ - public function head(): string + public function head(bool $fetch = false): string { + if (! $this->head && $fetch) { + $this->head = $this->fetchHead() ?? ''; + } + return $this->head; } @@ -112,13 +121,13 @@ public function hasBody(): bool /** * Get the message's body structure. */ - public function bodyStructure(bool $lazy = false): ?BodyStructureCollection + public function bodyStructure(bool $fetch = false): ?BodyStructureCollection { if ($this->bodyStructure) { return $this->bodyStructure; } - if (! $this->bodyStructureData && $lazy) { + if (! $this->bodyStructureData && $fetch) { $this->bodyStructureData = $this->fetchBodyStructureData(); } @@ -226,32 +235,179 @@ public function move(string $folder, bool $expunge = false): ?int } } + /** + * Get a header from the message. + */ + public function header(string $name, int $offset = 0, bool $fetch = false): ?IHeader + { + if ($fetch && ! $this->hasHead()) { + $this->head(fetch: true); + } + + if ($this->isEmpty()) { + return null; + } + + return $this->parse()->getHeader($name, $offset); + } + + /** + * Get the message date and time. + */ + public function date(bool $fetch = false): ?CarbonInterface + { + if (! $header = $this->header(HeaderConsts::DATE, fetch: $fetch)) { + return null; + } + + if (! $header instanceof DateHeader) { + return null; + } + + if (! $date = $header->getDateTime()) { + return null; + } + + return Carbon::instance($date); + } + + /** + * Get the message's message-id. + */ + public function messageId(bool $fetch = false): ?string + { + return $this->header(HeaderConsts::MESSAGE_ID, fetch: $fetch)?->getValue(); + } + + /** + * Get the message's subject. + */ + public function subject(bool $fetch = false): ?string + { + return $this->header(HeaderConsts::SUBJECT, fetch: $fetch)?->getValue(); + } + + /** + * Get the FROM address. + */ + public function from(bool $fetch = false): ?Address + { + return head($this->addresses(HeaderConsts::FROM, fetch: $fetch)) ?: null; + } + + /** + * Get the SENDER address. + */ + public function sender(bool $fetch = false): ?Address + { + return head($this->addresses(HeaderConsts::SENDER, fetch: $fetch)) ?: null; + } + + /** + * Get the REPLY-TO address. + */ + public function replyTo(bool $fetch = false): ?Address + { + return head($this->addresses(HeaderConsts::REPLY_TO, fetch: $fetch)) ?: null; + } + + /** + * Get the IN-REPLY-TO message identifier(s). + * + * @return string[] + */ + public function inReplyTo(bool $fetch = false): array + { + $parts = $this->header(HeaderConsts::IN_REPLY_TO, fetch: $fetch)?->getParts() ?? []; + + $values = array_map(fn (IHeaderPart $part) => $part->getValue(), $parts); + + return array_values(array_filter($values)); + } + + /** + * Get the TO addresses. + * + * @return Address[] + */ + public function to(bool $fetch = false): array + { + return $this->addresses(HeaderConsts::TO, fetch: $fetch); + } + + /** + * Get the CC addresses. + * + * @return Address[] + */ + public function cc(bool $fetch = false): array + { + return $this->addresses(HeaderConsts::CC, fetch: $fetch); + } + + /** + * Get the BCC addresses. + * + * @return Address[] + */ + public function bcc(bool $fetch = false): array + { + return $this->addresses(HeaderConsts::BCC, fetch: $fetch); + } + + /** + * Get addresses from the given header. + * + * @return Address[] + */ + public function addresses(string $header, bool $fetch = false): array + { + $parts = $this->header($header, fetch: $fetch)?->getParts() ?? []; + + $addresses = array_map(fn (IHeaderPart $part) => match (true) { + $part instanceof AddressPart => new Address($part->getEmail(), $part->getName()), + $part instanceof NameValuePart => new Address($part->getName(), $part->getValue()), + $part instanceof ContainerPart => new Address($part->getValue(), ''), + default => null, + }, $parts); + + return array_filter($addresses); + } + /** * Get the message's text content. */ - public function text(bool $lazy = false): ?string + public function text(bool $fetch = false): ?string { - if ($lazy && ! $this->hasBody()) { - if ($part = $this->bodyStructure(lazy: true)?->text()) { + if ($fetch && ! $this->hasBody()) { + if ($part = $this->bodyStructure(fetch: true)?->text()) { return Support\BodyPartDecoder::text($part, $this->bodyPart($part->partNumber())); } } - return $this->getParsedText(); + if ($this->isEmpty()) { + return null; + } + + return $this->parse()->getTextContent(); } /** * Get the message's HTML content. */ - public function html(bool $lazy = false): ?string + public function html(bool $fetch = false): ?string { - if ($lazy && ! $this->hasBody()) { - if ($part = $this->bodyStructure(lazy: true)?->html()) { + if ($fetch && ! $this->hasBody()) { + if ($part = $this->bodyStructure(fetch: true)?->html()) { return Support\BodyPartDecoder::text($part, $this->bodyPart($part->partNumber())); } } - return $this->getParsedHtml(); + if ($this->isEmpty()) { + return null; + } + + return $this->parse()->getHtmlContent(); } /** @@ -259,32 +415,37 @@ public function html(bool $lazy = false): ?string * * @return Attachment[] */ - public function attachments(bool $lazy = false): array + public function attachments(bool $fetch = false): array { - if ($lazy && ! $this->hasBody()) { - return $this->getLazyAttachments(); + if ($fetch && ! $this->hasBody()) { + return Attachment::lazy($this); } - return $this->getParsedAttachments(); + if ($this->isEmpty()) { + return []; + } + + return Attachment::parsed($this); } /** - * Get attachments using lazy loading from body structure. - * - * @return Attachment[] + * Determine if the message has attachments. */ - protected function getLazyAttachments(): array + public function hasAttachments(): bool { - return array_map( - fn (BodyStructurePart $part) => new Attachment( - $part->filename(), - $part->id(), - $part->contentType(), - $part->disposition()?->type()?->value, - new Support\LazyBodyPartStream($this, $part), - ), - $this->bodyStructure(lazy: true)?->attachments() ?? [] - ); + return $this->attachmentCount() > 0; + } + + /** + * Get the count of attachments. + */ + public function attachmentCount(): int + { + if ($this->isEmpty()) { + return 0; + } + + return $this->parse()->getAttachmentCount(); } /** @@ -366,6 +527,29 @@ public function isEmpty(): bool return ! $this->hasHead() && ! $this->hasBody(); } + /** + * Fetch the headers from the server. + */ + protected function fetchHead(): ?string + { + $response = $this->folder + ->mailbox() + ->connection() + ->bodyHeader($this->uid); + + if ($response->isEmpty()) { + return null; + } + + $data = $response->first()->tokenAt(3); + + if (! $data instanceof ListData) { + return null; + } + + return $data->lookup('[HEADER]')?->value; + } + /** * Fetch the body structure data from the server. */ diff --git a/src/Testing/FakeMessage.php b/src/Testing/FakeMessage.php index c9613c3..b78e8b6 100644 --- a/src/Testing/FakeMessage.php +++ b/src/Testing/FakeMessage.php @@ -5,13 +5,14 @@ use BackedEnum; use DirectoryTree\ImapEngine\BodyStructureCollection; use DirectoryTree\ImapEngine\HasFlags; +use DirectoryTree\ImapEngine\HasMessageAccessors; use DirectoryTree\ImapEngine\HasParsedMessage; use DirectoryTree\ImapEngine\MessageInterface; use DirectoryTree\ImapEngine\Support\Str; class FakeMessage implements MessageInterface { - use HasFlags, HasParsedMessage; + use HasFlags, HasMessageAccessors, HasParsedMessage; /** * Constructor. diff --git a/tests/Unit/MessageTest.php b/tests/Unit/MessageTest.php index 597cef2..2850b14 100644 --- a/tests/Unit/MessageTest.php +++ b/tests/Unit/MessageTest.php @@ -178,7 +178,7 @@ expect($unserializedMessage->size())->toBe(1024); }); -test('it lazy loads text content from body structure when body is not loaded', function () { +test('it fetches text content from body structure when body is not loaded', function () { $mailbox = Mailbox::make([ 'username' => 'foo', 'password' => 'bar', @@ -203,10 +203,10 @@ expect($message->hasBody())->toBeFalse(); expect($message->hasBodyStructure())->toBeTrue(); - expect($message->text(lazy: true))->toBe('Hello World!'); + expect($message->text(fetch: true))->toBe('Hello World!'); }); -test('it lazy loads html content from body structure when body is not loaded', function () { +test('it fetches html content from body structure when body is not loaded', function () { $mailbox = Mailbox::make([ 'username' => 'foo', 'password' => 'bar', @@ -231,7 +231,7 @@ expect($message->hasBody())->toBeFalse(); expect($message->hasBodyStructure())->toBeTrue(); - expect($message->html(lazy: true))->toBe('
Hello World!
'); + expect($message->html(fetch: true))->toBe('Hello World!
'); }); test('it decodes base64 encoded content when lazy loading', function () { @@ -259,7 +259,7 @@ $message = new Message($folder, 1, [], 'From: test@example.com', '', null, $bodyStructureData); - expect($message->text(lazy: true))->toBe('Hello World!'); + expect($message->text(fetch: true))->toBe('Hello World!'); }); test('it decodes quoted-printable encoded content when lazy loading', function () { @@ -287,7 +287,7 @@ $message = new Message($folder, 1, [], 'From: test@example.com', '', null, $bodyStructureData); - expect($message->text(lazy: true))->toBe('Hello World!'); + expect($message->text(fetch: true))->toBe('Hello World!'); }); test('it converts charset to utf-8 when lazy loading', function () { @@ -317,7 +317,7 @@ $message = new Message($folder, 1, [], 'From: test@example.com', '', null, $bodyStructureData); - expect($message->text(lazy: true))->toBe($originalContent); + expect($message->text(fetch: true))->toBe($originalContent); }); test('it uses parsed body when body is already loaded', function () { @@ -349,7 +349,7 @@ expect($message->text())->toBe('Hello from parsed body!'); }); -test('it lazy loads text from multipart message body structure', function () { +test('it fetches text from multipart message body structure', function () { $mailbox = Mailbox::make([ 'username' => 'foo', 'password' => 'bar', @@ -375,10 +375,10 @@ expect($message->hasBody())->toBeFalse(); expect($message->hasBodyStructure())->toBeTrue(); - expect($message->text(lazy: true))->toBe('Hello World!'); + expect($message->text(fetch: true))->toBe('Hello World!'); }); -test('it lazy loads html from multipart message body structure', function () { +test('it fetches html from multipart message body structure', function () { $mailbox = Mailbox::make([ 'username' => 'foo', 'password' => 'bar', @@ -402,7 +402,7 @@ $message = new Message($folder, 1, [], 'From: test@example.com', '', null, $bodyStructureData); - expect($message->html(lazy: true))->toBe('Hello World!
'); + expect($message->html(fetch: true))->toBe('Hello World!
'); }); test('it fetches body structure automatically when not preloaded', function () { @@ -435,7 +435,7 @@ expect($message->hasBodyStructure())->toBeFalse(); // This should automatically fetch body structure, then fetch and decode the text part - expect($message->text(lazy: true))->toBe('Hello World!'); + expect($message->text(fetch: true))->toBe('Hello World!'); }); test('it fetches body structure automatically for html when not preloaded', function () { @@ -462,10 +462,10 @@ $message = new Message($folder, 1, [], 'From: test@example.com', ''); expect($message->hasBodyStructure())->toBeFalse(); - expect($message->html(lazy: true))->toBe('Hello World!
'); + expect($message->html(fetch: true))->toBe('Hello World!
'); }); -test('it lazy loads attachments from body structure', function () { +test('it fetches attachments from body structure', function () { $mailbox = Mailbox::make([ 'username' => 'foo', 'password' => 'bar', @@ -494,7 +494,7 @@ expect($message->hasBody())->toBeFalse(); expect($message->hasBodyStructure())->toBeTrue(); - $attachments = $message->attachments(lazy: true); + $attachments = $message->attachments(fetch: true); expect($attachments)->toHaveCount(1); expect($attachments[0]->filename())->toBe('document.pdf'); @@ -503,3 +503,39 @@ // Content is fetched lazily when contents() is called expect($attachments[0]->contents())->toBe('Hello World!'); }); + +test('it fetches headers from server', function () { + $mailbox = Mailbox::make([ + 'username' => 'foo', + 'password' => 'bar', + ]); + + $headers = "Subject: Test Subject\r\nFrom: sender@example.com"; + + $mailbox->connect(ImapConnection::fake([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* 1 FETCH (UID 1 BODY[HEADER] {'.strlen($headers).'}', + $headers, + ')', + 'TAG2 OK FETCH completed', + ])); + + $folder = new Folder($mailbox, 'INBOX', [], '/'); + + // Create a message with just the UID - no headers or body + $message = new Message($folder, 1, [], '', ''); + + expect($message->hasHead())->toBeFalse(); + expect($message->hasBody())->toBeFalse(); + + // Without lazy, returns null because message is empty + expect($message->header('Subject'))->toBeNull(); + + // With lazy, fetches headers from server + $header = $message->header('Subject', fetch: true); + + expect($header)->not->toBeNull(); + expect($header->getValue())->toBe('Test Subject'); + expect($message->hasHead())->toBeTrue(); +});