diff --git a/composer.json b/composer.json index 1601ec2..ae2f837 100644 --- a/composer.json +++ b/composer.json @@ -16,8 +16,8 @@ } ], "require": { - "php": "^8.1", - "directorytree/imapengine": "^1.22.3", + "php": "^8.2", + "directorytree/imapengine": "2.0.x-dev", "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0" }, "require-dev": { diff --git a/config/imap.php b/config/imap.php index 17dc7fe..0b3f332 100644 --- a/config/imap.php +++ b/config/imap.php @@ -23,7 +23,7 @@ 'password' => env('IMAP_PASSWORD'), 'encryption' => env('IMAP_ENCRYPTION', 'ssl'), 'validate_cert' => env('IMAP_VALIDATE_CERT', true), - 'authentication' => env('IMAP_AUTHENTICATION', 'plain'), + 'authentication' => env('IMAP_AUTHENTICATION', 'login'), 'proxy' => [ 'socket' => env('IMAP_PROXY_SOCKET'), 'username' => env('IMAP_PROXY_USERNAME'), diff --git a/src/Commands/ConfigureIdleQuery.php b/src/Commands/ConfigureIdleQuery.php index 20202f5..aa6d691 100644 --- a/src/Commands/ConfigureIdleQuery.php +++ b/src/Commands/ConfigureIdleQuery.php @@ -2,6 +2,7 @@ namespace DirectoryTree\ImapEngine\Laravel\Commands; +use DirectoryTree\ImapEngine\MessageData; use DirectoryTree\ImapEngine\MessageQueryInterface; class ConfigureIdleQuery @@ -19,15 +20,15 @@ public function __construct( public function __invoke(MessageQueryInterface $query): MessageQueryInterface { if (in_array('flags', $this->with)) { - $query->withFlags(); + $query->with(MessageData::flags()); } if (in_array('body', $this->with)) { - $query->withBody(); + $query->with(MessageData::text()->peek()); } if (in_array('headers', $this->with)) { - $query->withHeaders(); + $query->with(MessageData::headers()->peek()); } return $query; diff --git a/src/Commands/HandleMailboxEventReceived.php b/src/Commands/HandleMailboxEventReceived.php new file mode 100644 index 0000000..2a8eaa0 --- /dev/null +++ b/src/Commands/HandleMailboxEventReceived.php @@ -0,0 +1,40 @@ +command->info("Mailbox event received: [{$event->type()}] in [{$event->folder()}]"); + + // Selecting the folder after reconnecting must not reset the retry budget. + if (! $event instanceof FolderSelected) { + $this->attempts = 0; + + $this->lastReceivedAt = Date::now(); + } + + Event::dispatch(new MailboxEventReceived($event, $this->mailbox)); + } +} diff --git a/src/Commands/WatchMailbox.php b/src/Commands/WatchMailbox.php index bb1c3b9..6ff30a1 100644 --- a/src/Commands/WatchMailbox.php +++ b/src/Commands/WatchMailbox.php @@ -2,15 +2,18 @@ namespace DirectoryTree\ImapEngine\Laravel\Commands; +use DirectoryTree\ImapEngine\Exceptions\ImapConnectionException; +use DirectoryTree\ImapEngine\Exceptions\ImapStreamException; use DirectoryTree\ImapEngine\FolderInterface; +use DirectoryTree\ImapEngine\Idle\Events\EventInterface; use DirectoryTree\ImapEngine\Laravel\Events\MailboxWatchAttemptsExceeded; use DirectoryTree\ImapEngine\Laravel\Facades\Imap; use DirectoryTree\ImapEngine\Laravel\Support\LoopInterface; use DirectoryTree\ImapEngine\MailboxInterface; -use Exception; +use DirectoryTree\ImapEngine\MessageInterface; use Illuminate\Console\Command; use Illuminate\Support\Facades\Event; -use Illuminate\Support\Str; +use Illuminate\Support\Sleep; use Symfony\Component\Console\Exception\InvalidOptionException; class WatchMailbox extends Command @@ -23,31 +26,41 @@ class WatchMailbox extends Command protected $signature = 'imap:watch {mailbox : The mailbox to watch} {folder? : The folder to watch} - {--method=idle : The watch method (idle or poll)} - {--with= : Comma-separated list of message parts to fetch} - {--timeout=30 : The timeout in minutes} - {--attempts=5 : Maximum number of retry attempts} - {--debug=false : Enable debug mode}'; + {--method=idle : The watch method (idle, poll, or events)} + {--with= : Comma-separated message parts to fetch with idle or poll (flags, body, headers)} + {--timeout=30 : The IDLE renewal interval or polling frequency in seconds} + {--attempts=5 : Maximum connection retries before a message or mailbox update is received}'; /** * The console command description. * * @var string */ - protected $description = 'Watch a mailbox for new messages.'; + protected $description = 'Watch a mailbox for new messages or mailbox events.'; /** * Execute the console command. */ public function handle(LoopInterface $loop): void { - if (! in_array($method = $this->option('method'), ['idle', 'poll'])) { - throw new InvalidOptionException("Invalid method [{$method}]. Valid options are [idle, poll]."); + if (! in_array($method = $this->option('method'), ['idle', 'poll', 'events'])) { + throw new InvalidOptionException("Invalid method [{$method}]. Valid options are [idle, poll, events]."); } - $mailbox = Imap::mailbox($name = $this->argument('mailbox')); + $timeout = (int) $this->option('timeout'); + + $retries = (int) $this->option('attempts'); - $with = explode(',', $this->option('with')); + $with = array_filter( + array_map('trim', explode(',', $this->option('with'))), + filled(...), + ); + + if ($invalid = array_diff($with, ['flags', 'body', 'headers'])) { + throw new InvalidOptionException('Invalid message parts ['.implode(', ', $invalid).']. Valid options are [flags, body, headers].'); + } + + $mailbox = Imap::mailbox($name = $this->argument('mailbox')); $this->info("Watching mailbox [$name]..."); @@ -55,35 +68,39 @@ public function handle(LoopInterface $loop): void $lastReceivedAt = null; - $loop->run(function () use ($mailbox, $name, $with, &$attempts, &$lastReceivedAt) { + $handler = $method === 'events' + ? new HandleMailboxEventReceived($this, $name, $attempts, $lastReceivedAt) + : new HandleMessageReceived($this, $name, $attempts, $lastReceivedAt); + + $loop->run(function () use ($mailbox, $name, $method, $with, $timeout, $retries, $handler, &$attempts, &$lastReceivedAt) { + $receiving = false; + + $callback = function (MessageInterface|EventInterface $event) use ($handler, &$receiving) { + $receiving = true; + + $handler($event); + + $receiving = false; + }; + try { $folder = $this->folder($mailbox); - match ($this->option('method')) { - 'idle' => $folder->idle( - new HandleMessageReceived($this, $name, $attempts, $lastReceivedAt), - new ConfigureIdleQuery($with), - $this->option('timeout'), - ), - 'poll' => $folder->poll( - new HandleMessageReceived($this, $name, $attempts, $lastReceivedAt), - new ConfigureIdleQuery($with), - $this->option('timeout'), - ), - }; - } catch (Exception $e) { - if ($this->isMessageMissing($e)) { - return; + if ($method === 'events') { + $folder->events($callback, $timeout); + } else { + $folder->{$method}($callback, new ConfigureIdleQuery($with), $timeout); } - - if ($this->isDisconnection($e)) { - sleep(2); - - return; + } catch (ImapConnectionException|ImapStreamException $e) { + // Listener failures must not restart the watcher, even for IMAP errors. + if ($receiving) { + throw $e; } - if ($attempts >= $this->option('attempts')) { - $this->info("Exception: {$e->getMessage()}"); + $mailbox->disconnect(); + + if ($attempts >= $retries) { + $this->error("Exception: {$e->getMessage()}"); Event::dispatch( new MailboxWatchAttemptsExceeded($name, $attempts, $e, $lastReceivedAt) @@ -93,6 +110,10 @@ public function handle(LoopInterface $loop): void } $attempts++; + + $this->warn("Connection failed. Retrying [$attempts/$retries] in 2 seconds."); + + Sleep::for(2)->seconds(); } }); } @@ -106,32 +127,4 @@ protected function folder(MailboxInterface $mailbox): FolderInterface ? $mailbox->folders()->findOrFail($folder) : $mailbox->inbox(); } - - /** - * Determine if the exception is due to a message missing error. - */ - protected function isMessageMissing(Exception $e): bool - { - return Str::contains($e->getMessage(), [ - 'no longer exist', - ], true); - } - - /** - * Determine if the exception is caused by a disconnection. - */ - protected function isDisconnection(Exception $e): bool - { - return Str::contains($e->getMessage(), [ - 'connection reset by peer', - 'temporary system problem', - 'failed to fetch content', - 'connection failed', - 'empty response', - 'not connected', - 'no response', - 'broken pipe', - 'unavailable', - ], true); - } } diff --git a/src/Events/MailboxEventReceived.php b/src/Events/MailboxEventReceived.php new file mode 100644 index 0000000..4acdec3 --- /dev/null +++ b/src/Events/MailboxEventReceived.php @@ -0,0 +1,16 @@ +swap($mailbox, $fake); diff --git a/src/ImapManager.php b/src/ImapManager.php index bddcba4..51e8ff4 100644 --- a/src/ImapManager.php +++ b/src/ImapManager.php @@ -49,7 +49,7 @@ public function mailbox(string $name): MailboxInterface */ public function register(string $name, array $config): static { - $this->mailboxes[$name] = $this->build($config); + $this->swap($name, $this->build($config)); return $this; } @@ -63,20 +63,30 @@ public function build(array $config): MailboxInterface } /** - * Remove a mailbox from the in-memory cache. + * Disconnect a mailbox and remove it from the in-memory cache. */ public function forget(string $name): static { + if (isset($this->mailboxes[$name])) { + $this->mailboxes[$name]->disconnect(); + } + unset($this->mailboxes[$name]); return $this; } /** - * Swap out a mailbox instance with a new one. + * Disconnect the previous mailbox and replace it with a new instance. */ public function swap(string $name, MailboxInterface $mailbox): void { + if (($this->mailboxes[$name] ?? null) === $mailbox) { + return; + } + + $this->forget($name); + $this->mailboxes[$name] = $mailbox; } } diff --git a/src/ImapServiceProvider.php b/src/ImapServiceProvider.php index c1ab8e6..99c6193 100644 --- a/src/ImapServiceProvider.php +++ b/src/ImapServiceProvider.php @@ -13,6 +13,8 @@ class ImapServiceProvider extends ServiceProvider */ public function register(): void { + $this->mergeConfigFrom(__DIR__.'/../config/imap.php', 'imap'); + $this->app->singleton(ImapManager::class, function () { return new ImapManager(config('imap', [])); }); diff --git a/tests/Commands/ConfigureIdleQueryTest.php b/tests/Commands/ConfigureIdleQueryTest.php index 499e123..3a6422f 100644 --- a/tests/Commands/ConfigureIdleQueryTest.php +++ b/tests/Commands/ConfigureIdleQueryTest.php @@ -1,6 +1,10 @@ messages()); + $query = $folder->messages(); - expect($query)->toBeInstanceOf(MessageQueryInterface::class); + expect($configure($query))->toBe($query); }); -it('configures query when "with" is present', function () { - $folder = new FakeFolder; +it('configures the requested fetch item', function (string $part, string $item) { + $query = Mockery::mock(MessageQueryInterface::class); + + $query->shouldReceive('with')->once()->withArgs( + fn (FetchItemInterface $fetch) => $fetch->toImap() === $item + )->andReturnSelf(); + + $configure = new ConfigureIdleQuery([$part]); + + expect($configure($query))->toBe($query); +})->with([ + 'flags' => ['flags', 'FLAGS'], + 'body without marking read' => ['body', 'BODY.PEEK[TEXT]'], + 'headers without marking read' => ['headers', 'BODY.PEEK[HEADER]'], +]); + +it('adds requested items without replacing existing fetch items', function () { + $query = Mockery::mock(MessageQueryInterface::class); + + $query->shouldReceive('with')->once()->withArgs( + fn (FetchItemInterface $item) => $item->toImap() === 'FLAGS' + )->andReturnSelf(); + + $query->shouldReceive('with')->once()->withArgs( + fn (FetchItemInterface $item) => $item->toImap() === 'BODY.PEEK[TEXT]' + )->andReturnSelf(); + + $query->shouldReceive('with')->once()->withArgs( + fn (FetchItemInterface $item) => $item->toImap() === 'BODY.PEEK[HEADER]' + )->andReturnSelf(); - $configure = new ConfigureIdleQuery([ - 'flags', 'body', 'headers', + $query->shouldNotReceive('only'); + + $configure = new ConfigureIdleQuery(['flags', 'body', 'headers']); + + expect($configure($query))->toBe($query); +}); + +it('fetches the requested data through the v2 query without marking messages read', function () { + $connection = ImapConnection::fake([ + '* OK Ready', + 'TAG1 OK Logged in', + '* 1 EXISTS', + 'TAG2 OK Selected', + '* SEARCH 42', + 'TAG3 OK Search completed', + '* 1 FETCH (UID 42 FLAGS () BODY[TEXT] "Hello" BODY[HEADER] "Subject: Test")', + 'TAG4 OK Fetch completed', ]); - $query = $configure($folder->messages()); + $mailbox = new Mailbox(['host' => 'localhost', 'encryption' => null]); + $mailbox->connect($connection); + + $folder = new Folder($mailbox, 'INBOX'); + $configure = new ConfigureIdleQuery(['flags', 'body', 'headers']); + + $message = $configure($folder->messages())->get()->first(); + + expect($message->uid())->toBe(42); + expect($message->data()->get('BODY[TEXT]'))->toBe('Hello'); - expect($query->isFetchingBody())->toBeTrue(); - expect($query->isFetchingFlags())->toBeTrue(); - expect($query->isFetchingHeaders())->toBeTrue(); + $connection->stream()->assertWritten('TAG4 UID FETCH 42 (FLAGS BODY.PEEK[TEXT] BODY.PEEK[HEADER])'); }); diff --git a/tests/Commands/WatchMailboxEventsTest.php b/tests/Commands/WatchMailboxEventsTest.php new file mode 100644 index 0000000..3338c76 --- /dev/null +++ b/tests/Commands/WatchMailboxEventsTest.php @@ -0,0 +1,164 @@ +open(); + $stream->feed([$response]); + + $parser = new ImapParser(new ImapTokenizer($stream)); + $event = new $type('Archive', $parser->next()); + + $folder = new FakeFolder('Archive'); + $folder->setIdleEvents([$event]); + + Imap::fake('test', folders: [$folder]); + + App::bind(LoopInterface::class, LoopFake::class); + + Event::fake(); + + artisan(WatchMailbox::class, [ + 'mailbox' => 'test', + 'folder' => 'Archive', + '--method' => 'events', + ])->assertSuccessful(); + + Event::assertDispatchedTimes(MailboxEventReceived::class, 2); + + Event::assertDispatched(fn (MailboxEventReceived $received) => ( + $received->mailbox === 'test' + && $received->event === $event + && $received->event->folder() === 'Archive' + )); + + Event::assertDispatched(fn (MailboxEventReceived $received) => ( + $received->event instanceof FolderSelected + && $received->event->folder() === 'Archive' + )); + + Event::assertNotDispatched(MessageReceived::class); +})->with([ + 'message count' => [MessagesExist::class, '* 10 EXISTS'], + 'flag changes' => [MessageFetched::class, '* 2 FETCH (UID 42 FLAGS (\\Seen))'], + 'expunged sequence number' => [MessageExpunged::class, '* 3 EXPUNGE'], + 'vanished UIDs' => [MessagesVanished::class, '* VANISHED 7:9'], + 'other server response' => [UnknownEvent::class, '* OK Still here'], +]); + +it('does not retry mailbox event listener failures', function (string $exception) { + Sleep::fake(); + + Imap::fake('test', folders: [new FakeFolder('INBOX')]); + + $failure = new $exception('Listener failed'); + + Event::fake([MailboxWatchAttemptsExceeded::class]); + + Event::listen(MailboxEventReceived::class, fn () => throw $failure); + + expect(fn () => artisan(WatchMailbox::class, [ + 'mailbox' => 'test', + '--method' => 'events', + ])->run())->toThrow($failure); + + Event::assertNotDispatched(MailboxWatchAttemptsExceeded::class); + + Sleep::assertNeverSlept(); +})->with([ + 'application failure' => RuntimeException::class, + 'connection failure inside listener' => ImapConnectionClosedException::class, +]); + +it('does not reset retries merely because the folder was selected again', function () { + Sleep::fake(); + + $failure = new ImapConnectionClosedException('Connection closed'); + $event = new FolderSelected('INBOX', new Result); + + $folder = Mockery::mock(FolderInterface::class); + $folder->shouldReceive('events')->twice()->andReturnUsing(function ($callback, $timeout) use ($event, $failure) { + expect($timeout)->toBe(45); + + $callback($event); + + throw $failure; + }); + + $mailbox = Mockery::mock(MailboxInterface::class); + $mailbox->shouldReceive('inbox')->twice()->andReturn($folder); + $mailbox->shouldReceive('disconnect')->twice(); + + Imap::shouldReceive('mailbox')->once()->with('test')->andReturn($mailbox); + + Event::fake(); + + expect(fn () => artisan(WatchMailbox::class, [ + 'mailbox' => 'test', + '--method' => 'events', + '--timeout' => '45', + '--attempts' => 1, + ])->run())->toThrow($failure); + + Event::assertDispatched(fn (MailboxWatchAttemptsExceeded $received) => ( + $received->attempts === 1 && $received->lastReceivedAt === null + )); + + Sleep::assertSleptTimes(1); +}); + +it('resets retries and records receipt when a mailbox update arrives', function () { + $stream = new FakeStream; + $stream->open(); + $stream->feed(['* 3 EXPUNGE']); + + $parser = new ImapParser(new ImapTokenizer($stream)); + $event = new MessageExpunged('INBOX', $parser->next()); + + $command = Mockery::mock(WatchMailbox::class); + $command->shouldReceive('info')->once(); + + $attempts = 3; + $lastReceivedAt = null; + $before = Date::now(); + + $handler = new HandleMailboxEventReceived($command, 'test', $attempts, $lastReceivedAt); + + Event::fake(); + + $handler($event); + + expect($attempts)->toBe(0); + expect($lastReceivedAt)->not->toBeNull(); + expect($lastReceivedAt->betweenIncluded($before, Date::now()))->toBeTrue(); + + Event::assertDispatched(fn (MailboxEventReceived $received) => $received->event === $event); +}); diff --git a/tests/Commands/WatchMailboxTest.php b/tests/Commands/WatchMailboxTest.php index 7dac3b7..1b430cb 100644 --- a/tests/Commands/WatchMailboxTest.php +++ b/tests/Commands/WatchMailboxTest.php @@ -2,18 +2,28 @@ namespace DirectoryTree\ImapEngine\Laravel\Tests; +use DirectoryTree\ImapEngine\Exceptions\ImapConnectionClosedException; +use DirectoryTree\ImapEngine\Exceptions\ImapConnectionFailedException; +use DirectoryTree\ImapEngine\Exceptions\ImapConnectionTimedOutException; +use DirectoryTree\ImapEngine\Exceptions\ImapStreamException; +use DirectoryTree\ImapEngine\FolderInterface; use DirectoryTree\ImapEngine\Laravel\Commands\WatchMailbox; use DirectoryTree\ImapEngine\Laravel\Events\MailboxWatchAttemptsExceeded; use DirectoryTree\ImapEngine\Laravel\Events\MessageReceived; use DirectoryTree\ImapEngine\Laravel\Facades\Imap; use DirectoryTree\ImapEngine\Laravel\Support\LoopFake; use DirectoryTree\ImapEngine\Laravel\Support\LoopInterface; +use DirectoryTree\ImapEngine\MailboxInterface; +use DirectoryTree\ImapEngine\MessageData\FetchItemInterface; +use DirectoryTree\ImapEngine\MessageQueryInterface; use DirectoryTree\ImapEngine\Testing\FakeFolder; use DirectoryTree\ImapEngine\Testing\FakeMessage; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Sleep; use InvalidArgumentException; +use Mockery; use RuntimeException; use Symfony\Component\Console\Exception\InvalidOptionException; @@ -74,6 +84,7 @@ artisan(WatchMailbox::class, [ 'mailbox' => 'test', '--method' => $method, + '--with' => 'flags,body,headers', ])->assertSuccessful(); Event::assertDispatched(fn (MessageReceived $event) => ( @@ -81,46 +92,49 @@ )); })->with(['idle', 'poll']); -it('dispatches event when failure attempts have been reached', function () { - Config::set('imap.mailboxes.test', [ - 'host' => 'localhost', - 'port' => 993, - 'encryption' => 'ssl', - 'username' => '', - 'password' => '', - ]); +it('limits connection retries and disconnects before retrying', function (string $method, string $exception, int $retries) { + Sleep::fake(); - Imap::fake('test', folders: [ - new class('inbox') extends FakeFolder - { - public function idle( - callable $callback, - ?callable $query = null, - int|callable $timeout = 300 - ): void { - throw new RuntimeException('Simulated exception'); - } - }, - ]); + $failure = new $exception('Simulated connection failure'); + + $folder = Mockery::mock(FolderInterface::class); + $folder->shouldReceive($method)->times($retries + 1)->andThrow($failure); + + $mailbox = Mockery::mock(MailboxInterface::class); + + for ($attempt = 0; $attempt <= $retries; $attempt++) { + $mailbox->shouldReceive('inbox')->once()->ordered()->andReturn($folder); + $mailbox->shouldReceive('disconnect')->once()->ordered(); + } + + Imap::shouldReceive('mailbox')->once()->with('test')->andReturn($mailbox); Event::fake(); - try { + expect(function () use ($method, $retries) { artisan(WatchMailbox::class, [ 'mailbox' => 'test', - '--attempts' => 5, + '--method' => $method, + '--attempts' => $retries, ]); - } catch (RuntimeException) { - // Do nothing. - } + })->toThrow($failure); - Event::assertDispatched(function (MailboxWatchAttemptsExceeded $event) { - return $event->attempts === 5 + Event::assertDispatchedTimes(MailboxWatchAttemptsExceeded::class, 1); + + Event::assertDispatched(function (MailboxWatchAttemptsExceeded $event) use ($retries, $failure) { + return $event->attempts === $retries && $event->mailbox === 'test' && is_null($event->lastReceivedAt) - && $event->exception->getMessage() === 'Simulated exception'; + && $event->exception === $failure; }); -}); + + Sleep::assertSleptTimes($retries); +})->with(['idle', 'poll', 'events'])->with([ + 'closed connection' => ImapConnectionClosedException::class, + 'failed connection' => ImapConnectionFailedException::class, + 'timed out connection' => ImapConnectionTimedOutException::class, + 'stream failure' => ImapStreamException::class, +])->with([0, 2]); it('throws exception when invalid method is provided', function () { Config::set('imap.mailboxes.test', [ @@ -143,5 +157,147 @@ public function idle( ]); })->throws( InvalidOptionException::class, - 'Invalid method [invalid]. Valid options are [idle, poll].' + 'Invalid method [invalid]. Valid options are [idle, poll, events].' ); + +it('rejects invalid options before resolving a mailbox', function (string $option, string $value) { + Imap::shouldReceive('mailbox')->never(); + + artisan(WatchMailbox::class, ['mailbox' => 'test', $option => $value]); +})->with([ + 'unknown message part' => ['--with', 'flags,invalid'], + 'zero message part' => ['--with', '0'], +])->throws(InvalidOptionException::class); + +it('passes normalized message parts and an integer interval to the watcher', function (string $method) { + $query = Mockery::mock(MessageQueryInterface::class); + + $query->shouldReceive('with')->once()->withArgs( + fn (FetchItemInterface $item) => $item->toImap() === 'FLAGS' + )->andReturnSelf(); + + $query->shouldReceive('with')->once()->withArgs( + fn (FetchItemInterface $item) => $item->toImap() === 'BODY.PEEK[TEXT]' + )->andReturnSelf(); + + $query->shouldReceive('with')->once()->withArgs( + fn (FetchItemInterface $item) => $item->toImap() === 'BODY.PEEK[HEADER]' + )->andReturnSelf(); + + $folder = Mockery::mock(FolderInterface::class); + $folder->shouldReceive($method)->once()->andReturnUsing(function ($callback, $configure, $interval) use ($query) { + expect($interval)->toBe(45); + expect($configure($query))->toBe($query); + }); + + $mailbox = Mockery::mock(MailboxInterface::class); + $mailbox->shouldReceive('inbox')->once()->andReturn($folder); + + Imap::shouldReceive('mailbox')->once()->with('test')->andReturn($mailbox); + + App::bind(LoopInterface::class, LoopFake::class); + + artisan(WatchMailbox::class, [ + 'mailbox' => 'test', + '--method' => $method, + '--timeout' => '45', + '--with' => ' flags, body, headers, ', + ])->assertSuccessful(); +})->with(['idle', 'poll']); + +it('does not retry listener failures', function (string $method, string $exception) { + Sleep::fake(); + + Config::set('imap.mailboxes.test', []); + + Imap::fake('test', folders: [ + new FakeFolder('inbox', messages: [new FakeMessage(uid: 1)]), + ]); + + $failure = new $exception('Connection unavailable: message no longer exists'); + + Event::fake([MailboxWatchAttemptsExceeded::class]); + + Event::listen(MessageReceived::class, fn () => throw $failure); + + expect(fn () => artisan(WatchMailbox::class, [ + 'mailbox' => 'test', + '--method' => $method, + ])->run())->toThrow($failure); + + Event::assertNotDispatched(MailboxWatchAttemptsExceeded::class); + + Sleep::assertNeverSlept(); +})->with(['idle', 'poll'])->with([ + 'application exception' => RuntimeException::class, + 'listener connection exception' => ImapConnectionClosedException::class, + 'listener stream exception' => ImapStreamException::class, + 'application error' => \Error::class, +]); + +it('does not retry non-connection watcher failures', function (string $method) { + Sleep::fake(); + + $failure = new RuntimeException('Message no longer exists'); + + $folder = Mockery::mock(FolderInterface::class); + $folder->shouldReceive($method)->once()->andThrow($failure); + + $mailbox = Mockery::mock(MailboxInterface::class); + $mailbox->shouldReceive('inbox')->once()->andReturn($folder); + $mailbox->shouldNotReceive('disconnect'); + + Imap::shouldReceive('mailbox')->once()->with('test')->andReturn($mailbox); + + Event::fake(); + + expect(fn () => artisan(WatchMailbox::class, [ + 'mailbox' => 'test', + '--method' => $method, + ])->run())->toThrow($failure); + + Event::assertNotDispatched(MailboxWatchAttemptsExceeded::class); + + Sleep::assertNeverSlept(); +})->with(['idle', 'poll']); + +it('resets retries after receiving a message and records the last receipt', function (string $method) { + Sleep::fake(); + + $failure = new ImapConnectionClosedException('Connection closed'); + + $message = new FakeMessage(uid: 42); + + $folder = Mockery::mock(FolderInterface::class); + $folder->shouldReceive($method)->once()->ordered()->andThrow($failure); + $folder->shouldReceive($method)->once()->ordered()->andReturnUsing(function ($callback) use ($message, $failure) { + $callback($message); + + throw $failure; + }); + $folder->shouldReceive($method)->once()->ordered()->andThrow($failure); + + $mailbox = Mockery::mock(MailboxInterface::class); + $mailbox->shouldReceive('inbox')->times(3)->andReturn($folder); + $mailbox->shouldReceive('disconnect')->times(3); + + Imap::shouldReceive('mailbox')->once()->with('test')->andReturn($mailbox); + + Event::fake(); + + expect(fn () => artisan(WatchMailbox::class, [ + 'mailbox' => 'test', + '--method' => $method, + '--attempts' => 1, + ])->run())->toThrow($failure); + + Event::assertDispatchedTimes(MessageReceived::class, 1); + + Event::assertDispatched(fn (MailboxWatchAttemptsExceeded $event) => ( + $event->attempts === 1 + && $event->lastReceivedAt !== null + && $event->exception === $failure + )); + + Sleep::assertSleptTimes(2); +})->with(['idle', 'poll']); diff --git a/tests/Facades/ImapTest.php b/tests/Facades/ImapTest.php index 06fe4a9..8564c47 100644 --- a/tests/Facades/ImapTest.php +++ b/tests/Facades/ImapTest.php @@ -39,6 +39,14 @@ expect($fake->config('username'))->toBe('fake@example.com'); }); +it('can fake mailbox capabilities using the v2 capability collection', function () { + $fake = Imap::fake('default', capabilities: ['IMAP4rev1', 'IDLE', 'CONDSTORE']); + + expect($fake->capabilities()->supports('IDLE'))->toBeTrue(); + expect($fake->capabilities()->supports('CONDSTORE'))->toBeTrue(); + expect($fake->capabilities()->supports('QRESYNC'))->toBeFalse(); +}); + it('can fake a mailbox with folders', function () { $inbox = new FakeFolder('inbox', ['\\HasNoChildren']); $sent = new FakeFolder('sent', ['\\HasNoChildren']); @@ -75,9 +83,9 @@ $fake = Imap::fake('default', [], [$inbox]); $messageContent = "From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: Test Subject\r\n\r\nTest message body"; - $uid = $fake->inbox()->messages()->append($messageContent); + $result = $fake->inbox()->messages()->append($messageContent); - expect($uid)->toBe(1); + expect($result->uid())->toBe(1); $messages = $fake->inbox()->messages(); diff --git a/tests/ImapManagerTest.php b/tests/ImapManagerTest.php index f355200..cd811ea 100644 --- a/tests/ImapManagerTest.php +++ b/tests/ImapManagerTest.php @@ -3,6 +3,7 @@ use DirectoryTree\ImapEngine\Laravel\ImapManager; use DirectoryTree\ImapEngine\Mailbox; use DirectoryTree\ImapEngine\MailboxInterface; +use DirectoryTree\ImapEngine\Testing\FakeMailbox; beforeEach(function () { $this->config = [ @@ -67,3 +68,81 @@ expect($mailbox)->toBeInstanceOf(MailboxInterface::class); expect($mailbox)->toBeInstanceOf(Mailbox::class); }); + +it('disconnects a forgotten mailbox and rebuilds it from configuration', function () { + $manager = new ImapManager(['mailboxes' => ['default' => ['host' => 'imap.example.com']]]); + + $mailbox = Mockery::mock(MailboxInterface::class); + $mailbox->shouldReceive('disconnect')->once(); + + $manager->swap('default', $mailbox); + + expect($manager->forget('default'))->toBe($manager); + + $replacement = $manager->mailbox('default'); + + expect($replacement)->not->toBe($mailbox); + expect($replacement->config('host'))->toBe('imap.example.com'); + expect($manager->mailbox('default'))->toBe($replacement); +}); + +it('does not resolve a mailbox when forgetting an uncached name', function () { + $manager = new ImapManager([]); + + expect($manager->forget('missing'))->toBe($manager); +}); + +it('disconnects the previous mailbox when swapping instances', function () { + $manager = new ImapManager([]); + + $mailbox = Mockery::mock(MailboxInterface::class); + $mailbox->shouldReceive('disconnect')->once(); + + $replacement = FakeMailbox::make(); + + $manager->swap('default', $mailbox); + $manager->swap('default', $replacement); + + expect($manager->mailbox('default'))->toBe($replacement); +}); + +it('does not disconnect when swapping in the same mailbox', function () { + $manager = new ImapManager([]); + + $mailbox = Mockery::mock(MailboxInterface::class); + $mailbox->shouldNotReceive('disconnect'); + + $manager->swap('default', $mailbox); + $manager->swap('default', $mailbox); + + expect($manager->mailbox('default'))->toBe($mailbox); +}); + +it('disconnects the previous mailbox when registering a replacement', function () { + $manager = new ImapManager([]); + + $mailbox = Mockery::mock(MailboxInterface::class); + $mailbox->shouldReceive('disconnect')->once(); + + $manager->swap('default', $mailbox); + + expect($manager->register('default', ['host' => 'imap.new.example.com']))->toBe($manager); + + expect($manager->mailbox('default')->config('host'))->toBe('imap.new.example.com'); +}); + +it('leaves other cached mailboxes connected when forgetting a mailbox', function () { + $manager = new ImapManager([]); + + $default = Mockery::mock(MailboxInterface::class); + $default->shouldReceive('disconnect')->once(); + + $secondary = Mockery::mock(MailboxInterface::class); + $secondary->shouldNotReceive('disconnect'); + + $manager->swap('default', $default); + $manager->swap('secondary', $secondary); + $manager->forget('default'); + + expect($manager->mailbox('secondary'))->toBe($secondary); +}); diff --git a/tests/ImapServiceProviderTest.php b/tests/ImapServiceProviderTest.php new file mode 100644 index 0000000..e11b9ff --- /dev/null +++ b/tests/ImapServiceProviderTest.php @@ -0,0 +1,37 @@ +toBe($defaults); + + $manager = App::make(ImapManager::class); + + expect($manager->mailbox('default')->config('authentication')) + ->toBe($defaults['mailboxes']['default']['authentication']); +}); + +it('preserves the application mailbox configuration', function () { + $mailboxes = ['custom' => ['host' => 'imap.example.com']]; + + Config::set('imap', ['mailboxes' => $mailboxes]); + + (new ImapServiceProvider(App::getFacadeRoot()))->register(); + + expect(Config::get('imap.mailboxes'))->toBe($mailboxes); + expect(App::make(ImapManager::class)->mailbox('custom')->config('host')) + ->toBe('imap.example.com'); +}); + +it('preserves an explicitly empty mailbox configuration', function () { + Config::set('imap', ['mailboxes' => []]); + + (new ImapServiceProvider(App::getFacadeRoot()))->register(); + + expect(Config::get('imap.mailboxes'))->toBe([]); +});