Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ protoc-gen-php-grpc*
.db
.sqlhistory
*Zone.Identifier
.context
mcp-*
.context
12 changes: 3 additions & 9 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,12 @@ build:
mkdir -p runtime/configs; \
chmod 0777 -R runtime; \
fi
chmod +x bin/get-binaries.sh; \
if [ ! -f "bin/centrifugo" ]; then \
cd bin; \
./get-binaries.sh; \
cd ../; \
fi
if [ ! -d "vendor" ]; then \
composer i --ignore-platform-reqs; \
fi
if [ ! -f "rr" ]; then \
vendor/bin/rr get;\
fi
if [ ! -f "bin/centrifugo" ] || [ ! -f "bin/dolt" ] || [ ! -f "rr" ]; then \
vendor/bin/dload get; \
fi
if [ ! -d ".db" ]; then \
mkdir .db; \
chmod 0777 -R .db; \
Expand Down
4 changes: 3 additions & 1 deletion app/modules/Profiler/Interfaces/Jobs/StoreProfileHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ public function invoke(array $payload): void

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

!\array_key_exists('main()', $edges) && \array_key_exists('value', $edges) and $edges['main()'] = $edges['value'];
if (!\array_key_exists('main()', $edges) && \array_key_exists('value', $edges)) {
$edges['main()'] = $edges['value'];
}
unset($edges['value']);

$batchSize = 0;
Expand Down
57 changes: 57 additions & 0 deletions app/modules/Smtp/Application/Mail/AttachmentProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace Modules\Smtp\Application\Mail;

use Modules\Smtp\Application\Mail\Strategy\AttachmentProcessingStrategy;
use ZBateson\MailMimeParser\Message\IMessagePart;

final readonly class AttachmentProcessor
{
public function __construct(
private AttachmentProcessingStrategy $strategy,
) {}

/**
* Processes a message part into an Attachment object
*/
public function processAttachment(IMessagePart $part): Attachment
{
$filename = $this->strategy->generateFilename($part);
$content = $part->getContent();
$contentType = $part->getContentType();
$contentId = $part->getContentId();

return new Attachment(
filename: $filename,
content: $content,
type: $contentType,
contentId: $contentId,
);
}

/**
* Gets metadata about the attachment processing
*/
public function getMetadata(IMessagePart $part): array
{
return $this->strategy->extractMetadata($part);
}

/**
* Determines if the attachment should be stored inline
*/
public function shouldStoreInline(IMessagePart $part): bool
{
return $this->strategy->shouldStoreInline($part);
}

/**
* Gets the current strategy
*/
public function getStrategy(): AttachmentProcessingStrategy
{
return $this->strategy;
}
}
34 changes: 28 additions & 6 deletions app/modules/Smtp/Application/Mail/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,20 @@

namespace Modules\Smtp\Application\Mail;

use Modules\Smtp\Application\Mail\Strategy\AttachmentProcessorFactory;
use Spiral\Exceptions\ExceptionReporterInterface;
use ZBateson\MailMimeParser\Header\AbstractHeader;
use ZBateson\MailMimeParser\Header\AddressHeader;
use ZBateson\MailMimeParser\Header\Part\AddressPart;
use ZBateson\MailMimeParser\Message as ParseMessage;

