[Mate][Symfony] Add MailerCollectorFormatter for Symfony profiler

This commit is contained in:
Johannes Wachter
2026-02-19 21:45:57 +01:00
committed by Oskar Stark
parent 6cc164caf3
commit 4f07d26dc5
14 changed files with 741 additions and 3 deletions

View File

@@ -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 ###

View File

@@ -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;
}
}

View 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 ###

View File

@@ -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",

View File

@@ -0,0 +1,3 @@
framework:
mailer:
dsn: '%env(MAILER_DSN)%'

View File

@@ -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;
}
}

View File

@@ -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"
},

View File

@@ -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"

View File

@@ -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
---

View File

@@ -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;
}
}

View File

@@ -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
1 abc123 127.0.0.1 GET /api/users 1704448800 200 request
2 def456 192.168.1.1 POST /api/users 1704448900 201 request
3 ghi789 127.0.0.1 GET /api/posts 1704449000 404 request
4

View File

@@ -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);
}
}

View File

@@ -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",

View File

@@ -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