Skip to content

Commit 6c401a3

Browse files
authored
Merge pull request #284 from buggregator/issue/281
Fix inline attachment processing with strategy pattern implementation
2 parents 99b9e6a + b3c59c0 commit 6c401a3

31 files changed

+3352
-1913
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,6 @@ protoc-gen-php-grpc*
1313
.db
1414
.sqlhistory
1515
*Zone.Identifier
16+
.context
17+
mcp-*
1618
.context

Makefile

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,12 @@ build:
55
mkdir -p runtime/configs; \
66
chmod 0777 -R runtime; \
77
fi
8-
chmod +x bin/get-binaries.sh; \
9-
if [ ! -f "bin/centrifugo" ]; then \
10-
cd bin; \
11-
./get-binaries.sh; \
12-
cd ../; \
13-
fi
148
if [ ! -d "vendor" ]; then \
159
composer i --ignore-platform-reqs; \
1610
fi
17-
if [ ! -f "rr" ]; then \
18-
vendor/bin/rr get;\
19-
fi
11+
if [ ! -f "bin/centrifugo" ] || [ ! -f "bin/dolt" ] || [ ! -f "rr" ]; then \
12+
vendor/bin/dload get; \
13+
fi
2014
if [ ! -d ".db" ]; then \
2115
mkdir .db; \
2216
chmod 0777 -R .db; \

app/modules/Profiler/Interfaces/Jobs/StoreProfileHandler.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ public function invoke(array $payload): void
4040

4141
$edges = &$event['edges'];
4242

43-
!\array_key_exists('main()', $edges) && \array_key_exists('value', $edges) and $edges['main()'] = $edges['value'];
43+
if (!\array_key_exists('main()', $edges) && \array_key_exists('value', $edges)) {
44+
$edges['main()'] = $edges['value'];
45+
}
4446
unset($edges['value']);
4547

4648
$batchSize = 0;
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Modules\Smtp\Application\Mail;
6+
7+
use Modules\Smtp\Application\Mail\Strategy\AttachmentProcessingStrategy;
8+
use ZBateson\MailMimeParser\Message\IMessagePart;
9+
10+
final readonly class AttachmentProcessor
11+
{
12+
public function __construct(
13+
private AttachmentProcessingStrategy $strategy,
14+
) {}
15+
16+
/**
17+
* Processes a message part into an Attachment object
18+
*/
19+
public function processAttachment(IMessagePart $part): Attachment
20+
{
21+
$filename = $this->strategy->generateFilename($part);
22+
$content = $part->getContent();
23+
$contentType = $part->getContentType();
24+
$contentId = $part->getContentId();
25+
26+
return new Attachment(
27+
filename: $filename,
28+
content: $content,
29+
type: $contentType,
30+
contentId: $contentId,
31+
);
32+
}
33+
34+
/**
35+
* Gets metadata about the attachment processing
36+
*/
37+
public function getMetadata(IMessagePart $part): array
38+
{
39+
return $this->strategy->extractMetadata($part);
40+
}
41+
42+
/**
43+
* Determines if the attachment should be stored inline
44+
*/
45+
public function shouldStoreInline(IMessagePart $part): bool
46+
{
47+
return $this->strategy->shouldStoreInline($part);
48+
}
49+
50+
/**
51+
* Gets the current strategy
52+
*/
53+
public function getStrategy(): AttachmentProcessingStrategy
54+
{
55+
return $this->strategy;
56+
}
57+
}

app/modules/Smtp/Application/Mail/Parser.php

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,20 @@
44

55
namespace Modules\Smtp\Application\Mail;
66

7+
use Modules\Smtp\Application\Mail\Strategy\AttachmentProcessorFactory;
8+
use Spiral\Exceptions\ExceptionReporterInterface;
79
use ZBateson\MailMimeParser\Header\AbstractHeader;
810
use ZBateson\MailMimeParser\Header\AddressHeader;
911
use ZBateson\MailMimeParser\Header\Part\AddressPart;
1012
use ZBateson\MailMimeParser\Message as ParseMessage;
1113

