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,11 @@
<?php
namespace DirectoryTree\ImapEngine\Collections;
use DirectoryTree\ImapEngine\FolderInterface;
use Illuminate\Support\Collection;
/**
* @template-extends Collection<array-key, FolderInterface>
*/
class FolderCollection extends Collection {}

View File

@@ -0,0 +1,32 @@
<?php
namespace DirectoryTree\ImapEngine\Collections;
use DirectoryTree\ImapEngine\Message;
use DirectoryTree\ImapEngine\MessageInterface;
/**
* @template-extends PaginatedCollection<array-key, MessageInterface|Message>
*/
class MessageCollection extends PaginatedCollection
{
/**
* Find a message by its UID.
*/
public function find(int $uid): ?MessageInterface
{
return $this->first(
fn (MessageInterface $message) => $message->uid() === $uid
);
}
/**
* Find a message by its UID or throw an exception.
*/
public function findOrFail(int $uid): MessageInterface
{
return $this->firstOrFail(
fn (MessageInterface $message) => $message->uid() === $uid
);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace DirectoryTree\ImapEngine\Collections;
use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
/**
* @template TKey of array-key
* @template TValue
*
* @template-extends Collection<TKey, TValue>
*/
class PaginatedCollection extends Collection
{
/**
* The total number of items.
*/
protected int $total = 0;
/**
* Paginate the current collection.
*
* @return LengthAwarePaginator<TKey, TValue>
*/
public function paginate(int $perPage = 15, ?int $page = null, string $pageName = 'page', bool $prepaginated = false): LengthAwarePaginator
{
$total = $this->total ?: $this->count();
$results = ! $prepaginated && $total ? $this->forPage($page, $perPage) : $this;
return $this->paginator($results, $total, $perPage, $page, $pageName);
}
/**
* Create a new length-aware paginator instance.
*
* @return LengthAwarePaginator<TKey, TValue>
*/
protected function paginator(Collection $items, int $total, int $perPage, ?int $currentPage, string $pageName): LengthAwarePaginator
{
return new LengthAwarePaginator($items, $total, $perPage, $currentPage, $pageName);
}
/**
* Get or set the total amount.
*/
public function total(?int $total = null): ?int
{
if (is_null($total)) {
return $this->total;
}
return $this->total = $total;
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace DirectoryTree\ImapEngine\Collections;
use DirectoryTree\ImapEngine\Connection\Responses\ContinuationResponse;
use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use Illuminate\Support\Collection;
/**
* @template TKey of array-key
*
* @template-covariant TValue
*
* @extends Collection<array-key, TValue>
*/
class ResponseCollection extends Collection
{
/**
* Filter the collection to only tagged responses.
*
* @return self<array-key, TaggedResponse>
*/
public function tagged(): self
{
return $this->whereInstanceOf(TaggedResponse::class);
}
/**
* Filter the collection to only untagged responses.
*
* @return self<array-key, UntaggedResponse>
*/
public function untagged(): self
{
return $this->whereInstanceOf(UntaggedResponse::class);
}
/**
* Filter the collection to only continuation responses.
*
* @return self<array-key, ContinuationResponse>
*/
public function continuation(): self
{
return $this->whereInstanceOf(ContinuationResponse::class);
}
}