mirror of
https://github.com/itflow-org/itflow
synced 2026-06-13 13:21:05 +00:00
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.
276 lines
6.5 KiB
PHP
276 lines
6.5 KiB
PHP
<?php
|
|
|
|
namespace DirectoryTree\ImapEngine;
|
|
|
|
use Countable;
|
|
use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData;
|
|
use DirectoryTree\ImapEngine\Connection\Tokens\Nil;
|
|
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
|
|
use Illuminate\Contracts\Support\Arrayable;
|
|
use IteratorAggregate;
|
|
use JsonSerializable;
|
|
use Traversable;
|
|
|
|
/**
|
|
* @implements IteratorAggregate<int, BodyStructurePart|BodyStructureCollection>
|
|
*/
|
|
class BodyStructureCollection implements Arrayable, Countable, IteratorAggregate, JsonSerializable
|
|
{
|
|
/**
|
|
* Constructor.
|
|
*
|
|
* @param array<BodyStructurePart|BodyStructureCollection> $parts
|
|
*/
|
|
public function __construct(
|
|
protected string $subtype = 'mixed',
|
|
protected array $parameters = [],
|
|
protected array $parts = [],
|
|
) {}
|
|
|
|
/**
|
|
* Parse a multipart BODYSTRUCTURE ListData into a BodyStructureCollection.
|
|
*/
|
|
public static function fromListData(ListData $data, ?string $partNumber = null): static
|
|
{
|
|
$tokens = $data->tokens();
|
|
|
|
$parts = [];
|
|
$childIndex = 1;
|
|
$subtypeIndex = null;
|
|
|
|
foreach ($tokens as $index => $token) {
|
|
if ($token instanceof Token && ! $token instanceof Nil) {
|
|
$subtypeIndex = $index;
|
|
|
|
break;
|
|
}
|
|
|
|
if (! $token instanceof ListData) {
|
|
continue;
|
|
}
|
|
|
|
$childPartNumber = $partNumber ? "{$partNumber}.{$childIndex}" : (string) $childIndex;
|
|
|
|
$parts[] = static::isMultipart($token)
|
|
? static::fromListData($token, $childPartNumber)
|
|
: BodyStructurePart::fromListData($token, $childPartNumber);
|
|
|
|
$childIndex++;
|
|
}
|
|
|
|
$parameters = [];
|
|
|
|
if ($subtypeIndex) {
|
|
foreach (array_slice($tokens, $subtypeIndex + 1) as $token) {
|
|
if ($token instanceof ListData && ! static::isDispositionList($token)) {
|
|
$parameters = $token->toKeyValuePairs();
|
|
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return new static(
|
|
$subtypeIndex ? strtolower($tokens[$subtypeIndex]->value) : 'mixed',
|
|
$parameters,
|
|
$parts
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Determine if a ListData represents a multipart structure.
|
|
*/
|
|
protected static function isMultipart(ListData $data): bool
|
|
{
|
|
return head($data->tokens()) instanceof ListData;
|
|
}
|
|
|
|
/**
|
|
* Determine if a ListData represents a disposition (INLINE or ATTACHMENT).
|
|
*/
|
|
protected static function isDispositionList(ListData $data): bool
|
|
{
|
|
$tokens = $data->tokens();
|
|
|
|
if (count($tokens) < 2 || ! isset($tokens[0]) || ! $tokens[0] instanceof Token) {
|
|
return false;
|
|
}
|
|
|
|
return in_array(strtoupper($tokens[0]->value), ['INLINE', 'ATTACHMENT']);
|
|
}
|
|
|
|
/**
|
|
* Get the multipart subtype (mixed, alternative, related, etc.).
|
|
*/
|
|
public function subtype(): string
|
|
{
|
|
return $this->subtype;
|
|
}
|
|
|
|
/**
|
|
* Get the content type.
|
|
*/
|
|
public function contentType(): string
|
|
{
|
|
return "multipart/{$this->subtype}";
|
|
}
|
|
|
|
/**
|
|
* Get the parameters (e.g., boundary).
|
|
*/
|
|
public function parameters(): array
|
|
{
|
|
return $this->parameters;
|
|
}
|
|
|
|
/**
|
|
* Get the boundary parameter.
|
|
*/
|
|
public function boundary(): ?string
|
|
{
|
|
return $this->parameters['boundary'] ?? null;
|
|
}
|
|
|
|
/**
|
|
* Get the direct child parts.
|
|
*
|
|
* @return array<BodyStructurePart|BodyStructureCollection>
|
|
*/
|
|
public function parts(): array
|
|
{
|
|
return $this->parts;
|
|
}
|
|
|
|
/**
|
|
* Get all parts flattened (including nested parts).
|
|
*
|
|
* @return BodyStructurePart[]
|
|
*/
|
|
public function flatten(): array
|
|
{
|
|
$flattened = [];
|
|
|
|
foreach ($this->parts as $part) {
|
|
if ($part instanceof self) {
|
|
$flattened = array_merge($flattened, $part->flatten());
|
|
} else {
|
|
$flattened[] = $part;
|
|
}
|
|
}
|
|
|
|
return $flattened;
|
|
}
|
|
|
|
/**
|
|
* Find a part by its part number.
|
|
*/
|
|
public function find(string $partNumber): BodyStructurePart|BodyStructureCollection|null
|
|
{
|
|
foreach ($this->parts as $part) {
|
|
if ($part instanceof self) {
|
|
if ($found = $part->find($partNumber)) {
|
|
return $found;
|
|
}
|
|
} elseif ($part->partNumber() === $partNumber) {
|
|
return $part;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get the text/plain part if available.
|
|
*/
|
|
public function text(): ?BodyStructurePart
|
|
{
|
|
foreach ($this->flatten() as $part) {
|
|
if ($part->isText()) {
|
|
return $part;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get the text/html part if available.
|
|
*/
|
|
public function html(): ?BodyStructurePart
|
|
{
|
|
foreach ($this->flatten() as $part) {
|
|
if ($part->isHtml()) {
|
|
return $part;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get all attachment parts.
|
|
*
|
|
* @return BodyStructurePart[]
|
|
*/
|
|
public function attachments(): array
|
|
{
|
|
return array_values(array_filter(
|
|
$this->flatten(),
|
|
fn (BodyStructurePart $part) => $part->isAttachment()
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Determine if the collection has attachments.
|
|
*/
|
|
public function hasAttachments(): bool
|
|
{
|
|
return count($this->attachments()) > 0;
|
|
}
|
|
|
|
/**
|
|
* Get the count of attachments.
|
|
*/
|
|
public function attachmentCount(): int
|
|
{
|
|
return count($this->attachments());
|
|
}
|
|
|
|
/**
|
|
* Get the count of parts.
|
|
*/
|
|
public function count(): int
|
|
{
|
|
return count($this->parts);
|
|
}
|
|
|
|
/**
|
|
* Get an iterator for the parts.
|
|
*/
|
|
public function getIterator(): Traversable
|
|
{
|
|
yield from $this->parts;
|
|
}
|
|
|
|
/**
|
|
* Get the array representation.
|
|
*/
|
|
public function toArray(): array
|
|
{
|
|
return [
|
|
'subtype' => $this->subtype,
|
|
'parameters' => $this->parameters,
|
|
'content_type' => $this->contentType(),
|
|
'parts' => array_map(fn (Arrayable $part) => $part->toArray(), $this->parts),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Get the JSON representation.
|
|
*/
|
|
public function jsonSerialize(): array
|
|
{
|
|
return $this->toArray();
|
|
}
|
|
}
|