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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 14 additions & 10 deletions src/Codeception/Module/Symfony/MailerAssertionsTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
use Symfony\Component\Mailer\Event\MessageEvents;
use Symfony\Component\Mailer\EventListener\MessageLoggerListener;
use Symfony\Component\Mailer\Test\Constraint as MailerConstraint;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\RawMessage;

use function array_key_last;

Expand Down Expand Up @@ -88,24 +88,21 @@ public function dontSeeEmailIsSent(): void
}

/**
* Returns the last sent email.
* Returns the last sent message or `null` if no message was sent.
* The return type is `RawMessage`, which covers both `Email` and `Message` objects.
* The function is based on `\Symfony\Component\Mailer\EventListener\MessageLoggerListener`, which means:
* If your app performs an HTTP redirect after sending the email, you need to suppress it using [stopFollowingRedirects()](#stopFollowingRedirects) first.
* See also: [grabSentEmails()](https://codeception.com/docs/modules/Symfony#grabSentEmails)
*
* ```php
* <?php
* $email = $I->grabLastSentEmail();
* $address = $email->getTo()[0];
* $I->assertSame('john_doe@example.com', $address->getAddress());
* $I->assertEmailHasHeader('To', $email);
* ```
*/
public function grabLastSentEmail(): ?Email
public function grabLastSentEmail(): ?RawMessage
{
/** @var Email[] $emails */
$emails = $this->getMessageMailerEvents()->getMessages();

return $emails ? $emails[array_key_last($emails)] : null;
return $this->grabLastSentRawMessage();
}

/**
Expand All @@ -119,7 +116,7 @@ public function grabLastSentEmail(): ?Email
* $emails = $I->grabSentEmails();
* ```
*
* @return \Symfony\Component\Mime\RawMessage[]
* @return RawMessage[]
*/
public function grabSentEmails(): array
{
Expand Down Expand Up @@ -161,6 +158,13 @@ public function getMailerEvent(int $index = 0, ?string $transport = null): ?Mess
return $this->getMessageMailerEvents()->getEvents($transport)[$index] ?? null;
}

protected function grabLastSentRawMessage(): ?RawMessage
{
$messages = $this->getMessageMailerEvents()->getMessages();

return $messages ? $messages[array_key_last($messages)] : null;
}

