mirror of
https://github.com/symfony/ai.git
synced 2026-03-23 23:42:18 +01:00
[Mate][Symfony] Add MailerCollectorFormatter for Symfony profiler
This commit is contained in:
committed by
Oskar Stark
parent
6cc164caf3
commit
4f07d26dc5
@@ -26,3 +26,7 @@ HUGGINGFACE_API_KEY=
|
||||
###> symfony/ai-open-ai-platform ###
|
||||
OPENAI_API_KEY=
|
||||
###< symfony/ai-open-ai-platform ###
|
||||
|
||||
###> symfony/mailer ###
|
||||
MAILER_DSN=null://null
|
||||
###< symfony/mailer ###
|
||||
|
||||
@@ -25,4 +25,42 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.share-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(20, 30, 20, 0.55);
|
||||
z-index: 1055;
|
||||
}
|
||||
|
||||
.share-overlay.is-open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.share-panel {
|
||||
position: relative;
|
||||
width: min(520px, 92vw);
|
||||
padding: 24px 24px 20px;
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.share-close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 12px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 16px;
|
||||
text-align: center;
|
||||
line-height: 32px;
|
||||
text-decoration: none;
|
||||
color: #2f3f2f;
|
||||
background: #f0f4ef;
|
||||
}
|
||||
}
|
||||
|
||||
12
demo/compose.override.yaml
Normal file
12
demo/compose.override.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
|
||||
services:
|
||||
###> symfony/mailer ###
|
||||
mailer:
|
||||
image: axllent/mailpit
|
||||
ports:
|
||||
- "1025"
|
||||
- "8025"
|
||||
environment:
|
||||
MP_SMTP_AUTH_ACCEPT_ANY: 1
|
||||
MP_SMTP_AUTH_ALLOW_INSECURE: 1
|
||||
###< symfony/mailer ###
|
||||
@@ -31,6 +31,7 @@
|
||||
"symfony/form": "^8.0",
|
||||
"symfony/framework-bundle": "^8.0",
|
||||
"symfony/http-client": "^8.0",
|
||||
"symfony/mailer": "^8.0",
|
||||
"symfony/mcp-bundle": "^0.5",
|
||||
"symfony/mime": "^8.0",
|
||||
"symfony/monolog-bundle": "^4.0",
|
||||
|
||||
3
demo/config/packages/mailer.yaml
Normal file
3
demo/config/packages/mailer.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
framework:
|
||||
mailer:
|
||||
dsn: '%env(MAILER_DSN)%'
|
||||
@@ -12,6 +12,8 @@
|
||||
namespace App\Recipe;
|
||||
|
||||
use App\Recipe\Data\Recipe;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
|
||||
use Symfony\UX\LiveComponent\Attribute\LiveAction;
|
||||
use Symfony\UX\LiveComponent\Attribute\LiveProp;
|
||||
@@ -25,8 +27,15 @@ final class TwigComponent
|
||||
#[LiveProp(writable: true)]
|
||||
public ?string $message = null;
|
||||
|
||||
#[LiveProp(writable: true)]
|
||||
public ?string $shareEmail = null;
|
||||
|
||||
#[LiveProp]
|
||||
public bool $shareOpen = false;
|
||||
|
||||
public function __construct(
|
||||
private readonly Chat $chat,
|
||||
private readonly MailerInterface $mailer,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -56,4 +65,46 @@ final class TwigComponent
|
||||
{
|
||||
$this->chat->reset();
|
||||
}
|
||||
|
||||
#[LiveAction]
|
||||
public function openShare(): void
|
||||
{
|
||||
$this->shareOpen = true;
|
||||
}
|
||||
|
||||
#[LiveAction]
|
||||
public function closeShare(): void
|
||||
{
|
||||
$this->shareOpen = false;
|
||||
}
|
||||
|
||||
#[LiveAction]
|
||||
public function share(): void
|
||||
{
|
||||
if (null === $this->shareEmail || '' === trim($this->shareEmail)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (false === filter_var($this->shareEmail, \FILTER_VALIDATE_EMAIL)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$recipe = $this->getRecipe();
|
||||
if (null === $recipe) {
|
||||
return;
|
||||
}
|
||||
|
||||
$body = $recipe->toString().\PHP_EOL.\PHP_EOL.'Generated by https://github.com/symfony/ai-demo';
|
||||
|
||||
$email = (new Email())
|
||||
->from('noreply@symfony-ai.demo')
|
||||
->to($this->shareEmail)
|
||||
->subject(\sprintf('Recipe: %s', $recipe->name))
|
||||
->text($body);
|
||||
|
||||
$this->mailer->send($email);
|
||||
|
||||
$this->shareOpen = false;
|
||||
$this->shareEmail = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +215,18 @@
|
||||
"src/Kernel.php"
|
||||
]
|
||||
},
|
||||
"symfony/mailer": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "4.3",
|
||||
"ref": "09051cfde49476e3c12cd3a0e44289ace1c75a4f"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/mailer.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/mcp-bundle": {
|
||||
"version": "dev-main"
|
||||
},
|
||||
|
||||
@@ -72,6 +72,13 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="mt-4 border-top pt-3 d-flex align-items-center justify-content-between">
|
||||
<div class="fw-semibold text-muted">Share this recipe</div>
|
||||
<button class="btn btn-outline-secondary" {{ live_action('openShare') }}>
|
||||
{{ ux_icon('mdi:email-send', { height: '20px', width: '20px', class: 'me-1' }) }}
|
||||
Share
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,6 +99,27 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="recipe-share-overlay" class="share-overlay {{ this.shareOpen ? 'is-open' : '' }}">
|
||||
<div class="share-panel">
|
||||
<button class="share-close" {{ live_action('closeShare') }} aria-label="Close">×</button>
|
||||
<h4 class="mb-2">
|
||||
{{ ux_icon('mdi:email-send', { height: '22px', width: '22px', class: 'me-2' }) }}
|
||||
Share this recipe
|
||||
</h4>
|
||||
<p class="text-muted mb-3">Send the recipe to an email address.</p>
|
||||
<form class="input-group" {{ live_action('share:prevent') }}>
|
||||
<input
|
||||
type="email"
|
||||
class="form-control"
|
||||
placeholder="Email address"
|
||||
data-model="shareEmail"
|
||||
>
|
||||
<button class="btn btn-outline-secondary">
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer p-2">
|
||||
<form class="input-group" {{ live_action('submit:prevent') }}>
|
||||
<input autofocus id="chat-message"
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
CHANGELOG
|
||||
=========
|
||||
|
||||
0.6
|
||||
---
|
||||
|
||||
* Add `MailerCollectorFormatter` to expose Symfony Mailer data (recipients, body preview, links, attachments, transport) to AI via the profiler
|
||||
|
||||
0.3
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Mate\Bridge\Symfony\Profiler\Service\Formatter;
|
||||
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Service\CollectorFormatterInterface;
|
||||
use Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface;
|
||||
use Symfony\Component\Mailer\DataCollector\MessageDataCollector;
|
||||
use Symfony\Component\Mime\Address;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Symfony\Component\String\UnicodeString;
|
||||
|
||||
/**
|
||||
* Formats Mailer collector data for AI consumption.
|
||||
*
|
||||
* Extracts email message details including recipients, subject,
|
||||
* body preview, attachments, and transport information.
|
||||
*
|
||||
* @author Johannes Wachter <johannes@sulu.io>
|
||||
*
|
||||
* @internal
|
||||
*
|
||||
* @implements CollectorFormatterInterface<MessageDataCollector>
|
||||
*/
|
||||
final class MailerCollectorFormatter implements CollectorFormatterInterface
|
||||
{
|
||||
private const MAX_BODY_LENGTH = 500;
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'mailer';
|
||||
}
|
||||
|
||||
public function format(DataCollectorInterface $collector): array
|
||||
{
|
||||
\assert($collector instanceof MessageDataCollector);
|
||||
|
||||
$events = $collector->getEvents();
|
||||
$messages = [];
|
||||
|
||||
foreach ($events->getEvents() as $event) {
|
||||
$message = $event->getMessage();
|
||||
$messageData = [
|
||||
'transport' => $event->getTransport(),
|
||||
'is_queued' => $event->isQueued(),
|
||||
];
|
||||
|
||||
if ($message instanceof Email) {
|
||||
$messageData = array_merge($messageData, $this->formatEmail($message));
|
||||
} else {
|
||||
$messageData['type'] = $message::class;
|
||||
}
|
||||
|
||||
$messages[] = $messageData;
|
||||
}
|
||||
|
||||
return [
|
||||
'message_count' => \count($messages),
|
||||
'messages' => $messages,
|
||||
];
|
||||
}
|
||||
|
||||
public function getSummary(DataCollectorInterface $collector): array
|
||||
{
|
||||
\assert($collector instanceof MessageDataCollector);
|
||||
|
||||
$events = $collector->getEvents();
|
||||
$subjects = [];
|
||||
|
||||
foreach ($events->getEvents() as $event) {
|
||||
$message = $event->getMessage();
|
||||
if ($message instanceof Email) {
|
||||
$subjects[] = $message->getSubject() ?? '(no subject)';
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'message_count' => \count($events->getEvents()),
|
||||
'subjects' => $subjects,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatEmail(Email $email): array
|
||||
{
|
||||
$textBody = $email->getTextBody();
|
||||
|
||||
return [
|
||||
'subject' => $email->getSubject(),
|
||||
'from' => $this->formatAddresses($email->getFrom()),
|
||||
'to' => $this->formatAddresses($email->getTo()),
|
||||
'cc' => $this->formatAddresses($email->getCc()),
|
||||
'bcc' => $this->formatAddresses($email->getBcc()),
|
||||
'reply_to' => $this->formatAddresses($email->getReplyTo()),
|
||||
'text_body' => null !== $textBody ? $this->truncateBody($textBody) : null,
|
||||
'links' => $this->extractLinks($textBody, $email->getHtmlBody()),
|
||||
'has_html_body' => null !== $email->getHtmlBody(),
|
||||
'attachments' => $this->formatAttachments($email),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Address[] $addresses
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private function formatAddresses(array $addresses): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (Address $address): string => '' !== $address->getName()
|
||||
? \sprintf('%s <%s>', $address->getName(), $address->getAddress())
|
||||
: $address->getAddress(),
|
||||
$addresses
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function extractLinks(?string $textBody, ?string $htmlBody): array
|
||||
{
|
||||
$links = [];
|
||||
|
||||
if (null !== $textBody) {
|
||||
preg_match_all('/https?:\/\/[^\s<>"\']+/i', $textBody, $matches);
|
||||
$links = array_merge($links, $matches[0]);
|
||||
}
|
||||
|
||||
if (null !== $htmlBody) {
|
||||
preg_match_all('/href=["\']+(https?:\/\/[^"\']+)["\']/i', $htmlBody, $matches);
|
||||
$links = array_merge($links, $matches[1]);
|
||||
}
|
||||
|
||||
return array_values(array_unique($links));
|
||||
}
|
||||
|
||||
private function truncateBody(string $body): string
|
||||
{
|
||||
$unicode = new UnicodeString($body);
|
||||
|
||||
if ($unicode->length() <= self::MAX_BODY_LENGTH) {
|
||||
return $body;
|
||||
}
|
||||
|
||||
return $unicode->slice(0, self::MAX_BODY_LENGTH)->toString().'...';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array{filename: string|null, content_type: string|null}>
|
||||
*/
|
||||
private function formatAttachments(Email $email): array
|
||||
{
|
||||
$attachments = [];
|
||||
|
||||
foreach ($email->getAttachments() as $attachment) {
|
||||
// Symfony 8.0+ has dedicated getter methods, older versions need to extract from headers
|
||||
if (method_exists($attachment, 'getFilename')) {
|
||||
$filename = $attachment->getFilename();
|
||||
$contentType = $attachment->getContentType();
|
||||
} else {
|
||||
// Extract from headers for Symfony 5.4-7.x
|
||||
$headers = $attachment->getPreparedHeaders();
|
||||
|
||||
$disposition = $headers->get('Content-Disposition');
|
||||
$filename = $disposition && method_exists($disposition, 'getParameter')
|
||||
? $disposition->getParameter('filename')
|
||||
: null;
|
||||
|
||||
$contentTypeHeader = $headers->get('Content-Type');
|
||||
$contentType = $contentTypeHeader && method_exists($contentTypeHeader, 'getBody')
|
||||
? $contentTypeHeader->getBody()
|
||||
: null;
|
||||
}
|
||||
|
||||
$attachments[] = [
|
||||
'filename' => $filename,
|
||||
'content_type' => $contentType,
|
||||
];
|
||||
}
|
||||
|
||||
return $attachments;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
abc123,127.0.0.1,GET,/api/users,1704448800,,200,request
|
||||
def456,192.168.1.1,POST,/api/users,1704448900,,201,request
|
||||
ghi789,127.0.0.1,GET,/api/posts,1704449000,,404,request
|
||||
abc123,127.0.0.1,GET,/api/users,1704448800,,200
|
||||
def456,192.168.1.1,POST,/api/users,1704448900,,201
|
||||
ghi789,127.0.0.1,GET,/api/posts,1704449000,,404
|
||||
|
||||
|
||||
|
@@ -0,0 +1,381 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Mate\Bridge\Symfony\Tests\Profiler\Service\Formatter;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Service\Formatter\MailerCollectorFormatter;
|
||||
use Symfony\Component\Mailer\DataCollector\MessageDataCollector;
|
||||
use Symfony\Component\Mailer\Envelope;
|
||||
use Symfony\Component\Mailer\Event\MessageEvent;
|
||||
use Symfony\Component\Mailer\Event\MessageEvents;
|
||||
use Symfony\Component\Mailer\EventListener\MessageLoggerListener;
|
||||
use Symfony\Component\Mime\Address;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Symfony\Component\Mime\RawMessage;
|
||||
|
||||
/**
|
||||
* @author Johannes Wachter <johannes@sulu.io>
|
||||
*/
|
||||
final class MailerCollectorFormatterTest extends TestCase
|
||||
{
|
||||
private MailerCollectorFormatter $formatter;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->formatter = new MailerCollectorFormatter();
|
||||
}
|
||||
|
||||
public function testGetName()
|
||||
{
|
||||
$this->assertSame('mailer', $this->formatter->getName());
|
||||
}
|
||||
|
||||
public function testFormatWithNoMessages()
|
||||
{
|
||||
$collector = $this->createCollectorWithMessages([]);
|
||||
|
||||
$result = $this->formatter->format($collector);
|
||||
|
||||
$this->assertSame(0, $result['message_count']);
|
||||
$this->assertSame([], $result['messages']);
|
||||
}
|
||||
|
||||
public function testFormatWithSingleEmail()
|
||||
{
|
||||
$email = (new Email())
|
||||
->from(new Address('sender@example.com', 'John Sender'))
|
||||
->to(new Address('recipient@example.com', 'Jane Recipient'))
|
||||
->subject('Test Subject')
|
||||
->text('This is the body text.');
|
||||
|
||||
$collector = $this->createCollectorWithMessages([
|
||||
$this->createMessageEvent($email, 'smtp', false),
|
||||
]);
|
||||
|
||||
$result = $this->formatter->format($collector);
|
||||
|
||||
$this->assertSame(1, $result['message_count']);
|
||||
$this->assertCount(1, $result['messages']);
|
||||
|
||||
$message = $result['messages'][0];
|
||||
$this->assertSame('smtp', $message['transport']);
|
||||
$this->assertFalse($message['is_queued']);
|
||||
$this->assertSame('Test Subject', $message['subject']);
|
||||
$this->assertSame(['John Sender <sender@example.com>'], $message['from']);
|
||||
$this->assertSame(['Jane Recipient <recipient@example.com>'], $message['to']);
|
||||
$this->assertSame([], $message['cc']);
|
||||
$this->assertSame([], $message['bcc']);
|
||||
$this->assertSame([], $message['reply_to']);
|
||||
$this->assertSame('This is the body text.', $message['text_body']);
|
||||
$this->assertFalse($message['has_html_body']);
|
||||
$this->assertSame([], $message['attachments']);
|
||||
}
|
||||
|
||||
public function testFormatWithQueuedEmail()
|
||||
{
|
||||
$email = (new Email())
|
||||
->from('sender@example.com')
|
||||
->to('recipient@example.com')
|
||||
->subject('Queued Email');
|
||||
|
||||
$collector = $this->createCollectorWithMessages([
|
||||
$this->createMessageEvent($email, 'async', true),
|
||||
]);
|
||||
|
||||
$result = $this->formatter->format($collector);
|
||||
|
||||
$this->assertTrue($result['messages'][0]['is_queued']);
|
||||
$this->assertSame('async', $result['messages'][0]['transport']);
|
||||
}
|
||||
|
||||
public function testFormatWithHtmlBody()
|
||||
{
|
||||
$email = (new Email())
|
||||
->from('sender@example.com')
|
||||
->to('recipient@example.com')
|
||||
->subject('HTML Email')
|
||||
->html('<h1>Hello World</h1>');
|
||||
|
||||
$collector = $this->createCollectorWithMessages([
|
||||
$this->createMessageEvent($email, 'smtp', false),
|
||||
]);
|
||||
|
||||
$result = $this->formatter->format($collector);
|
||||
|
||||
$this->assertNull($result['messages'][0]['text_body']);
|
||||
$this->assertTrue($result['messages'][0]['has_html_body']);
|
||||
}
|
||||
|
||||
public function testFormatWithAllRecipientTypes()
|
||||
{
|
||||
$email = (new Email())
|
||||
->from('sender@example.com')
|
||||
->to('to@example.com')
|
||||
->cc('cc@example.com')
|
||||
->bcc('bcc@example.com')
|
||||
->replyTo('reply@example.com')
|
||||
->subject('Full Recipients');
|
||||
|
||||
$collector = $this->createCollectorWithMessages([
|
||||
$this->createMessageEvent($email, 'smtp', false),
|
||||
]);
|
||||
|
||||
$result = $this->formatter->format($collector);
|
||||
|
||||
$message = $result['messages'][0];
|
||||
$this->assertSame(['sender@example.com'], $message['from']);
|
||||
$this->assertSame(['to@example.com'], $message['to']);
|
||||
$this->assertSame(['cc@example.com'], $message['cc']);
|
||||
$this->assertSame(['bcc@example.com'], $message['bcc']);
|
||||
$this->assertSame(['reply@example.com'], $message['reply_to']);
|
||||
}
|
||||
|
||||
public function testFormatTruncatesLongBody()
|
||||
{
|
||||
$longBody = str_repeat('a', 600);
|
||||
$email = (new Email())
|
||||
->from('sender@example.com')
|
||||
->to('recipient@example.com')
|
||||
->subject('Long Body')
|
||||
->text($longBody);
|
||||
|
||||
$collector = $this->createCollectorWithMessages([
|
||||
$this->createMessageEvent($email, 'smtp', false),
|
||||
]);
|
||||
|
||||
$result = $this->formatter->format($collector);
|
||||
|
||||
$this->assertSame(503, mb_strlen($result['messages'][0]['text_body']));
|
||||
$this->assertStringEndsWith('...', $result['messages'][0]['text_body']);
|
||||
}
|
||||
|
||||
public function testFormatWithAttachments()
|
||||
{
|
||||
$email = (new Email())
|
||||
->from('sender@example.com')
|
||||
->to('recipient@example.com')
|
||||
->subject('With Attachment')
|
||||
->text('See attachment')
|
||||
->attach('file content', 'document.pdf', 'application/pdf');
|
||||
|
||||
$collector = $this->createCollectorWithMessages([
|
||||
$this->createMessageEvent($email, 'smtp', false),
|
||||
]);
|
||||
|
||||
$result = $this->formatter->format($collector);
|
||||
|
||||
$this->assertCount(1, $result['messages'][0]['attachments']);
|
||||
$this->assertSame('document.pdf', $result['messages'][0]['attachments'][0]['filename']);
|
||||
$this->assertSame('application/pdf', $result['messages'][0]['attachments'][0]['content_type']);
|
||||
}
|
||||
|
||||
public function testFormatExtractsLinksFromBody()
|
||||
{
|
||||
$email = (new Email())
|
||||
->from('sender@example.com')
|
||||
->to('recipient@example.com')
|
||||
->subject('Email with links')
|
||||
->text("Visit https://example.com and https://symfony.com for more info.\nSee also https://example.com again.");
|
||||
|
||||
$collector = $this->createCollectorWithMessages([
|
||||
$this->createMessageEvent($email, 'smtp', false),
|
||||
]);
|
||||
|
||||
$result = $this->formatter->format($collector);
|
||||
|
||||
$this->assertSame(
|
||||
['https://example.com', 'https://symfony.com'],
|
||||
$result['messages'][0]['links']
|
||||
);
|
||||
}
|
||||
|
||||
public function testFormatExtractsLinksFromHtmlBody()
|
||||
{
|
||||
$email = (new Email())
|
||||
->from('sender@example.com')
|
||||
->to('recipient@example.com')
|
||||
->subject('HTML only')
|
||||
->html('<a href="https://example.com">link</a> and <a href="https://symfony.com">another</a>');
|
||||
|
||||
$collector = $this->createCollectorWithMessages([
|
||||
$this->createMessageEvent($email, 'smtp', false),
|
||||
]);
|
||||
|
||||
$result = $this->formatter->format($collector);
|
||||
|
||||
$this->assertSame(
|
||||
['https://example.com', 'https://symfony.com'],
|
||||
$result['messages'][0]['links']
|
||||
);
|
||||
}
|
||||
|
||||
public function testFormatDeduplicatesLinksAcrossTextAndHtmlBody()
|
||||
{
|
||||
$email = (new Email())
|
||||
->from('sender@example.com')
|
||||
->to('recipient@example.com')
|
||||
->subject('Both bodies')
|
||||
->text('Visit https://example.com for info.')
|
||||
->html('<a href="https://example.com">link</a> <a href="https://symfony.com">other</a>');
|
||||
|
||||
$collector = $this->createCollectorWithMessages([
|
||||
$this->createMessageEvent($email, 'smtp', false),
|
||||
]);
|
||||
|
||||
$result = $this->formatter->format($collector);
|
||||
|
||||
$this->assertSame(
|
||||
['https://example.com', 'https://symfony.com'],
|
||||
$result['messages'][0]['links']
|
||||
);
|
||||
}
|
||||
|
||||
public function testFormatWithRawMessage()
|
||||
{
|
||||
$rawMessage = new RawMessage('raw email content');
|
||||
|
||||
$collector = $this->createCollectorWithMessages([
|
||||
$this->createMessageEvent($rawMessage, 'smtp', false),
|
||||
]);
|
||||
|
||||
$result = $this->formatter->format($collector);
|
||||
|
||||
$this->assertSame(1, $result['message_count']);
|
||||
$this->assertSame('smtp', $result['messages'][0]['transport']);
|
||||
$this->assertFalse($result['messages'][0]['is_queued']);
|
||||
$this->assertSame(RawMessage::class, $result['messages'][0]['type']);
|
||||
}
|
||||
|
||||
public function testFormatWithMultipleMessages()
|
||||
{
|
||||
$email1 = (new Email())
|
||||
->from('sender@example.com')
|
||||
->to('recipient1@example.com')
|
||||
->subject('First Email');
|
||||
|
||||
$email2 = (new Email())
|
||||
->from('sender@example.com')
|
||||
->to('recipient2@example.com')
|
||||
->subject('Second Email');
|
||||
|
||||
$collector = $this->createCollectorWithMessages([
|
||||
$this->createMessageEvent($email1, 'smtp', false),
|
||||
$this->createMessageEvent($email2, 'async', true),
|
||||
]);
|
||||
|
||||
$result = $this->formatter->format($collector);
|
||||
|
||||
$this->assertSame(2, $result['message_count']);
|
||||
$this->assertSame('First Email', $result['messages'][0]['subject']);
|
||||
$this->assertSame('Second Email', $result['messages'][1]['subject']);
|
||||
}
|
||||
|
||||
public function testGetSummaryWithNoMessages()
|
||||
{
|
||||
$collector = $this->createCollectorWithMessages([]);
|
||||
|
||||
$result = $this->formatter->getSummary($collector);
|
||||
|
||||
$this->assertSame(0, $result['message_count']);
|
||||
$this->assertSame([], $result['subjects']);
|
||||
}
|
||||
|
||||
public function testGetSummaryWithMessages()
|
||||
{
|
||||
$email1 = (new Email())
|
||||
->from('sender@example.com')
|
||||
->to('recipient@example.com')
|
||||
->subject('Welcome Email');
|
||||
|
||||
$email2 = (new Email())
|
||||
->from('sender@example.com')
|
||||
->to('recipient@example.com')
|
||||
->subject('Password Reset');
|
||||
|
||||
$collector = $this->createCollectorWithMessages([
|
||||
$this->createMessageEvent($email1, 'smtp', false),
|
||||
$this->createMessageEvent($email2, 'smtp', false),
|
||||
]);
|
||||
|
||||
$result = $this->formatter->getSummary($collector);
|
||||
|
||||
$this->assertSame(2, $result['message_count']);
|
||||
$this->assertSame(['Welcome Email', 'Password Reset'], $result['subjects']);
|
||||
}
|
||||
|
||||
public function testGetSummaryWithNoSubject()
|
||||
{
|
||||
$email = (new Email())
|
||||
->from('sender@example.com')
|
||||
->to('recipient@example.com');
|
||||
|
||||
$collector = $this->createCollectorWithMessages([
|
||||
$this->createMessageEvent($email, 'smtp', false),
|
||||
]);
|
||||
|
||||
$result = $this->formatter->getSummary($collector);
|
||||
|
||||
$this->assertSame(['(no subject)'], $result['subjects']);
|
||||
}
|
||||
|
||||
public function testGetSummaryExcludesRawMessages()
|
||||
{
|
||||
$email = (new Email())
|
||||
->from('sender@example.com')
|
||||
->to('recipient@example.com')
|
||||
->subject('Regular Email');
|
||||
|
||||
$rawMessage = new RawMessage('raw content');
|
||||
|
||||
$collector = $this->createCollectorWithMessages([
|
||||
$this->createMessageEvent($email, 'smtp', false),
|
||||
$this->createMessageEvent($rawMessage, 'smtp', false),
|
||||
]);
|
||||
|
||||
$result = $this->formatter->getSummary($collector);
|
||||
|
||||
$this->assertSame(2, $result['message_count']);
|
||||
$this->assertSame(['Regular Email'], $result['subjects']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param MessageEvent[] $events
|
||||
*/
|
||||
private function createCollectorWithMessages(array $events): MessageDataCollector
|
||||
{
|
||||
$messageEvents = new MessageEvents();
|
||||
foreach ($events as $event) {
|
||||
$messageEvents->add($event);
|
||||
}
|
||||
|
||||
$logger = $this->createMock(MessageLoggerListener::class);
|
||||
$logger->method('getEvents')->willReturn($messageEvents);
|
||||
|
||||
$collector = new MessageDataCollector($logger);
|
||||
$collector->collect(
|
||||
new \Symfony\Component\HttpFoundation\Request(),
|
||||
new \Symfony\Component\HttpFoundation\Response()
|
||||
);
|
||||
|
||||
return $collector;
|
||||
}
|
||||
|
||||
private function createMessageEvent(RawMessage $message, string $transport, bool $queued): MessageEvent
|
||||
{
|
||||
$envelope = new Envelope(
|
||||
new Address('sender@example.com'),
|
||||
[new Address('recipient@example.com')]
|
||||
);
|
||||
|
||||
return new MessageEvent($message, $envelope, $transport, $queued);
|
||||
}
|
||||
}
|
||||
@@ -37,11 +37,13 @@
|
||||
"phpstan/phpstan-strict-rules": "^2.0",
|
||||
"phpunit/phpunit": "^11.5.53",
|
||||
"symfony/http-kernel": "^7.3|^8.0",
|
||||
"symfony/mailer": "^7.3|^8.0",
|
||||
"symfony/var-dumper": "^7.3|^8.0",
|
||||
"symfony/web-profiler-bundle": "^7.3|^8.0"
|
||||
},
|
||||
"suggest": {
|
||||
"symfony/http-kernel": "Required for profiler data access tools",
|
||||
"symfony/mailer": "Required for mailer profiler formatter",
|
||||
"symfony/web-profiler-bundle": "Required for profiler data access tools"
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
|
||||
@@ -14,6 +14,7 @@ use Symfony\AI\Mate\Bridge\Symfony\Capability\ProfilerTool;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Capability\ServiceTool;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Service\CollectorRegistry;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Service\Formatter\ExceptionCollectorFormatter;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Service\Formatter\MailerCollectorFormatter;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Service\Formatter\RequestCollectorFormatter;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Service\ProfilerDataProvider;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Service\ContainerProvider;
|
||||
@@ -55,9 +56,15 @@ return static function (ContainerConfigurator $configurator) {
|
||||
|
||||
// Built-in collector formatters
|
||||
$services->set(RequestCollectorFormatter::class)
|
||||
->lazy()
|
||||
->tag('ai_mate.profiler_collector_formatter');
|
||||
|
||||
$services->set(ExceptionCollectorFormatter::class)
|
||||
->lazy()
|
||||
->tag('ai_mate.profiler_collector_formatter');
|
||||
|
||||
$services->set(MailerCollectorFormatter::class)
|
||||
->lazy()
|
||||
->tag('ai_mate.profiler_collector_formatter');
|
||||
|
||||
// MCP Capabilities
|
||||
|
||||
Reference in New Issue
Block a user