Skip to content

Commit 029afca

Browse files
[Mate] Add MailerCollectorFormatter for Symfony profiler
Add a new formatter that extracts email message details from Symfony's MessageDataCollector for AI consumption in the Mate profiler extension. The formatter extracts: - Subject, from, to, cc, bcc, reply_to addresses - Text body (truncated to 500 chars) and HTML body indicator - Attachment metadata (filename, content type) - Transport name and queued status
1 parent 6b766cb commit 029afca

File tree

8 files changed

+598
-4
lines changed

8 files changed

+598
-4
lines changed

src/mate/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
CHANGELOG
22
=========
33

4+
0.5
5+
---
6+
7+
* Add `MailerCollectorFormatter` to expose Symfony Mailer data (recipients, body preview, links, attachments, transport) to AI via the profiler
8+
9+
410
0.3
511
---
612

src/mate/composer.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,10 @@
4444
"phpstan/phpstan-phpunit": "^2.0",
4545
"phpstan/phpstan-strict-rules": "^2.0",
4646
"phpunit/phpunit": "^11.5.53",
47-
"symfony/dotenv": "^5.4|^6.4|^7.3|^8.0"
47+
"symfony/dotenv": "^5.4|^6.4|^7.3|^8.0",
48+
"symfony/http-kernel": "^5.4|^6.4|^7.3|^8.0",
49+
"symfony/mailer": "^5.4|^6.4|^7.3|^8.0",
50+
"symfony/mime": "^5.4|^6.4|^7.3|^8.0"
4851
},
4952
"minimum-stability": "dev",
5053
"autoload": {

src/mate/phpunit.xml.dist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
<testsuites>
1212
<testsuite name="Symony AI Mate Test Suite">
1313
<directory>tests</directory>
14+
<directory>src/Bridge/*/Tests</directory>
1415
</testsuite>
1516
</testsuites>
1617

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Mate\Bridge\Symfony\Profiler\Service\Formatter;
13+
14+
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Service\CollectorFormatterInterface;
15+
use Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface;
16+
use Symfony\Component\Mailer\DataCollector\MessageDataCollector;
17+
use Symfony\Component\Mime\Address;
18+
use Symfony\Component\Mime\Email;
19+
use Symfony\Component\String\UnicodeString;
20+
21+
/**
22+
* Formats Mailer collector data for AI consumption.
23+
*
24+
* Extracts email message details including recipients, subject,
25+
* body preview, attachments, and transport information.
26+
*
27+
* @author Johannes Wachter <johannes@sulu.io>
28+
*
29+
* @internal
30+
*
31+
* @implements CollectorFormatterInterface<MessageDataCollector>
32+
*/
33+
final class MailerCollectorFormatter implements CollectorFormatterInterface
34+
{
35+
private const MAX_BODY_LENGTH = 500;
36+
37+
public function getName(): string
38+
{
39+
return 'mailer';
40+
}
41+
42+
public function format(DataCollectorInterface $collector): array
43+
{
44+
\assert($collector instanceof MessageDataCollector);
45+
46+
$events = $collector->getEvents();
47+
$messages = [];
48+
49+
foreach ($events->getEvents() as $event) {
50+
$message = $event->getMessage();
51+
$messageData = [
52+
'transport' => $event->getTransport(),
53+
'is_queued' => $event->isQueued(),
54+
];
55+
56+
if ($message instanceof Email) {
57+
$messageData = array_merge($messageData, $this->formatEmail($message));
58+
} else {
59+
$messageData['type'] = $message::class;
60+
}
61+
62+
$messages[] = $messageData;
63+
}
64+
65+
return [
66+
'message_count' => \count($messages),
67+
'messages' => $messages,
68+
];
69+
}
70+
71+
public function getSummary(DataCollectorInterface $collector): array
72+
{
73+
\assert($collector instanceof MessageDataCollector);
74+
75+
$events = $collector->getEvents();
76+
$subjects = [];
77+
78+
foreach ($events->getEvents() as $event) {
79+
$message = $event->getMessage();
80+
if ($message instanceof Email) {
81+
$subjects[] = $message->getSubject() ?? '(no subject)';
82+
}
83+
}
84+
85+
return [
86+
'message_count' => \count($events->getEvents()),
87+
'subjects' => $subjects,
88+
];
89+
}
90+
91+
/**
92+
* @return array<string, mixed>
93+
*/
94+
private function formatEmail(Email $email): array
95+
{
96+
$textBody = $email->getTextBody();
97+
98+
return [
99+
'subject' => $email->getSubject(),
100+
'from' => $this->formatAddresses($email->getFrom()),
101+
'to' => $this->formatAddresses($email->getTo()),
102+
'cc' => $this->formatAddresses($email->getCc()),
103+
'bcc' => $this->formatAddresses($email->getBcc()),
104+
'reply_to' => $this->formatAddresses($email->getReplyTo()),
105+
'text_body' => null !== $textBody ? $this->truncateBody($textBody) : null,
106+
'links' => $this->extractLinks($textBody, $email->getHtmlBody()),
107+
'has_html_body' => null !== $email->getHtmlBody(),
108+
'attachments' => $this->formatAttachments($email),
109+
];
110+
}
111+
112+
/**
113+
* @param Address[] $addresses
114+
*
115+
* @return string[]
116+
*/
117+
private function formatAddresses(array $addresses): array
118+
{
119+
return array_map(
120+
static fn (Address $address): string => '' !== $address->getName()
121+
? \sprintf('%s <%s>', $address->getName(), $address->getAddress())
122+
: $address->getAddress(),
123+
$addresses
124+
);
125+
}
126+
127+
/**
128+
* @return string[]
129+
*/
130+
private function extractLinks(?string $textBody, ?string $htmlBody): array
131+
{
132+
$links = [];
133+
134+
if (null !== $textBody) {
135+
preg_match_all('/https?:\/\/[^\s<>"\']+/i', $textBody, $matches);
136+
$links = array_merge($links, $matches[0]);
137+
}
138+
139+
if (null !== $htmlBody) {
140+
preg_match_all('/href=["\']+(https?:\/\/[^"\']+)["\']/i', $htmlBody, $matches);
141+
$links = array_merge($links, $matches[1]);
142+
}
143+
144+
return array_values(array_unique($links));
145+
}
146+
147+
private function truncateBody(string $body): string
148+
{
149+
$unicode = new UnicodeString($body);
150+
151+
if ($unicode->length() <= self::MAX_BODY_LENGTH) {
152+
return $body;
153+
}
154+
155+
return $unicode->slice(0, self::MAX_BODY_LENGTH)->toString().'...';
156+
}
157+
158+
/**
159+
* @return array<array{filename: string|null, content_type: string|null}>
160+
*/
161+
private function formatAttachments(Email $email): array
162+
{
163+
$attachments = [];
164+
165+
foreach ($email->getAttachments() as $attachment) {
166+
// Symfony 8.0+ has dedicated getter methods, older versions need to extract from headers
167+
if (method_exists($attachment, 'getFilename')) {
168+
$filename = $attachment->getFilename();
169+
$contentType = $attachment->getContentType();
170+
} else {
171+
// Extract from headers for Symfony 5.4-7.x
172+
$headers = $attachment->getPreparedHeaders();
173+
174+
$disposition = $headers->get('Content-Disposition');
175+
$filename = $disposition && method_exists($disposition, 'getParameter')
176+
? $disposition->getParameter('filename')
177+
: null;
178+
179+
$contentTypeHeader = $headers->get('Content-Type');
180+
$contentType = $contentTypeHeader && method_exists($contentTypeHeader, 'getBody')
181+
? $contentTypeHeader->getBody()
182+
: null;
183+
}
184+
185+
$attachments[] = [
186+
'filename' => $filename,
187+
'content_type' => $contentType,
188+
];
189+
}
190+
191+
return $attachments;
192+
}
193+
}
Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
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
1+
abc123,127.0.0.1,GET,/api/users,1704448800,,200
2+
def456,192.168.1.1,POST,/api/users,1704448900,,201
3+
ghi789,127.0.0.1,GET,/api/posts,1704449000,,404
4+

0 commit comments

Comments
 (0)