final readonly class Parser
{
public function __construct(
private ExceptionReporterInterface $reporter,
private AttachmentProcessorFactory $processorFactory = new AttachmentProcessorFactory(),
) {}

public function parse(string $body, array $allRecipients = []): Message
{
$message = ParseMessage::from($body, true);
Expand Down Expand Up @@ -62,12 +69,27 @@ public function parse(string $body, array $allRecipients = []): Message
*/
private function buildAttachmentFrom(array $attachments): array
{
return \array_map(fn(ParseMessage\IMessagePart $part) => new Attachment(
$part->getFilename(),
$part->getContent(),
$part->getContentType(),
$part->getContentId(),
), $attachments);
$result = [];

foreach ($attachments as $part) {
try {
$processor = $this->processorFactory->createProcessor($part);
$attachment = $processor->processAttachment($part);
$result[] = $attachment;
} catch (\Throwable $e) {
$this->reporter->report($e);
// Create a fallback attachment
$fallbackFilename = 'failed_attachment_' . uniqid() . '.bin';
$result[] = new Attachment(
filename: $fallbackFilename,
content: $part->getContent() ?? '',
type: $part->getContentType() ?? 'application/octet-stream',
contentId: $part->getContentId(),
);
}
}

return $result;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace Modules\Smtp\Application\Mail\Strategy;

use ZBateson\MailMimeParser\Message\IMessagePart;

interface AttachmentProcessingStrategy
{
/**
* Determines if this strategy can handle the given message part
*/
public function canHandle(IMessagePart $part): bool;

/**
* Generates a safe filename for the attachment
*/
public function generateFilename(IMessagePart $part): string;

/**
* Extracts metadata from the message part
*/
public function extractMetadata(IMessagePart $part): array;

/**
* Determines if the attachment should be stored inline
*/
public function shouldStoreInline(IMessagePart $part): bool;

/**
* Gets the priority of this strategy (higher number = higher priority)
*/
public function getPriority(): int;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

namespace Modules\Smtp\Application\Mail\Strategy;

use Modules\Smtp\Application\Mail\AttachmentProcessor;
use ZBateson\MailMimeParser\Message\IMessagePart;

final class AttachmentProcessorFactory
{
/**
* @param AttachmentProcessingStrategy[] $strategies
*/
public function __construct(
private array $strategies = [
new InlineAttachmentStrategy(),
new RegularAttachmentStrategy(),
new FallbackAttachmentStrategy(),
],
) {}

/**
* Determines the appropriate strategy for processing the given message part
*/
public function determineStrategy(IMessagePart $part): AttachmentProcessingStrategy
{
$availableStrategies = \array_filter(
$this->strategies,
static fn(AttachmentProcessingStrategy $strategy) => $strategy->canHandle($part),
);

if ($availableStrategies === []) {
// This should never happen due to FallbackAttachmentStrategy
throw new \RuntimeException('No strategy available to handle the message part');
}

// Sort by priority (highest first)
\usort($availableStrategies, fn($a, $b) => $b->getPriority() <=> $a->getPriority());

return $availableStrategies[0];
}

/**
* Creates a processor with the appropriate strategy for the given part
*/
public function createProcessor(IMessagePart $part): AttachmentProcessor
{
$strategy = $this->determineStrategy($part);
return new AttachmentProcessor($strategy);
}

/**
* Registers a custom strategy
*/
public function registerStrategy(AttachmentProcessingStrategy $strategy): void
{
$this->strategies[] = $strategy;
}

/**
* Gets all registered strategies
*
* @return AttachmentProcessingStrategy[]
*/
public function getStrategies(): array
{
return $this->strategies;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<?php

declare(strict_types=1);

namespace Modules\Smtp\Application\Mail\Strategy;

use ZBateson\MailMimeParser\Message\IMessagePart;

final readonly class FallbackAttachmentStrategy implements AttachmentProcessingStrategy
{
public function canHandle(IMessagePart $part): bool
{
// Fallback strategy handles everything
return true;
}

public function generateFilename(IMessagePart $part): string
{
$originalFilename = $part->getFilename();
$mimeType = $part->getContentType();
$contentId = $part->getContentId();

// Try original filename first
if ($originalFilename !== null && $originalFilename !== '' && $originalFilename !== '0') {
return $this->sanitizeFilename($originalFilename);
}

// Try content-id if available
if ($contentId !== null && $contentId !== '' && $contentId !== '0') {
$safeName = $this->sanitizeContentId($contentId);
$extension = $this->getExtensionFromMimeType($mimeType);
return $safeName . $extension;
}

// Last resort: generate unique filename
$baseName = 'unknown_attachment_' . uniqid();
$extension = $this->getExtensionFromMimeType($mimeType);

return $baseName . $extension;
}

public function extractMetadata(IMessagePart $part): array
{
return [
'content_id' => $part->getContentId(),
'is_inline' => $part->getContentDisposition() === 'inline',
'disposition' => $part->getContentDisposition(),
'original_filename' => $part->getFilename(),
'fallback_used' => true,
];
}

public function shouldStoreInline(IMessagePart $part): bool
{
return $part->getContentDisposition() === 'inline';
}

public function getPriority(): int
{
return 1; // Lowest priority - fallback only
}

private function sanitizeFilename(string $filename): string
{
// Remove directory traversal attempts
$filename = basename($filename);

// Replace problematic characters
$filename = preg_replace('/[^\w\s\.-]/', '_', $filename);

// Replace multiple spaces or underscores with single underscore
$filename = preg_replace('/[\s_]+/', '_', $filename);

// Remove leading/trailing underscores
$filename = trim($filename, '_');

// Ensure we have a reasonable length
if (strlen($filename) > 255) {
$filename = substr($filename, 0, 255);
}

// Fallback if filename becomes empty
if ($filename === '' || $filename === '0') {
$filename = 'fallback_' . uniqid() . '.bin';
}

return $filename;
}

private function sanitizeContentId(string $contentId): string
{
// Remove angle brackets if present
$contentId = trim($contentId, '<>');

// Replace problematic characters with underscores
$safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', $contentId);

// Remove multiple consecutive underscores
$safeName = preg_replace('/_+/', '_', $safeName);

// Trim underscores from start and end
$safeName = trim($safeName, '_');

// If empty or too short, generate a fallback
if ($safeName === '' || $safeName === '0' || strlen($safeName) < 3) {
$safeName = 'cid_' . uniqid();
}

return $safeName;
}

private function getExtensionFromMimeType(string $mimeType): string
{
$mimeType = strtolower($mimeType);

$extensions = [
'image/jpeg' => '.jpg',
'image/jpg' => '.jpg',
'image/png' => '.png',
'image/gif' => '.gif',
'image/svg+xml' => '.svg',
'application/pdf' => '.pdf',
'text/plain' => '.txt',
'text/html' => '.html',
'application/zip' => '.zip',
'application/json' => '.json',
'application/xml' => '.xml',
];

return $extensions[$mimeType] ?? '.bin';
}
}
Loading
Loading