feat: initial version

Signed-off-by: Sebastian Krupinski <root@LAPTOP-7DVOR6NC>
This commit was merged in pull request #1.
This commit is contained in:
Sebastian Krupinski
2026-02-20 16:41:19 -05:00
committed by Sebastian Krupinski
parent a313767846
commit e51c65bf19
139 changed files with 11256 additions and 0 deletions

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXM\ProviderImapMail\Providers;
use Gricob\IMAP\Mailbox;
use KTXF\Mail\Collection\CollectionPropertiesMutableAbstract;
use KTXF\Mail\Collection\CollectionRoles;
/**
* IMAP Mail Collection Properties
*
* Backed by the same internal $data shape as the JMAP provider so that cache
* documents are interchangeable with fromStore() / toStore().
*/
class CollectionProperties extends CollectionPropertiesMutableAbstract
{
// ── IMAP hydration ───────────────────────────────────────────────────────
/**
* Populate from a gricob Mailbox object.
*
* Total / unread counts are NOT available from a LIST response alone.
* They must be set separately (after SELECT + SEARCH UNSEEN).
*/
public function fromImap(Mailbox $mailbox): static
{
$this->data['label'] = $mailbox->name;
$this->data['delimiter'] = $mailbox->hierarchyDelimiter;
$this->data['attributes'] = $mailbox->nameAttributes;
$this->data['subscribed'] = in_array('\Subscribed', $mailbox->nameAttributes, true);
$this->data['total'] = 0;
$this->data['unread'] = 0;
$this->data['rank'] = 0;
// Map standard IMAP role attributes
$this->data['role'] = $this->roleFromAttributes($mailbox->nameAttributes)->value;
return $this;
}
// ── Store (MongoDB cache) ────────────────────────────────────────────────
public function toStore(): array
{
return $this->data;
}
public function fromStore(array $data): static
{
$this->data = $data;
return $this;
}
// ── JSON helpers ─────────────────────────────────────────────────────────
public function jsonSerialize(): array
{
return $this->data;
}
public function getDelimiter(): ?string
{
return $this->data['delimiter'] ?? null;
}
// ── Helpers ───────────────────────────────────────────────────────────────
private function roleFromAttributes(array $attributes): CollectionRoles
{
foreach ($attributes as $attr) {
$lower = strtolower($attr);
$role = match ($lower) {
'\sent' => CollectionRoles::Sent,
'\trash' => CollectionRoles::Trash,
'\drafts' => CollectionRoles::Drafts,
'\junk' => CollectionRoles::Junk,
'\archive' => CollectionRoles::Archive,
default => null,
};
if ($role !== null) {
return $role;
}
}
return CollectionRoles::Custom;
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXM\ProviderImapMail\Providers;
use Gricob\IMAP\Mailbox;
use KTXF\Mail\Collection\CollectionMutableAbstract;
/**
* IMAP Mail Collection Resource
*
* Represents one IMAP mailbox / folder.
*/
class CollectionResource extends CollectionMutableAbstract
{
public function __construct(
string $provider = 'imap',
string|int|null $service = null,
) {
parent::__construct($provider, $service);
}
// ── IMAP hydration ───────────────────────────────────────────────────────
/**
* Populate from a gricob Mailbox object.
*
* @param Mailbox $mailbox gricob Mailbox value object from LIST response
*/
public function fromImap(Mailbox $mailbox): static
{
// The mailbox name is its unique identifier within the account
$this->data['identifier'] = $mailbox->name;
// Derive parent collection from path + delimiter
$delimiter = $mailbox->hierarchyDelimiter;
if ($delimiter && str_contains($mailbox->name, $delimiter)) {
$parts = explode($delimiter, $mailbox->name);
array_pop($parts);
$this->data['collection'] = implode($delimiter, $parts);
} else {
$this->data['collection'] = null;
}
$this->getProperties()->fromImap($mailbox);
return $this;
}
// ── Store (MongoDB cache) ────────────────────────────────────────────────
/**
* Serialise to a MongoDB document.
*
* The caller must inject the service UUID as `sid` before persisting.
*/
public function toStore(): array
{
return array_merge(
$this->data,
[
'name' => $this->data['identifier'],
'properties' => $this->getProperties()->toStore(),
],
);
}
public function fromStore(array $data): static
{
$this->data = $data;
if (isset($data['properties'])) {
$this->getProperties()->fromStore($data['properties']);
}
return $this;
}
// ── Getters (lazy properties init) ───────────────────────────────────────
public function getProperties(): CollectionProperties
{
if (!isset($this->properties)) {
$this->properties = new CollectionProperties([]);
}
return $this->properties;
}
// ── JSON ─────────────────────────────────────────────────────────────────
public function jsonSerialize(): array
{
$data = $this->data;
$data['properties'] = $this->getProperties()->jsonSerialize();
return $data;
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXM\ProviderImapMail\Providers;
use DateTimeInterface;
use Gricob\IMAP\Mime\Part\Part;
use Gricob\IMAP\Protocol\Response\Line\Data\FetchData;
use KTXF\Mail\Entity\EntityMutableAbstract;
/**
* Mail Entity Resource Implementation
*/
class EntityResource extends EntityMutableAbstract {
public function __construct(
string $provider = 'imap',
string|int|null $service = null,
) {
parent::__construct($provider, $service);
}
/**
* Convert gricob FetchData to mail entity object
*
* @param FetchData $fetchData result from IMAP FETCH command
* @param string $mailbox IMAP mailbox name (used as collection)
* @param Part|null $bodyPart MIME Part tree for body content (optional)
*/
public function fromImap(FetchData $fetchData, string $mailbox, ?Part $bodyPart = null): static {
// Collection = the IMAP mailbox name
$this->data['collection'] = $mailbox;
// Identifier = UID (preferred) or sequence number as fallback
$this->data['identifier'] = $fetchData->uid ?? $fetchData->id;
// Created = INTERNALDATE (server arrival time)
if ($fetchData->internalDate !== null) {
$this->data['created'] = $fetchData->internalDate->format(DateTimeInterface::ATOM);
}
$this->getProperties()->fromImap(
flags: $fetchData->flags ?? [],
envelope: $fetchData->envelope,
bodyStructure: $fetchData->bodyStructure,
size: $fetchData->rfc822Size ?? 0,
bodyPart: $bodyPart,
);
return $this;
}
/**
* Convert mail entity object to store array
*/
public function toStore(): array {
return array_merge(
$this->data,
['properties' => $this->getProperties()->toStore()]
);
}
/**
* Hydrate mail entity object from store array
*/
public function fromStore(array $data): static {
$properties = $data['properties'] ?? [];
unset($data['properties']);
$this->data = $data;
$this->getProperties()->fromStore($properties);
return $this;
}
/**
* @inheritDoc
*/
public function getProperties(): MessageProperties {
if (!isset($this->properties)) {
$this->properties = new MessageProperties([]);
}
return $this->properties;
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXM\ProviderImapMail\Providers;
/**
* Mail Attachment Object
*
* @since 1.0.0
*/
class MessageAttachment implements \KTXF\Mail\Object\MessagePartInterface {
protected MessagePart $_meta;
protected ?string $_contents = null;
public function __construct(?MessagePart $meta = null, ?string $contents = null) {
if ($meta === null) {
$meta = new MessagePart();
$meta->setDisposition('attachment');
$meta->setType('application/octet-stream');
}
$this->setParameters($meta);
if ($contents !== null) {
$this->setContents($contents);
}
}
/**
* Sets the attachment parameters
*/
public function setParameters(?MessagePart $meta): static {
$this->_meta = $meta;
return $this;
}
/**
* Gets the attachment parameters
*/
public function getParameters(): MessagePart {
return $this->_meta;
}
/**
* Returns the unique identifier for this attachment (MIME part id)
*/
public function id(): string {
return $this->_meta->getBlobId() ?? $this->_meta->getId() ?? '';
}
/**
* Sets the attachment file name
*/
public function setName(string $value): static {
$this->_meta->setName($value);
return $this;
}
/**
* Gets the attachment file name
*/
public function getName(): ?string {
return $this->_meta->getName();
}
/**
* Sets the attachment MIME type
*/
public function setType(string $value): static {
$this->_meta->setType($value);
return $this;
}
/**
* Gets the attachment MIME type
*/
public function getType(): ?string {
return $this->_meta->getType();
}
/**
* Sets the attachment contents (binary data)
*/
public function setContents(string $value): static {
$this->_contents = $value;
return $this;
}
/**
* Gets the attachment contents
*/
public function getContents(): ?string {
return $this->_contents;
}
/**
* Sets whether the attachment is embedded (inline)
*/
public function setEmbedded(bool $value): static {
$this->_meta->setDisposition($value ? 'inline' : 'attachment');
return $this;
}
/**
* Gets whether the attachment is embedded (inline)
*/
public function getEmbedded(): bool {
return $this->_meta->getDisposition() === 'inline';
}
// ──────────────────────────────────────────────────────────────
// MessagePartInterface pass-throughs
// ──────────────────────────────────────────────────────────────
public function getBlobId(): ?string { return $this->_meta->getBlobId(); }
public function getId(): ?string { return $this->_meta->getId(); }
public function getDisposition(): ?string { return $this->_meta->getDisposition(); }
public function getCharset(): ?string { return $this->_meta->getCharset(); }
public function getLanguage(): ?string { return $this->_meta->getLanguage(); }
public function getLocation(): ?string { return $this->_meta->getLocation(); }
public function getParts(): array { return $this->_meta->getParts(); }
public function jsonSerialize(): array { return $this->_meta->jsonSerialize(); }
}

View File

@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXM\ProviderImapMail\Providers;
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\MultiPart;
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\Part;
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\SinglePart;
use KTXF\Mail\Object\MessagePartMutableAbstract;
/**
* Mail Message Part Implementation
*/
class MessagePart extends MessagePartMutableAbstract {
/**
* Convert gricob BodyStructure part to message part object
*
* @param Part $part gricob BodyStructure Part (SinglePart or MultiPart)
* @param string $partId numeric part identifier (e.g. "1", "1.1", "2")
*/
public function fromImap(Part $part, string $partId = '1'): static {
$this->data['partId'] = $partId;
if ($part instanceof SinglePart) {
$mimeType = strtolower($part->type) . '/' . strtolower($part->subtype);
$this->data['type'] = $mimeType;
if ($part->id !== null) {
$this->data['blobId'] = trim($part->id, '<>');
}
// Content-Type parameters (name, charset, etc.)
if (!empty($part->attributes)) {
foreach ($part->attributes as $key => $value) {
$keyLower = strtolower($key);
if ($keyLower === 'name') {
$this->data['name'] = $value;
} elseif ($keyLower === 'charset') {
$this->data['charset'] = $value;
}
}
}
if ($part->encoding !== null) {
$this->data['encoding'] = strtolower($part->encoding);
}
if ($part->size !== null) {
$this->data['size'] = $part->size;
}
if ($part->disposition !== null) {
$this->data['disposition'] = strtolower($part->disposition->type);
// disposition filename attribute
if (!empty($part->disposition->attributes)) {
foreach ($part->disposition->attributes as $key => $value) {
if (strtolower($key) === 'filename') {
$this->data['name'] = $this->data['name'] ?? $value;
}
}
}
}
if (!empty($part->language)) {
$this->data['language'] = implode(',', $part->language);
}
if ($part->location !== null) {
$this->data['location'] = $part->location;
}
} elseif ($part instanceof MultiPart) {
$this->data['type'] = 'multipart/' . strtolower($part->subtype);
if ($part->disposition !== null) {
$this->data['disposition'] = strtolower($part->disposition->type);
}
if (!empty($part->language)) {
$this->data['language'] = implode(',', $part->language);
}
if ($part->location !== null) {
$this->data['location'] = $part->location;
}
// Recursively process sub-parts
foreach ($part->parts as $index => $subPart) {
$subPartId = $partId . '.' . ($index + 1);
$this->parts[] = (new MessagePart())->fromImap($subPart, $subPartId);
}
}
return $this;
}
/**
* Convert message part to store array
*/
public function toStore(): array {
$data = $this->data;
if (count($this->parts) > 0) {
$data['subParts'] = [];
foreach ($this->parts as $subPart) {
if ($subPart instanceof MessagePart) {
$data['subParts'][] = $subPart->toStore();
}
}
} else {
$data['subParts'] = null;
}
return $data;
}
/**
* Hydrate message part from store array
*/
public function fromStore(array $data): static {
if (isset($data['subParts']) && is_array($data['subParts'])) {
foreach ($data['subParts'] as $subPart) {
$this->parts[] = (new MessagePart())->fromStore($subPart);
}
unset($data['subParts']);
}
$this->data = $data;
return $this;
}
/**
* Inject decoded body content from a parallel gricob Mime Part tree.
*
* Walks the gricob Mime Part tree alongside this MessagePart tree and
* sets 'content' on each leaf single-part node from its decoded body.
*
* @param \Gricob\IMAP\Mime\Part\Part $mimePart Corresponding gricob Mime Part node
*/
public function injectBodyContent(\Gricob\IMAP\Mime\Part\Part $mimePart): void
{
if ($mimePart instanceof \Gricob\IMAP\Mime\Part\MultiPart) {
foreach ($mimePart->parts as $index => $childMimePart) {
$childPart = $this->parts[$index] ?? null;
if ($childPart instanceof MessagePart) {
$childPart->injectBodyContent($childMimePart);
}
}
return;
}
if ($mimePart instanceof \Gricob\IMAP\Mime\Part\SinglePart) {
// Only inject content for text/* parts; binary parts (images, PDFs, …)
// produce raw bytes that cannot be JSON-encoded as UTF-8 strings.
$type = strtolower($this->data['type'] ?? '');
if (!str_starts_with($type, 'text/')) {
return;
}
try {
$decoded = $mimePart->decodedBody();
} catch (\Throwable) {
return;
}
if ($decoded !== null && $decoded !== '') {
$charset = $mimePart->charset() ?? 'utf-8';
$this->data['content'] = MessageProperties::toUtf8($decoded, $charset);
}
}
}
}

View File

@@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXM\ProviderImapMail\Providers;
use DateTimeImmutable;
use DateTimeInterface;
use Gricob\IMAP\Mime\Part\Part as MimePart;
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure;
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\MultiPart;
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\Part;
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\SinglePart;
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\Envelope;
use KTXF\Mail\Object\MessagePropertiesMutableAbstract;
/**
* Mail Message Properties Implementation
*/
class MessageProperties extends MessagePropertiesMutableAbstract {
/**
* Convert IMAP data to mail message properties object
*
* @param array $flags IMAP flags (e.g. ['\Seen', '\Flagged', ...])
* @param ?Envelope $envelope parsed envelope from gricob
* @param ?BodyStructure $bodyStructure parsed body structure from gricob
* @param int $size RFC822.SIZE byte count
*/
public function fromImap(
array $flags,
?Envelope $envelope,
?BodyStructure $bodyStructure,
int $size = 0,
?MimePart $bodyPart = null,
): static {
// ── Size ──────────────────────────────────────────────────────
$this->data['size'] = $size;
// ── Flags ─────────────────────────────────────────────────────
$this->data['flags'] = [];
foreach ($flags as $flag) {
$flag = ltrim($flag, '\\');
$normalized = match (strtolower($flag)) {
'seen' => 'read',
'flagged' => 'flagged',
'answered' => 'answered',
'draft' => 'draft',
'deleted' => 'deleted',
default => strtolower($flag),
};
$this->data['flags'][$normalized] = true;
}
// ── Envelope ──────────────────────────────────────────────────
if ($envelope !== null) {
if ($envelope->messageId !== null) {
$this->data['urid'] = trim($envelope->messageId, '<>');
}
if ($envelope->subject !== null) {
// Decode MIME encoded-word in subject
$this->data['subject'] = mb_decode_mimeheader($envelope->subject);
}
if ($envelope->date !== null) {
$date = $envelope->date instanceof DateTimeImmutable
? $envelope->date
: new DateTimeImmutable($envelope->date);
$this->data['date'] = $date->format(DateTimeInterface::ATOM);
}
if ($envelope->inReplyTo !== null) {
$this->data['inReplyTo'] = $envelope->inReplyTo;
}
$addressToArray = static function ($addr): array {
$email = '';
if ($addr->mailboxName !== null && $addr->hostName !== null) {
$email = $addr->mailboxName . '@' . $addr->hostName;
} elseif ($addr->mailboxName !== null) {
$email = $addr->mailboxName;
}
return [
'address' => $email,
'label' => $addr->displayName ?? null,
];
};
if (!empty($envelope->from)) {
$this->data['from'] = $addressToArray($envelope->from[0]);
}
if (!empty($envelope->sender)) {
$this->data['sender'] = $addressToArray($envelope->sender[0]);
}
foreach (['to', 'cc', 'bcc', 'replyTo'] as $field) {
$envField = $field === 'replyTo' ? 'replyTo' : $field;
if (!empty($envelope->$envField)) {
$this->data[$field] = [];
foreach ($envelope->$envField as $addr) {
$this->data[$field][] = $addressToArray($addr);
}
}
}
}
// ── Body Structure ────────────────────────────────────────────
if ($bodyStructure !== null) {
$rootPart = (new MessagePart())->fromImap($bodyStructure->part, '1');
// ── Body Content: inject decoded content onto part nodes ──────
if ($bodyPart !== null) {
$rootPart->injectBodyContent($bodyPart);
}
$this->data['body'] = $rootPart->toStore();
// Collect attachments: non-body parts with name or attachment disposition
$attachments = [];
self::collectAttachments($bodyStructure->part, '1', $attachments);
if (!empty($attachments)) {
$this->data['attachments'] = $attachments;
}
}
return $this;
}
/**
* Convert a string to UTF-8 from the given charset.
*
* Tries mb_convert_encoding first; falls back to iconv when mbstring does
* not recognise the charset name (e.g. "windows-1250").
*/
public static function toUtf8(string $content, string $charset): string
{
if ($charset === '' || in_array(strtolower($charset), ['utf-8', 'utf8'], true)) {
// Content claims to be UTF-8 but may still have invalid sequences; scrub to be safe.
return mb_convert_encoding($content, 'UTF-8', 'UTF-8');
}
// Try mbstring first
try {
$converted = mb_convert_encoding($content, 'UTF-8', $charset);
if ($converted !== false) {
return $converted;
}
} catch (\ValueError) {
// charset not recognised by mbstring — fall through to iconv
}
// iconv fallback (handles Windows-125x, ISO-8859-*, etc.)
$converted = @iconv($charset, 'UTF-8//TRANSLIT//IGNORE', $content);
$content = ($converted !== false) ? $converted : $content;
// Final scrub: strip any residual invalid UTF-8 bytes so json_encode never fails.
return mb_convert_encoding($content, 'UTF-8', 'UTF-8');
}
/**
* Recursively collect attachment parts from body structure
*/
private static function collectAttachments(
Part $part,
string $partId,
array &$attachments,
): void {
if ($part instanceof SinglePart) {
$type = strtolower($part->type ?? '');
$subtype = strtolower($part->subtype ?? '');
$disposition = strtolower($part->disposition?->type ?? '');
$name = null;
if (!empty($part->attributes)) {
foreach ($part->attributes as $k => $v) {
if (strtolower($k) === 'name') {
$name = $v;
break;
}
}
}
if (!empty($part->disposition?->attributes)) {
foreach ($part->disposition->attributes as $k => $v) {
if (strtolower($k) === 'filename') {
$name = $name ?? $v;
}
}
}
$isInlineText = ($type === 'text' && ($subtype === 'plain' || $subtype === 'html') && $disposition !== 'attachment');
if (!$isInlineText && ($disposition !== '' || $name !== null)) {
$mp = (new MessagePart())->fromImap($part, $partId);
$attachments[] = $mp->toStore();
}
} elseif ($part instanceof MultiPart) {
foreach ($part->parts as $index => $subPart) {
self::collectAttachments($subPart, $partId . '.' . ($index + 1), $attachments);
}
}
}
/**
* Serialize to store array
*/
public function toStore(): array {
return $this->data;
}
/**
* Hydrate from store array
*/
public function fromStore(array $data): static {
$this->data = $data;
return $this;
}
}

246
lib/Providers/Provider.php Normal file
View File

@@ -0,0 +1,246 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXM\ProviderImapMail\Providers;
use KTXF\Mail\Provider\ProviderBaseInterface;
use KTXF\Mail\Provider\ProviderServiceDiscoverInterface;
use KTXF\Mail\Provider\ProviderServiceMutateInterface;
use KTXF\Mail\Provider\ProviderServiceTestInterface;
use KTXF\Mail\Service\ServiceBaseInterface;
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
use KTXF\Resource\Provider\ResourceServiceMutateInterface;
use KTXM\ProviderImapMail\Service\Discovery;
use KTXM\ProviderImapMail\Service\Remote\RemoteService;
use KTXM\ProviderImapMail\Stores\ServiceStore;
/**
* IMAP Mail Provider
*
* Registers IMAP as a mail provider and handles service lifecycle:
* list / fetch / create / modify / destroy / discover / test.
*/
class Provider implements ProviderServiceMutateInterface, ProviderServiceDiscoverInterface, ProviderServiceTestInterface
{
public const JSON_TYPE = ProviderBaseInterface::JSON_TYPE;
protected const PROVIDER_IDENTIFIER = 'imap';
protected const PROVIDER_LABEL = 'IMAP Mail Provider';
protected const PROVIDER_DESCRIPTION = 'Provides mail services via the IMAP protocol';
protected const PROVIDER_ICON = 'mdi-email';
protected array $providerAbilities = [
self::CAPABILITY_SERVICE_LIST => true,
self::CAPABILITY_SERVICE_FETCH => true,
self::CAPABILITY_SERVICE_EXTANT => true,
self::CAPABILITY_SERVICE_CREATE => true,
self::CAPABILITY_SERVICE_MODIFY => true,
self::CAPABILITY_SERVICE_DESTROY => true,
self::CAPABILITY_SERVICE_TEST => true,
];
public function __construct(
private readonly ServiceStore $serviceStore,
) {}
// ── ProviderBaseInterface ─────────────────────────────────────────────────
public function jsonSerialize(): array
{
return [
self::JSON_PROPERTY_TYPE => self::JSON_TYPE,
self::JSON_PROPERTY_IDENTIFIER => self::PROVIDER_IDENTIFIER,
self::JSON_PROPERTY_LABEL => self::PROVIDER_LABEL,
self::JSON_PROPERTY_CAPABILITIES => $this->providerAbilities,
];
}
public function jsonDeserialize(array|string $data): static
{
return $this;
}
public function type(): string
{
return self::TYPE_MAIL;
}
public function identifier(): string
{
return self::PROVIDER_IDENTIFIER;
}
public function label(): string
{
return self::PROVIDER_LABEL;
}
public function description(): string
{
return self::PROVIDER_DESCRIPTION;
}
public function icon(): string
{
return self::PROVIDER_ICON;
}
public function capable(string $value): bool
{
return !empty($this->providerAbilities[$value]);
}
public function capabilities(): array
{
return $this->providerAbilities;
}
// ── ProviderServiceMutateInterface ────────────────────────────────────────
public function serviceList(string $tenantId, string $userId, array $filter = []): array
{
$list = $this->serviceStore->list($tenantId, $userId, $filter);
$result = [];
foreach ($list as $entry) {
$service = new Service();
$service->fromStore($entry);
$result[$service->identifier()] = $service;
}
return $result;
}
public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?Service
{
return $this->serviceStore->fetch($tenantId, $userId, $identifier);
}
public function serviceFindByAddress(string $tenantId, string $userId, string $address): ?Service
{
/** @var Service[] $services */
$services = $this->serviceList($tenantId, $userId);
foreach ($services as $service) {
if ($service->hasAddress($address)) {
return $service;
}
}
return null;
}
public function serviceExtant(string $tenantId, string $userId, string|int ...$identifiers): array
{
return $this->serviceStore->extant($tenantId, $userId, $identifiers);
}
public function serviceFresh(): ResourceServiceMutateInterface
{
return new Service();
}
public function serviceCreate(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string
{
if (!($service instanceof Service)) {
throw new \InvalidArgumentException('Service must be an instance of IMAP Service');
}
$created = $this->serviceStore->create($tenantId, $userId, $service);
return (string) $created->identifier();
}
public function serviceModify(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string
{
if (!($service instanceof Service)) {
throw new \InvalidArgumentException('Service must be an instance of IMAP Service');
}
$updated = $this->serviceStore->modify($tenantId, $userId, $service);
return (string) $updated->identifier();
}
public function serviceDestroy(string $tenantId, string $userId, ResourceServiceMutateInterface $service): bool
{
if (!($service instanceof Service)) {
return false;
}
return $this->serviceStore->delete($tenantId, $userId, $service->identifier());
}
// ── ProviderServiceDiscoverInterface ──────────────────────────────────────
public function serviceDiscover(
string $tenantId,
string $userId,
string $identity,
?string $location = null,
?string $secret = null,
): ?ResourceServiceLocationInterface {
$discovery = new Discovery();
// TODO: Make SSL verification configurable per-tenant
$verifySSL = true;
return $discovery->discover($identity, $location, $secret, $verifySSL);
}
// ── ProviderServiceTestInterface ──────────────────────────────────────────
public function serviceTest(ServiceBaseInterface $service, array $options = []): array
{
$startTime = microtime(true);
try {
if (!($service instanceof Service)) {
throw new \InvalidArgumentException('Service must be an instance of IMAP Service');
}
// Attempt to authenticate and list mailboxes as a connectivity check
$wrapper = RemoteService::freshClient($service);
$mailboxes = $wrapper->mailboxes();
$latency = (int) round((microtime(true) - $startTime) * 1000);
return [
'success' => true,
'message' => 'IMAP connection successful'
. ' (Mailboxes: ' . count($mailboxes) . ')'
. ' (Latency: ' . $latency . ' ms)',
];
} catch (\Throwable $e) {
$latency = (int) round((microtime(true) - $startTime) * 1000);
$location = ($service instanceof Service) ? $service->getLocation() : null;
$target = $location
? $location->getEncryption() . '://' . $location->getHost() . ':' . $location->getPort()
: 'unknown host';
// stream_socket_client errors are suppressed with @ in gricob — recover them
$phpError = error_get_last();
$detail = $e->getMessage() !== '' ? $e->getMessage() : ($phpError['message'] ?? '');
if ($detail === '' && $location !== null) {
$host = $location->getHost();
if ($host !== '' && gethostbyname($host) === $host) {
$detail = "hostname '{$host}' could not be resolved";
} else {
$detail = 'connection refused or timed out — check port and encryption settings';
}
} elseif ($detail === '') {
$detail = 'no details — check host, port, and encryption settings';
}
return [
'success' => false,
'message' => sprintf(
'Connection to %s failed (%s): %s',
$target,
(new \ReflectionClass($e))->getShortName(),
$detail,
),
];
}
}
}

515
lib/Providers/Service.php Normal file
View File

@@ -0,0 +1,515 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXM\ProviderImapMail\Providers;
use Generator;
use KTXF\Mail\Collection\CollectionBaseInterface;
use KTXF\Mail\Collection\CollectionMutableInterface;
use KTXF\Mail\Object\Address;
use KTXF\Mail\Object\AddressInterface;
use KTXF\Mail\Service\ServiceBaseInterface;
use KTXF\Mail\Service\ServiceCollectionMutableInterface;
use KTXF\Mail\Service\ServiceConfigurableInterface;
use KTXF\Mail\Service\ServiceMutableInterface;
use KTXF\Resource\Provider\ResourceServiceIdentityInterface;
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
use KTXF\Resource\Delta\Delta;
use KTXF\Resource\Filter\Filter;
use KTXF\Resource\Filter\IFilter;
use KTXF\Resource\Range\IRange;
use KTXF\Resource\Range\Range;
use KTXF\Resource\Range\RangeTally;
use KTXF\Resource\Range\RangeType;
use KTXF\Resource\Sort\ISort;
use KTXF\Resource\Sort\Sort;
use KTXM\ProviderImapMail\Providers\ServiceIdentityBasic;
use KTXM\ProviderImapMail\Providers\ServiceLocation;
use KTXM\ProviderImapMail\Service\Remote\RemoteMailService;
use KTXM\ProviderImapMail\Service\Remote\RemoteService;
/**
* IMAP Mail Service
*
* Represents a single IMAP account configuration and acts as the primary
* entry-point for all mail operations (collections + entities).
*
* The RemoteMailService is initialised lazily on first use so that the object
* can be constructed cheaply for serialisation/deserialisation tasks.
*/
class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface
{
public const JSON_TYPE = ServiceBaseInterface::JSON_TYPE;
private const PROVIDER_IDENTIFIER = 'imap';
private ?string $serviceTenantId = null;
private ?string $serviceUserId = null;
private ?string $serviceIdentifier = null;
private ?string $serviceLabel = null;
private bool $serviceEnabled = false;
private string $primaryAddress = '';
private array $secondaryAddresses = [];
private ?ServiceLocation $location = null;
private ?ServiceIdentityBasic $identity = null;
private array $auxiliary = [];
private array $serviceAbilities = [
self::CAPABILITY_COLLECTION_LIST => true,
self::CAPABILITY_COLLECTION_LIST_FILTER => [],
self::CAPABILITY_COLLECTION_LIST_SORT => [],
self::CAPABILITY_COLLECTION_EXTANT => true,
self::CAPABILITY_COLLECTION_FETCH => true,
self::CAPABILITY_COLLECTION_CREATE => true,
self::CAPABILITY_COLLECTION_UPDATE => true,
self::CAPABILITY_COLLECTION_DELETE => true,
self::CAPABILITY_ENTITY_LIST => true,
self::CAPABILITY_ENTITY_LIST_FILTER => [
'seen' => 'b:0:1:1',
'flagged' => 'b:0:1:1',
self::CAPABILITY_ENTITY_FILTER_FROM => 's:100:256:256',
self::CAPABILITY_ENTITY_FILTER_TO => 's:100:256:256',
self::CAPABILITY_ENTITY_FILTER_SUBJECT => 's:200:256:256',
self::CAPABILITY_ENTITY_FILTER_BODY => 's:200:256:256',
self::CAPABILITY_ENTITY_FILTER_DATE_BEFORE => 's:32:1:1',
self::CAPABILITY_ENTITY_FILTER_DATE_AFTER => 's:32:1:1',
self::CAPABILITY_ENTITY_FILTER_SIZE_MIN => 'i:0:16:16',
self::CAPABILITY_ENTITY_FILTER_SIZE_MAX => 'i:0:32:32',
],
self::CAPABILITY_ENTITY_LIST_SORT => [],
self::CAPABILITY_ENTITY_LIST_RANGE => ['tally' => ['absolute', 'relative']],
self::CAPABILITY_ENTITY_EXTANT => true,
self::CAPABILITY_ENTITY_FETCH => true,
];
private RemoteMailService $mailService;
public function __construct() {}
// ── Lazy initialisation ───────────────────────────────────────────────────
private function initialize(): void
{
if (!isset($this->mailService)) {
$wrapper = RemoteService::freshClient($this);
$this->mailService = RemoteService::mailService($this, $wrapper);
}
}
// ── Store (MongoDB persistence) ───────────────────────────────────────────
public function toStore(): array
{
return array_filter([
'tid' => $this->serviceTenantId,
'uid' => $this->serviceUserId,
'sid' => $this->serviceIdentifier,
'label' => $this->serviceLabel,
'enabled' => $this->serviceEnabled,
'primaryAddress' => $this->primaryAddress,
'secondaryAddresses'=> $this->secondaryAddresses,
'location' => $this->location?->toStore(),
'identity' => $this->identity?->toStore(),
'auxiliary' => $this->auxiliary,
], fn($v) => $v !== null);
}
public function fromStore(array $data): static
{
$this->serviceTenantId = $data['tid'] ?? null;
$this->serviceUserId = $data['uid'] ?? null;
$this->serviceIdentifier = $data['sid'] ?? null;
$this->serviceLabel = $data['label'] ?? '';
$this->serviceEnabled = $data['enabled'] ?? false;
if (isset($data['primaryAddress'])) {
$this->primaryAddress = $data['primaryAddress'];
}
if (isset($data['secondaryAddresses']) && is_array($data['secondaryAddresses'])) {
$this->secondaryAddresses = $data['secondaryAddresses'];
}
if (isset($data['location'])) {
$this->location = (new ServiceLocation())->fromStore($data['location']);
}
if (isset($data['identity'])) {
$this->identity = (new ServiceIdentityBasic())->fromStore($data['identity']);
}
if (isset($data['auxiliary']) && is_array($data['auxiliary'])) {
$this->auxiliary = $data['auxiliary'];
}
return $this;
}
// ── JSON ──────────────────────────────────────────────────────────────────
public function jsonSerialize(): array
{
return array_filter([
self::JSON_PROPERTY_TYPE => self::JSON_TYPE,
self::JSON_PROPERTY_PROVIDER => self::PROVIDER_IDENTIFIER,
self::JSON_PROPERTY_IDENTIFIER => $this->serviceIdentifier,
self::JSON_PROPERTY_LABEL => $this->serviceLabel,
self::JSON_PROPERTY_ENABLED => $this->serviceEnabled,
self::JSON_PROPERTY_CAPABILITIES => $this->serviceAbilities,
self::JSON_PROPERTY_PRIMARY_ADDRESS => $this->primaryAddress,
self::JSON_PROPERTY_SECONDARY_ADDRESSES => $this->secondaryAddresses,
self::JSON_PROPERTY_LOCATION => $this->location?->jsonSerialize(),
self::JSON_PROPERTY_IDENTITY => $this->identity?->jsonSerialize(),
self::JSON_PROPERTY_AUXILIARY => $this->auxiliary,
], fn($v) => $v !== null);
}
public function jsonDeserialize(array|string $data): static
{
if (is_string($data)) {
$data = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
}
if (isset($data[self::JSON_PROPERTY_LABEL])) {
$this->setLabel($data[self::JSON_PROPERTY_LABEL]);
}
if (isset($data[self::JSON_PROPERTY_ENABLED])) {
$this->setEnabled($data[self::JSON_PROPERTY_ENABLED]);
}
if (isset($data[self::JSON_PROPERTY_LOCATION])) {
$this->setLocation($this->freshLocation(null, $data[self::JSON_PROPERTY_LOCATION]));
}
if (isset($data[self::JSON_PROPERTY_IDENTITY])) {
$this->setIdentity($this->freshIdentity(null, $data[self::JSON_PROPERTY_IDENTITY]));
}
if (isset($data[self::JSON_PROPERTY_PRIMARY_ADDRESS]) && is_string($data[self::JSON_PROPERTY_PRIMARY_ADDRESS])) {
$this->setPrimaryAddress(new Address($data[self::JSON_PROPERTY_PRIMARY_ADDRESS]));
}
if (isset($data[self::JSON_PROPERTY_SECONDARY_ADDRESSES]) && is_array($data[self::JSON_PROPERTY_SECONDARY_ADDRESSES])) {
$this->setSecondaryAddresses(array_map(
fn($addr) => new Address(is_array($addr) ? ($addr['address'] ?? $addr) : $addr),
$data[self::JSON_PROPERTY_SECONDARY_ADDRESSES]
));
}
if (isset($data[self::JSON_PROPERTY_AUXILIARY]) && is_array($data[self::JSON_PROPERTY_AUXILIARY])) {
$this->setAuxiliary($data[self::JSON_PROPERTY_AUXILIARY]);
}
return $this;
}
// ── ServiceBaseInterface ──────────────────────────────────────────────────
public function capable(string $value): bool
{
return isset($this->serviceAbilities[$value]);
}
public function capabilities(): array
{
return $this->serviceAbilities;
}
public function provider(): string
{
return self::PROVIDER_IDENTIFIER;
}
public function identifier(): string|int
{
return $this->serviceIdentifier;
}
// ── ServiceMutableInterface ───────────────────────────────────────────────
public function getLabel(): ?string
{
return $this->serviceLabel;
}
public function setLabel(string $label): static
{
$this->serviceLabel = $label;
return $this;
}
public function getEnabled(): bool
{
return $this->serviceEnabled;
}
public function setEnabled(bool $enabled): static
{
$this->serviceEnabled = $enabled;
return $this;
}
public function getPrimaryAddress(): AddressInterface
{
return new Address($this->primaryAddress);
}
public function setPrimaryAddress(AddressInterface $value): static
{
$this->primaryAddress = $value->getAddress();
return $this;
}
public function getSecondaryAddresses(): array
{
return $this->secondaryAddresses;
}
public function setSecondaryAddresses(array $addresses): static
{
$this->secondaryAddresses = $addresses;
return $this;
}
public function hasAddress(string $address): bool
{
$address = strtolower(trim($address));
if ($this->primaryAddress && strtolower($this->primaryAddress) === $address) {
return true;
}
foreach ($this->secondaryAddresses as $secondary) {
$secondaryAddr = $secondary instanceof AddressInterface ? $secondary->getAddress() : (string) $secondary;
if (strtolower($secondaryAddr) === $address) {
return true;
}
}
return false;
}
// ── ServiceConfigurableInterface ──────────────────────────────────────────
public function getLocation(): ServiceLocation
{
return $this->location;
}
public function setLocation(ResourceServiceLocationInterface $location): static
{
$this->location = $location;
return $this;
}
public function freshLocation(?string $type = null, array $data = []): ServiceLocation
{
$loc = new ServiceLocation();
$loc->jsonDeserialize($data);
return $loc;
}
public function getIdentity(): ServiceIdentityBasic
{
return $this->identity;
}
public function setIdentity(ResourceServiceIdentityInterface $identity): static
{
$this->identity = $identity;
return $this;
}
public function freshIdentity(?string $type = null, array $data = []): ServiceIdentityBasic
{
$id = new ServiceIdentityBasic();
$id->jsonDeserialize($data);
return $id;
}
public function getDebug(): bool
{
return ($this->auxiliary['debug'] ?? false) === true;
}
public function setDebug(bool $debug): static
{
$this->auxiliary['debug'] = $debug;
return $this;
}
public function getAuxiliary(): array
{
return $this->auxiliary;
}
public function setAuxiliary(array $auxiliary): static
{
$this->auxiliary = $auxiliary;
return $this;
}
// ── Collection operations ─────────────────────────────────────────────────
public function collectionList(string|int|null $location, ?\KTXF\Resource\Filter\IFilter $filter = null, ?\KTXF\Resource\Sort\ISort $sort = null): array
{
$this->initialize();
return $this->mailService->collectionList();
}
public function collectionListFilter(): Filter
{
return new Filter($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_FILTER] ?? []);
}
public function collectionListSort(): Sort
{
return new Sort($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_SORT] ?? []);
}
public function collectionExtant(string|int ...$identifiers): array
{
$this->initialize();
$existing = $this->mailService->collectionList();
$extant = [];
foreach ($identifiers as $id) {
$extant[(string) $id] = isset($existing[(string) $id]);
}
return $extant;
}
public function collectionFetch(string|int $identifier): ?CollectionBaseInterface
{
$this->initialize();
return $this->mailService->collectionFetch((string) $identifier);
}
public function collectionFresh(): CollectionMutableInterface
{
return new CollectionResource();
}
public function collectionCreate(string|int|null $location, CollectionMutableInterface $collection, array $options = []): CollectionBaseInterface
{
$this->initialize();
// Resolve the full name: if a parent location is given, prepend it
$label = $collection->getProperties()->getLabel() ?? '';
if ($location !== null && $location !== '') {
// Determine the hierarchy delimiter from an existing mailbox, default to '/'
$existing = $this->mailService->collectionList();
$delimiter = '/';
foreach ($existing as $c) {
$props = $c->getProperties();
if ($props instanceof CollectionProperties) {
$d = $props->getDelimiter();
if ($d !== null && $d !== '') {
$delimiter = $d;
break;
}
}
}
$label = rtrim((string) $location, $delimiter) . $delimiter . ltrim($label, $delimiter);
}
return $this->mailService->collectionCreate($label);
}
public function collectionUpdate(string|int $identifier, CollectionMutableInterface $collection): CollectionBaseInterface
{
$this->initialize();
// In IMAP, "update" = rename to the new label
$newName = $collection->getProperties()->getLabel() ?? (string) $identifier;
return $this->mailService->collectionRename((string) $identifier, $newName);
}
public function collectionDelete(string|int $identifier, bool $force = false, bool $recursive = false): bool
{
$this->initialize();
return $this->mailService->collectionDestroy((string) $identifier);
}
public function collectionMove(string|int $identifier, string|int|null $targetLocation): CollectionBaseInterface
{
$this->initialize();
// IMAP RENAME effectively moves+renames the mailbox
$existing = $this->mailService->collectionFetch((string) $identifier);
$label = $existing?->getProperties()->getLabel() ?? basename((string) $identifier);
$newName = $targetLocation !== null ? rtrim((string) $targetLocation, '/') . '/' . $label : $label;
return $this->mailService->collectionRename((string) $identifier, $newName);
}
// ── Entity operations ─────────────────────────────────────────────────────
public function entityList(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array
{
$this->initialize();
$uids = $this->mailService->entityList((string) $collection, $filter, $range);
if (empty($uids)) {
return [];
}
return $this->mailService->entityFetch((string) $collection, ...$uids);
}
public function entityListStream(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): Generator
{
$this->initialize();
$uids = $this->mailService->entityList((string) $collection, $filter, $range);
if (empty($uids)) {
return;
}
yield from $this->mailService->entityFetchStream((string) $collection, ...$uids);
}
public function entityListFilter(): Filter
{
return new Filter($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_FILTER] ?? []);
}
public function entityListSort(): Sort
{
return new Sort($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_SORT] ?? []);
}
public function entityListRange(RangeType $type): IRange
{
return match ($type) {
RangeType::TALLY => new RangeTally(),
default => new Range(),
};
}
/**
* Delta sync is not supported for IMAP (no CONDSTORE/QRESYNC initially).
* Returns an empty Delta so callers detect the absence of changes gracefully.
*/
public function entityDelta(string|int $collection, string $signature, string $detail = 'ids'): Delta
{
return new Delta(signature: $signature);
}
public function entityExtant(string|int $collection, string|int ...$identifiers): array
{
$this->initialize();
$allUids = $this->mailService->entityList((string) $collection);
$uidSet = array_flip($allUids); // int[] → [uid => index]
$extant = [];
foreach ($identifiers as $id) {
$extant[$id] = isset($uidSet[(int) $id]);
}
return $extant;
}
public function entityFetch(string|int $collection, string|int ...$identifiers): array
{
$this->initialize();
$uids = array_map('intval', $identifiers);
return $this->mailService->entityFetch((string) $collection, ...$uids);
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXM\ProviderImapMail\Providers;
use KTXF\Resource\Provider\ResourceServiceIdentityBasic;
/**
* IMAP Service Basic Identity
*
* Username / password (or token) authentication.
*/
class ServiceIdentityBasic implements ResourceServiceIdentityBasic
{
public function __construct(
private string $identity = '',
private string $secret = '',
) {}
public function toStore(): array
{
return [
'type' => self::TYPE_BASIC,
'identity' => $this->identity,
'secret' => $this->secret,
];
}
public function fromStore(array $data): static
{
return new static(
identity: $data['identity'] ?? '',
secret: $data['secret'] ?? '',
);
}
public function jsonSerialize(): array
{
return [
'type' => self::TYPE_BASIC,
'identity' => $this->identity,
// Secret intentionally omitted from serialisation
];
}
public function jsonDeserialize(array|string $data): static
{
if (is_string($data)) {
$data = json_decode($data, true);
}
$this->identity = $data['identity'] ?? '';
$this->secret = $data['secret'] ?? '';
return $this;
}
public function type(): string
{
return self::TYPE_BASIC;
}
public function getIdentity(): string { return $this->identity; }
public function setIdentity(string $v): void { $this->identity = $v; }
public function getSecret(): string { return $this->secret; }
public function setSecret(string $v): void { $this->secret = $v; }
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXM\ProviderImapMail\Providers;
use Gricob\IMAP\Configuration;
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
/**
* IMAP Service Location
*
* Connection details for an IMAP server (host / port / encryption).
*/
class ServiceLocation implements ResourceServiceLocationInterface
{
public function __construct(
private string $host = '',
private int $port = 993,
private string $encryption = 'ssl', // ssl | tls | starttls | none
private bool $verifyPeer = true,
private bool $verifyPeerName = true,
private bool $allowSelfSigned = false,
) {}
// ── Serialisation ────────────────────────────────────────────────────────
public function toStore(): array
{
return $this->jsonSerialize();
}
public function fromStore(array $data): static
{
return $this->jsonDeserialize($data);
}
public function jsonSerialize(): array
{
return array_filter([
'type' => self::TYPE_URI,
'host' => $this->host,
'port' => $this->port,
'encryption' => $this->encryption,
'verifyPeer' => $this->verifyPeer,
'verifyPeerName' => $this->verifyPeerName,
'allowSelfSigned' => $this->allowSelfSigned,
], fn($v) => $v !== null && $v !== '');
}
public function jsonDeserialize(array|string $data): static
{
if (is_string($data)) {
$data = json_decode($data, true);
}
$this->host = $data['host'] ?? '';
$this->port = (int)($data['port'] ?? 993);
$this->encryption = $data['encryption'] ?? 'ssl';
$this->verifyPeer = $data['verifyPeer'] ?? true;
$this->verifyPeerName = $data['verifyPeerName'] ?? true;
$this->allowSelfSigned = $data['allowSelfSigned'] ?? false;
return $this;
}
// ── ResourceServiceLocationInterface ─────────────────────────────────────
public function type(): string
{
return self::TYPE_URI;
}
public function location(): string
{
return $this->encryption . '://' . $this->host . ':' . $this->port;
}
// ── Accessors ────────────────────────────────────────────────────────────
public function getHost(): string { return $this->host; }
public function setHost(string $v): void { $this->host = $v; }
public function getPort(): int { return $this->port; }
public function setPort(int $v): void { $this->port = $v; }
public function getEncryption(): string { return $this->encryption; }
public function setEncryption(string $v): void { $this->encryption = $v; }
public function getVerifyPeer(): bool { return $this->verifyPeer; }
public function setVerifyPeer(bool $v): void { $this->verifyPeer = $v; }
public function getVerifyPeerName(): bool { return $this->verifyPeerName; }
public function setVerifyPeerName(bool $v): void { $this->verifyPeerName = $v; }
public function getAllowSelfSigned(): bool { return $this->allowSelfSigned; }
public function setAllowSelfSigned(bool $v): void { $this->allowSelfSigned = $v; }
// ── gricob helper ────────────────────────────────────────────────────────
/**
* Build a Gricob IMAP Configuration from this location.
*
* gricob passes the transport directly to stream_socket_client:
* 'ssl' → ssl://host:port (implicit TLS, port 993)
* 'tcp' → tcp://host:port (plain TCP; STARTTLS negotiation is not
* supported by gricob, so starttls/none both
* use plain TCP)
*/
public function toConfiguration(): Configuration
{
// Map our encryption label to a stream_socket_client transport.
// gricob has no STARTTLS negotiation, so starttls falls back to tcp.
$transport = match ($this->encryption) {
'ssl', 'tls' => 'ssl',
default => 'tcp', // starttls, none
};
return new Configuration(
transport: $transport,
host: $this->host,
port: $this->port,
verifyPeer: $this->verifyPeer,
verifyPeerName: $this->verifyPeerName,
allowSelfSigned: $this->allowSelfSigned,
useUid: true,
);
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXM\ProviderImapMail\Providers;
enum ServiceMode: string
{
case Live = 'live';
case Cached = 'cached';
}
enum CacheSyncStrategy: string
{
case Manual = 'manual';
case Interval = 'interval';
case Push = 'push';
}
/**
* Per-service operational settings for the IMAP provider.
*
* - debug: Attach a PSR-3 logger to the IMAP client when true
* - mode: live (all reads/writes hit IMAP) | cached (reads from MongoDB)
* - cacheSync: How the cache is kept fresh (only relevant in cached mode)
*/
class ServiceSettings
{
public function __construct(
private bool $debug = false,
private ServiceMode $mode = ServiceMode::Live,
private CacheSyncStrategy $cacheSync = CacheSyncStrategy::Interval,
) {}
// ── Serialisation ────────────────────────────────────────────────────────
public function toStore(): array
{
return [
'debug' => $this->debug,
'mode' => $this->mode->value,
'cacheSync' => $this->cacheSync->value,
];
}
public function fromStore(array $data): static
{
$this->debug = $data['debug'] ?? false;
$this->mode = ServiceMode::from($data['mode'] ?? ServiceMode::Live->value);
$this->cacheSync = CacheSyncStrategy::from($data['cacheSync'] ?? CacheSyncStrategy::Interval->value);
return $this;
}
public function jsonSerialize(): array
{
return $this->toStore();
}
public function jsonDeserialize(array|string $data): static
{
if (is_string($data)) {
$data = json_decode($data, true);
}
return $this->fromStore($data);
}
// ── Accessors ────────────────────────────────────────────────────────────
public function getDebug(): bool { return $this->debug; }
public function setDebug(bool $v): static { $this->debug = $v; return $this; }
public function getMode(): ServiceMode { return $this->mode; }
public function setMode(ServiceMode $v): static { $this->mode = $v; return $this; }
public function getCacheSync(): CacheSyncStrategy { return $this->cacheSync; }
public function setCacheSync(CacheSyncStrategy $v): static { $this->cacheSync = $v; return $this; }
}