1214
final readonly class Parser
1315
{
16+
public function __construct(
17+
private ExceptionReporterInterface $reporter,
18+
private AttachmentProcessorFactory $processorFactory = new AttachmentProcessorFactory(),
19+
) {}
20+
1421
public function parse(string $body, array $allRecipients = []): Message
1522
{
1623
$message = ParseMessage::from($body, true);
@@ -62,12 +69,27 @@ public function parse(string $body, array $allRecipients = []): Message
6269
*/
6370
private function buildAttachmentFrom(array $attachments): array
6471
{
65-
return \array_map(fn(ParseMessage\IMessagePart $part) => new Attachment(
66-
$part->getFilename(),
67-
$part->getContent(),
68-
$part->getContentType(),
69-
$part->getContentId(),
70-
), $attachments);
72+
$result = [];
73+
74+
foreach ($attachments as $part) {
75+
try {
76+
$processor = $this->processorFactory->createProcessor($part);
77+
$attachment = $processor->processAttachment($part);
78+
$result[] = $attachment;
79+
} catch (\Throwable $e) {
80+
$this->reporter->report($e);
81+
// Create a fallback attachment
82+
$fallbackFilename = 'failed_attachment_' . uniqid() . '.bin';
83+
$result[] = new Attachment(
84+
filename: $fallbackFilename,
85+
content: $part->getContent() ?? '',
86+
type: $part->getContentType() ?? 'application/octet-stream',
87+
contentId: $part->getContentId(),
88+
);
89+
}
90+
}
91+
92+
return $result;
7193
}
7294

7395
/**
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Modules\Smtp\Application\Mail\Strategy;
6+
7+
use ZBateson\MailMimeParser\Message\IMessagePart;
8+
9+
interface AttachmentProcessingStrategy
10+
{
11+
/**
12+
* Determines if this strategy can handle the given message part
13+
*/
14+
public function canHandle(IMessagePart $part): bool;
15+
16+
/**
17+
* Generates a safe filename for the attachment
18+
*/
19+
public function generateFilename(IMessagePart $part): string;
20+
21+
/**
22+
* Extracts metadata from the message part
23+
*/
24+
public function extractMetadata(IMessagePart $part): array;
25+
26+
/**
27+
* Determines if the attachment should be stored inline
28+
*/
29+
public function shouldStoreInline(IMessagePart $part): bool;
30+
31+
/**
32+
* Gets the priority of this strategy (higher number = higher priority)
33+
*/
34+
public function getPriority(): int;
35+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Modules\Smtp\Application\Mail\Strategy;
6+
7+
use Modules\Smtp\Application\Mail\AttachmentProcessor;
8+
use ZBateson\MailMimeParser\Message\IMessagePart;
9+
10+
final class AttachmentProcessorFactory
11+
{
12+
/**
13+
* @param AttachmentProcessingStrategy[] $strategies
14+
*/
15+
public function __construct(
16+
private array $strategies = [
17+
new InlineAttachmentStrategy(),
18+
new RegularAttachmentStrategy(),
19+
new FallbackAttachmentStrategy(),
20+
],
21+
) {}
22+
23+
/**
24+
* Determines the appropriate strategy for processing the given message part
25+
*/
26+
public function determineStrategy(IMessagePart $part): AttachmentProcessingStrategy
27+
{
28+
$availableStrategies = \array_filter(
29+
$this->strategies,
30+
static fn(AttachmentProcessingStrategy $strategy) => $strategy->canHandle($part),
31+
);
32+
33+
if ($availableStrategies === []) {
34+
// This should never happen due to FallbackAttachmentStrategy
35+
throw new \RuntimeException('No strategy available to handle the message part');
36+
}
37+
38+
// Sort by priority (highest first)
39+
\usort($availableStrategies, fn($a, $b) => $b->getPriority() <=> $a->getPriority());
40+
41+
return $availableStrategies[0];
42+
}
43+
44+
/**
45+
* Creates a processor with the appropriate strategy for the given part
46+
*/
47+
public function createProcessor(IMessagePart $part): AttachmentProcessor
48+
{
49+
$strategy = $this->determineStrategy($part);
50+
return new AttachmentProcessor($strategy);
51+
}
52+
53+
/**
54+
* Registers a custom strategy
55+
*/
56+
public function registerStrategy(AttachmentProcessingStrategy $strategy): void
57+
{
58+
$this->strategies[] = $strategy;
59+
}
60+
61+
/**
62+
* Gets all registered strategies
63+
*
64+
* @return AttachmentProcessingStrategy[]
65+
*/
66+
public function getStrategies(): array
67+
{
68+
return $this->strategies;
69+
}
70+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Modules\Smtp\Application\Mail\Strategy;
6+
7+
use ZBateson\MailMimeParser\Message\IMessagePart;
8+
9+
final readonly class FallbackAttachmentStrategy implements AttachmentProcessingStrategy
10+
{
11+
public function canHandle(IMessagePart $part): bool
12+
{
13+
// Fallback strategy handles everything
14+
return true;
15+
}
16+
17+
public function generateFilename(IMessagePart $part): string
18+
{
19+
$originalFilename = $part->getFilename();
20+
$mimeType = $part->getContentType();
21+
$contentId = $part->getContentId();
22+
23+
// Try original filename first
24+
if ($originalFilename !== null && $originalFilename !== '' && $originalFilename !== '0') {
25+
return $this->sanitizeFilename($originalFilename);
26+
}
27+
28+
// Try content-id if available
29+
if ($contentId !== null && $contentId !== '' && $contentId !== '0') {
30+
$safeName = $this->sanitizeContentId($contentId);
31+
$extension = $this->getExtensionFromMimeType($mimeType);
32+
return $safeName . $extension;
33+
}
34+
35+
// Last resort: generate unique filename
36+
$baseName = 'unknown_attachment_' . uniqid();
37+
$extension = $this->getExtensionFromMimeType($mimeType);
38+
39+
return $baseName . $extension;
40+
}
41+
42+
public function extractMetadata(IMessagePart $part): array
43+
{
44+
return [
45+
'content_id' => $part->getContentId(),
46+
'is_inline' => $part->getContentDisposition() === 'inline',
47+
'disposition' => $part->getContentDisposition(),
48+
'original_filename' => $part->getFilename(),
49+
'fallback_used' => true,
50+
];
51+
}
52+
53+
public function shouldStoreInline(IMessagePart $part): bool
54+
{
55+
return $part->getContentDisposition() === 'inline';
56+
}
57+
58+
public function getPriority(): int
59+
{
60+
return 1; // Lowest priority - fallback only
61+
}
62+
63+
private function sanitizeFilename(string $filename): string
64+
{
65+
// Remove directory traversal attempts
66+
$filename = basename($filename);
67+
68+
// Replace problematic characters
69+
$filename = preg_replace('/[^\w\s\.-]/', '_', $filename);
70+
71+
// Replace multiple spaces or underscores with single underscore
72+
$filename = preg_replace('/[\s_]+/', '_', $filename);
73+
74+
// Remove leading/trailing underscores
75+
$filename = trim($filename, '_');
76+
77+
// Ensure we have a reasonable length
78+
if (strlen($filename) > 255) {
79+
$filename = substr($filename, 0, 255);
80+
}
81+
82+
// Fallback if filename becomes empty
83+
if ($filename === '' || $filename === '0') {
84+
$filename = 'fallback_' . uniqid() . '.bin';
85+
}
86+
87+
return $filename;
88+
}
89+
90+
private function sanitizeContentId(string $contentId): string
91+
{
92+
// Remove angle brackets if present
93+
$contentId = trim($contentId, '<>');
94+
95+
// Replace problematic characters with underscores
96+
$safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', $contentId);
97+
98+
// Remove multiple consecutive underscores
99+
$safeName = preg_replace('/_+/', '_', $safeName);
100+
101+
// Trim underscores from start and end
102+
$safeName = trim($safeName, '_');
103+
104+
// If empty or too short, generate a fallback
105+
if ($safeName === '' || $safeName === '0' || strlen($safeName) < 3) {
106+
$safeName = 'cid_' . uniqid();
107+
}
108+
109+
return $safeName;
110+
}
111+
112+
private function getExtensionFromMimeType(string $mimeType): string
113+
{
114+
$mimeType = strtolower($mimeType);
115+
116+
$extensions = [
117+
'image/jpeg' => '.jpg',
118+
'image/jpg' => '.jpg',
119+
'image/png' => '.png',
120+
'image/gif' => '.gif',
121+
'image/svg+xml' => '.svg',
122+
'application/pdf' => '.pdf',
123+
'text/plain' => '.txt',
124+
'text/html' => '.html',
125+
'application/zip' => '.zip',
126+
'application/json' => '.json',
127+
'application/xml' => '.xml',
128+
];
129+
130+
return $extensions[$mimeType] ?? '.bin';
131+
}
132+
}

0 commit comments

Comments
 (0)