protected function getMessageMailerEvents(): MessageEvents
{
$logger = $this->grabCachedService(
Expand Down
93 changes: 45 additions & 48 deletions src/Codeception/Module/Symfony/MimeAssertionsTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,32 @@
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Constraint\LogicalNot;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\Message;
use Symfony\Component\Mime\RawMessage;
use Symfony\Component\Mime\Test\Constraint as MimeConstraint;

use function sprintf;

trait MimeAssertionsTrait
{
/**
* Verify that an email contains addresses with a [header](https://datatracker.ietf.org/doc/html/rfc4021)
* Verify that a message contains addresses with a [header](https://datatracker.ietf.org/doc/html/rfc4021)
* `$headerName` and its expected value `$expectedValue`.
* If the Email object is not specified, the last email sent is used instead.
* If no Message is specified, the last sent message is used instead.
*
* ```php
* <?php
* $I->assertEmailAddressContains('To', 'jane_doe@example.com');
* ```
*/
public function assertEmailAddressContains(string $headerName, string $expectedValue, ?Email $email = null): void
public function assertEmailAddressContains(string $headerName, string $expectedValue, ?Message $email = null): void
{
$email = $this->verifyEmailObject($email, __FUNCTION__);
$this->assertThat($email, new MimeConstraint\EmailAddressContains($headerName, $expectedValue));
$this->assertThat($this->getMessageOrFail($email, __FUNCTION__), new MimeConstraint\EmailAddressContains($headerName, $expectedValue));
}

/**
* Verify that an email has sent the specified number `$count` of attachments.
* If the Email object is not specified, the last email sent is used instead.
* Verify that an email has the specified number `$count` of attachments.
* If no Email is specified, the last sent email is used instead.
*
* ```php
* <?php
Expand All @@ -40,60 +41,56 @@ public function assertEmailAddressContains(string $headerName, string $expectedV
*/
public function assertEmailAttachmentCount(int $count, ?Email $email = null): void
{
$email = $this->verifyEmailObject($email, __FUNCTION__);
$this->assertThat($email, new MimeConstraint\EmailAttachmentCount($count));
$this->assertThat($this->getMessageOrFail($email, __FUNCTION__), new MimeConstraint\EmailAttachmentCount($count));
}

/**
* Verify that an email has a [header](https://datatracker.ietf.org/doc/html/rfc4021) `$headerName`.
* If the Email object is not specified, the last email sent is used instead.
* Verify that a message has a [header](https://datatracker.ietf.org/doc/html/rfc4021) `$headerName`.
* If no Message is specified, the last sent message is used instead.
*
* ```php
* <?php
* $I->assertEmailHasHeader('Bcc');
* ```
*/
public function assertEmailHasHeader(string $headerName, ?Email $email = null): void
public function assertEmailHasHeader(string $headerName, ?Message $email = null): void
{
$email = $this->verifyEmailObject($email, __FUNCTION__);
$this->assertThat($email, new MimeConstraint\EmailHasHeader($headerName));
$this->assertThat($this->getMessageOrFail($email, __FUNCTION__), new MimeConstraint\EmailHasHeader($headerName));
}

/**
* Verify that the [header](https://datatracker.ietf.org/doc/html/rfc4021)
* `$headerName` of an email is not the expected one `$expectedValue`.
* If the Email object is not specified, the last email sent is used instead.
* `$headerName` of a message is not the expected one `$expectedValue`.
* If no Message is specified, the last sent message is used instead.
*
* ```php
* <?php
* $I->assertEmailHeaderNotSame('To', 'john_doe@gmail.com');
* ```
*/
public function assertEmailHeaderNotSame(string $headerName, string $expectedValue, ?Email $email = null): void
public function assertEmailHeaderNotSame(string $headerName, string $expectedValue, ?Message $email = null): void
{
$email = $this->verifyEmailObject($email, __FUNCTION__);
$this->assertThat($email, new LogicalNot(new MimeConstraint\EmailHeaderSame($headerName, $expectedValue)));
$this->assertThat($this->getMessageOrFail($email, __FUNCTION__), new LogicalNot(new MimeConstraint\EmailHeaderSame($headerName, $expectedValue)));
}

/**
* Verify that the [header](https://datatracker.ietf.org/doc/html/rfc4021)
* `$headerName` of an email is the same as expected `$expectedValue`.
* If the Email object is not specified, the last email sent is used instead.
* `$headerName` of a message is the same as expected `$expectedValue`.
* If no Message is specified, the last sent message is used instead.
*
* ```php
* <?php
* $I->assertEmailHeaderSame('To', 'jane_doe@gmail.com');
* ```
*/
public function assertEmailHeaderSame(string $headerName, string $expectedValue, ?Email $email = null): void
public function assertEmailHeaderSame(string $headerName, string $expectedValue, ?Message $email = null): void
Copy link
Copy Markdown
Author

@krrico krrico May 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure whether it's better to use RawMessage as type hint everywhere and let the Symfony Mime Constraints used here run into the RuntimeException when they don't support RawMessage or Message.

{
$email = $this->verifyEmailObject($email, __FUNCTION__);
$this->assertThat($email, new MimeConstraint\EmailHeaderSame($headerName, $expectedValue));
$this->assertThat($this->getMessageOrFail($email, __FUNCTION__), new MimeConstraint\EmailHeaderSame($headerName, $expectedValue));
}

/**
* Verify that the HTML body of an email contains `$text`.
* If the Email object is not specified, the last email sent is used instead.
* If no Email is specified, the last sent email is used instead.
*
* ```php
* <?php
Expand All @@ -102,13 +99,12 @@ public function assertEmailHeaderSame(string $headerName, string $expectedValue,
*/
public function assertEmailHtmlBodyContains(string $text, ?Email $email = null): void
{
$email = $this->verifyEmailObject($email, __FUNCTION__);
$this->assertThat($email, new MimeConstraint\EmailHtmlBodyContains($text));
$this->assertThat($this->getMessageOrFail($email, __FUNCTION__), new MimeConstraint\EmailHtmlBodyContains($text));
}

/**
* Verify that the HTML body of an email does not contain a text `$text`.
* If the Email object is not specified, the last email sent is used instead.
* If no Email is specified, the last sent email is used instead.
*
* ```php
* <?php
Expand All @@ -117,28 +113,26 @@ public function assertEmailHtmlBodyContains(string $text, ?Email $email = null):
*/
public function assertEmailHtmlBodyNotContains(string $text, ?Email $email = null): void
{
$email = $this->verifyEmailObject($email, __FUNCTION__);
$this->assertThat($email, new LogicalNot(new MimeConstraint\EmailHtmlBodyContains($text)));
$this->assertThat($this->getMessageOrFail($email, __FUNCTION__), new LogicalNot(new MimeConstraint\EmailHtmlBodyContains($text)));
}

/**
* Verify that an email does not have a [header](https://datatracker.ietf.org/doc/html/rfc4021) `$headerName`.
* If the Email object is not specified, the last email sent is used instead.
* Verify that a message does not have a [header](https://datatracker.ietf.org/doc/html/rfc4021) `$headerName`.
* If no Message is specified, the last sent message is used instead.
*
* ```php
* <?php
* $I->assertEmailNotHasHeader('Bcc');
* ```
*/
public function assertEmailNotHasHeader(string $headerName, ?Email $email = null): void
public function assertEmailNotHasHeader(string $headerName, ?Message $email = null): void
{
$email = $this->verifyEmailObject($email, __FUNCTION__);
$this->assertThat($email, new LogicalNot(new MimeConstraint\EmailHasHeader($headerName)));
$this->assertThat($this->getMessageOrFail($email, __FUNCTION__), new LogicalNot(new MimeConstraint\EmailHasHeader($headerName)));
}

/**
* Verify the text body of an email contains a `$text`.
* If the Email object is not specified, the last email sent is used instead.
* If no Email is specified, the last sent email is used instead.
*
* ```php
* <?php
Expand All @@ -147,13 +141,12 @@ public function assertEmailNotHasHeader(string $headerName, ?Email $email = null
*/
public function assertEmailTextBodyContains(string $text, ?Email $email = null): void
{
$email = $this->verifyEmailObject($email, __FUNCTION__);
$this->assertThat($email, new MimeConstraint\EmailTextBodyContains($text));
$this->assertThat($this->getMessageOrFail($email, __FUNCTION__), new MimeConstraint\EmailTextBodyContains($text));
}

/**
* Verify that the text body of an email does not contain a `$text`.
* If the Email object is not specified, the last email sent is used instead.
* If no Email is specified, the last sent email is used instead.
*
* ```php
* <?php
Expand All @@ -162,19 +155,23 @@ public function assertEmailTextBodyContains(string $text, ?Email $email = null):
*/
public function assertEmailTextBodyNotContains(string $text, ?Email $email = null): void
{
$email = $this->verifyEmailObject($email, __FUNCTION__);
$this->assertThat($email, new LogicalNot(new MimeConstraint\EmailTextBodyContains($text)));
$this->assertThat($this->getMessageOrFail($email, __FUNCTION__), new LogicalNot(new MimeConstraint\EmailTextBodyContains($text)));
}

/**
* Returns the last email sent if $email is null. If no email has been sent it fails.
* Resolves a Message for assertion or fails the test.
*
* Uses the provided `$message` or retrieves the last sent message.
* Fails if no message is found, or if it is a plain RawMessage lacking the headers and structure required by Mime constraints.
*/
private function verifyEmailObject(?Email $email, string $function): Email
private function getMessageOrFail(?Message $message, string $caller): Message
{
$email = $email ?: $this->grabLastSentEmail();
$errorMsgTemplate = "There is no email to verify. An Email object was not specified when invoking '%s' and the application has not sent one.";
return $email ?? Assert::fail(
sprintf($errorMsgTemplate, $function)
);
$message ??= $this->grabLastSentRawMessage();

if (!$message instanceof Message) {
Assert::fail(sprintf("No message to verify for '%s'. None was provided or sent by the application.", $caller));
}

return $message;
}
}
39 changes: 33 additions & 6 deletions tests/MailerAssertionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Symfony\Component\Mailer\Event\MessageEvent;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\Message;
use Tests\Support\CodeceptTestCase;

final class MailerAssertionsTest extends CodeceptTestCase
Expand All @@ -27,6 +28,12 @@ public function testAssertEmailCount(): void
$this->assertEmailCount(1);
}

public function testAssertEmailCountWithMessage(): void
{
$this->client->request('GET', '/send-message');
$this->assertEmailCount(1);
}

public function testAssertEmailIsNotQueued(): void
{
$this->client->request('GET', '/send-email');
Expand Down Expand Up @@ -58,18 +65,40 @@ public function testGetMailerEvent(): void
$this->assertInstanceOf(MessageEvent::class, $this->getMailerEvent());
}

public function testGrabLastSentEmail(): void
public function testGrabLastSentEmailReturnsEmailInstance(): void
{
$this->client->request('GET', '/send-email');
$email = $this->grabLastSentEmail();
$this->assertInstanceOf(Email::class, $email);
$this->assertSame('jane_doe@example.com', $email->getTo()[0]->getAddress());
}

public function testGrabSentEmails(): void
public function testGrabLastSentEmailReturnsMessageInstance(): void
{
$this->client->request('GET', '/send-message');
$message = $this->grabLastSentEmail();
$this->assertInstanceOf(Message::class, $message);
$this->assertNotInstanceOf(Email::class, $message);
}

public function testGrabLastSentEmailReturnsNullWhenNoMessagesSent(): void
{
$this->assertNull($this->grabLastSentEmail());
}

public function testGrabSentEmailsWithEmailType(): void
{
$this->client->request('GET', '/send-email');
$this->assertCount(1, $this->grabSentEmails());
$emails = $this->grabSentEmails();
$this->assertCount(1, $emails);
$this->assertInstanceOf(Email::class, $emails[0]);
}

public function testGrabSentEmailsWithMessageType(): void
{
$this->client->request('GET', '/send-message');
$messages = $this->grabSentEmails();
$this->assertCount(1, $messages);
$this->assertInstanceOf(Message::class, $messages[0]);
}

public function testSeeEmailIsSent(): void
Expand All @@ -80,10 +109,8 @@ public function testSeeEmailIsSent(): void

public function testEdgeCases(): void
{
// No emails sent
$this->assertNull($this->grabLastSentEmail());

// Out of range index
$this->client->request('GET', '/send-email');
$this->assertNull($this->getMailerEvent(999));
}
Expand Down
Loading