Rewrite email parser using ImapEngine, harden processing loop

Replace webklex/php-imap with directorytree/imapengine in the ticket
email parser. ImapEngine is pure PHP over sockets.

Parser improvements:
- Wrap per-message processing in try/catch so one malformed email
  can't abort the run; failures are flagged and logged with UID
- Query unseen + unflagged so previously-failed (flagged) messages
  are no longer re-processed on every cron run
- Skip vacation/auto-responder emails (RFC 3834) to prevent mail
  loops with the ticket auto-reply
- Cap messages per run (50) and attachment size (15MB); inline
  images over 2MB are stored as attachments instead of base64-embedded
  in ticket details
- Atomic lock file creation
- preg_quote() the ticket prefix in subject matching
- Dedupe CC watchers and exclude the sender
- Map webklex 'tls' encryption setting to STARTTLS for compatibility

NDR/DSN parsing now walks MIME parts via the underlying
zbateson parser instead of relying on attachment extraction.
This commit is contained in:
johnnyq
2026-06-12 16:56:39 -04:00
parent 300a1aff9f
commit 2204bd52f4
701 changed files with 111718 additions and 940 deletions

View File

@@ -0,0 +1,95 @@
<?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\Component\Mime\Part;
use Symfony\Component\Mime\Header\Headers;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
abstract class AbstractMultipartPart extends AbstractPart
{
private ?string $boundary = null;
private array $parts = [];
public function __construct(AbstractPart ...$parts)
{
parent::__construct();
foreach ($parts as $part) {
$this->parts[] = $part;
}
}
/**
* @return AbstractPart[]
*/
public function getParts(): array
{
return $this->parts;
}
public function getMediaType(): string
{
return 'multipart';
}
public function getPreparedHeaders(): Headers
{
$headers = parent::getPreparedHeaders();
$headers->setHeaderParameter('Content-Type', 'boundary', $this->getBoundary());
return $headers;
}
public function bodyToString(): string
{
$parts = $this->getParts();
$string = '';
foreach ($parts as $part) {
$string .= '--'.$this->getBoundary()."\r\n".$part->toString()."\r\n";
}
$string .= '--'.$this->getBoundary()."--\r\n";
return $string;
}
public function bodyToIterable(): iterable
{
$parts = $this->getParts();
foreach ($parts as $part) {
yield '--'.$this->getBoundary()."\r\n";
yield from $part->toIterable();
yield "\r\n";
}
yield '--'.$this->getBoundary()."--\r\n";
}
public function asDebugString(): string
{
$str = parent::asDebugString();
foreach ($this->getParts() as $part) {
$lines = explode("\n", $part->asDebugString());
$str .= "\n".array_shift($lines);
foreach ($lines as $line) {
$str .= "\n |".$line;
}
}
return $str;
}
private function getBoundary(): string
{
return $this->boundary ??= strtr(base64_encode(random_bytes(6)), '+/', '-_');
}
}