Skip to content

Commit d381636

Browse files
committed
Properly utilize interface methods, remove base Connection class, and add logger interface
1 parent a127a82 commit d381636

14 files changed

+372
-462
lines changed

src/Connection/Connection.php

Lines changed: 0 additions & 411 deletions
This file was deleted.

src/Connection/ConnectionInterface.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,15 @@ interface ConnectionInterface
1313
*
1414
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-state-and-flow-diagram
1515
*/
16-
public function connect(string $host, ?int $port = null): void;
16+
public function connect(string $host, ?int $port = null, array $options = []): void;
1717

1818
/**
19-
* Check if the current session is connected.
19+
* Close the current connection.
20+
*/
21+
public function disconnect(): void;
22+
23+
/**
24+
* Determine if the current session is connected.
2025
*/
2126
public function connected(): bool;
2227

@@ -36,7 +41,7 @@ public function login(string $user, string $password): TaggedResponse;
3641
*
3742
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-logout-command
3843
*/
39-
public function logout(): ?TaggedResponse;
44+
public function logout(): void;
4045

4146
/**
4247
* Send an "AUTHENTICATE" command.

src/Connection/FakeConnection.php

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
88
use RuntimeException;
99

10-
class FakeConnection extends Connection
10+
class FakeConnection implements ConnectionInterface
1111
{
1212
/**
1313
* All calls recorded, keyed by method name.
@@ -39,7 +39,7 @@ public function expect(string $method, callable|array $matcher, mixed $response)
3939
/**
4040
* {@inheritDoc}
4141
*/
42-
public function connect(string $host, ?int $port = null): void
42+
public function connect(string $host, ?int $port = null, array $options = []): void
4343
{
4444
$this->getExpectationResponse(__FUNCTION__, func_get_args());
4545
}
@@ -63,9 +63,22 @@ public function authenticate(string $user, string $token): TaggedResponse
6363
/**
6464
* {@inheritDoc}
6565
*/
66-
public function logout(): TaggedResponse
66+
public function connected(): bool {}
67+
68+
/**
69+
* {@inheritDoc}
70+
*/
71+
public function startTls(): void
6772
{
68-
return $this->getExpectationResponse(__FUNCTION__, func_get_args());
73+
$this->getExpectationResponse(__FUNCTION__, func_get_args());
74+
}
75+
76+
/**
77+
* {@inheritDoc}
78+
*/
79+
public function logout(): void
80+
{
81+
$this->getExpectationResponse(__FUNCTION__, func_get_args());
6982
}
7083

7184
/**

src/Connection/ImapConnection.php

Lines changed: 234 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,78 +3,184 @@
33
namespace DirectoryTree\ImapEngine\Connection;
44

55
use DirectoryTree\ImapEngine\Collections\ResponseCollection;
6+
use DirectoryTree\ImapEngine\Connection\Loggers\LoggerInterface;
67
use DirectoryTree\ImapEngine\Connection\Responses\ContinuationResponse;
78
use DirectoryTree\ImapEngine\Connection\Responses\Response;
89
use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse;
910
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
11+
use DirectoryTree\ImapEngine\Connection\Streams\StreamInterface;
1012
use DirectoryTree\ImapEngine\Exceptions\CommandFailedException;
13+
use DirectoryTree\ImapEngine\Exceptions\ConnectionClosedException;
1114
use DirectoryTree\ImapEngine\Exceptions\ConnectionFailedException;
15+
use DirectoryTree\ImapEngine\Exceptions\ConnectionTimedOutException;
1216
use DirectoryTree\ImapEngine\Exceptions\Exception;
17+
use DirectoryTree\ImapEngine\Exceptions\RuntimeException;
1318
use DirectoryTree\ImapEngine\Support\Str;
1419

15-
class ImapConnection extends Connection
20+
class ImapConnection implements ConnectionInterface
1621
{
22+
use ParsesResponses;
23+
1724
/**
18-
* The current request sequence.
25+
* Sequence number used to generate unique command tags.
1926
*/
2027
protected int $sequence = 0;
2128

29+
/**
30+
* Constructor.
31+
*/
32+
public function __construct(
33+
protected StreamInterface $stream,
34+
protected ?LoggerInterface $logger = null,
35+
) {}
36+
37+
/**
38+
* Tear down the connection.
39+
*/
40+
public function __destruct()
41+
{
42+
$this->logout();
43+
}
44+
2245
/**
2346
* {@inheritDoc}
2447
*/
25-
public function login(string $user, string $password): TaggedResponse
48+
public function connect(string $host, ?int $port = null, array $options = []): void
2649
{
27-
$this->send('LOGIN', Str::literal([$user, $password]), $tag);
50+
$transport = strtolower($options['encryption'] ?? 'tcp');
2851

29-
return $this->assertTaggedResponse($tag);
52+
if (in_array($transport, ['ssl', 'tls'])) {
53+
$port ??= 993;
54+
} else {
55+
$port ??= 143;
56+
}
57+
58+
$this->setParser(
59+
$this->newParser($this->stream)
60+
);
61+
62+
$this->stream->open(
63+
$transport,
64+
$host,
65+
$port,
66+
$options['timeout'] ?? 30,
67+
$this->getDefaultSocketOptions(
68+
$transport,
69+
$options['proxy'] ?? [],
70+
$options['validate_cert'] ?? true
71+
)
72+
);
73+
74+
$this->assertNextResponse(
75+
fn (Response $response) => $response instanceof UntaggedResponse,
76+
fn (UntaggedResponse $response) => $response->type()->is('OK'),
77+
fn () => new ConnectionFailedException("Connection to $host:$port failed")
78+
);
79+
80+
if ($transport === 'starttls') {
81+
$this->startTls();
82+
}
3083
}
3184

3285
/**
33-
* {@inheritDoc}
86+
* Get the default socket options for the given transport.
3487
*/
35-
public function authenticate(string $user, string $token): TaggedResponse
88+
protected function getDefaultSocketOptions(string $transport, array $proxy = [], bool $validateCert = true): array
3689
{
37-
$credentials = base64_encode("user=$user\1auth=Bearer $token\1\1");
90+
$options = [];
3891

39-
$this->send('AUTHENTICATE', ['XOAUTH2', $credentials], $tag);
92+
if (in_array($transport, ['ssl', 'tls'])) {
93+
$options['ssl'] = [
94+
'verify_peer' => $validateCert,
95+
'verify_peer_name' => $validateCert,
96+
];
97+
}
4098

41-
return $this->assertTaggedResponse($tag);
99+
if (! isset($proxy['socket'])) {
100+
return $options;
101+
}
102+
103+
$options[$transport]['proxy'] = $proxy['socket'];
104+
$options[$transport]['request_fulluri'] = $proxy['request_fulluri'] ?? false;
105+
106+
if (isset($proxy['username'])) {
107+
$auth = base64_encode($proxy['username'].':'.$proxy['password']);
108+
109+
$options[$transport]['header'] = ["Proxy-Authorization: Basic $auth"];
110+
}
111+
112+
return $options;
42113
}
43114

44115
/**
45116
* {@inheritDoc}
46117
*/
47-
public function startTls(): void
118+
public function disconnect(): void
48119
{
49-
$this->send('STARTTLS', tag: $tag);
50-
51-
$this->assertTaggedResponse($tag, fn () => (
52-
new ConnectionFailedException('Failed to enable STARTTLS')
53-
));
120+
$this->stream->close();
121+
}
54122

55-
$this->stream->setSocketSetCrypto(true, $this->getCryptoMethod());
123+
/**
124+
* Check if the current stream is open.
125+
*/
126+
public function connected(): bool
127+
{
128+
return $this->stream->isOpen();
56129
}
57130

58131
/**
59132
* {@inheritDoc}
60133
*/
61-
public function logout(): ?TaggedResponse
134+
public function login(string $user, string $password): TaggedResponse
62135
{
63-
if (! $this->stream->isOpen() || ($this->meta()['timed_out'] ?? false)) {
64-
$this->close();
136+
$this->send('LOGIN', Str::literal([$user, $password]), $tag);
65137

66-
return null;
67-
}
138+
return $this->assertTaggedResponse($tag);
139+
}
68140

141+
/**
142+
* {@inheritDoc}
143+
*/
144+
public function logout(): void
145+
{
69146
try {
147+
// It's generally acceptable to send a logout command to an IMAP server
148+
// and not wait for a response. If the server encounters an error
149+
// processing the request, we will have to reconnect anyway.
70150
$this->send('LOGOUT', tag: $tag);
71151
} catch (Exception) {
72152
// Do nothing.
73153
}
154+
}
74155

75-
$this->close();
156+
/**
157+
* {@inheritDoc}
158+
*/
159+
public function authenticate(string $user, string $token): TaggedResponse
160+
{
161+
$credentials = base64_encode("user=$user\1auth=Bearer $token\1\1");
162+
163+
$this->send('AUTHENTICATE', ['XOAUTH2', $credentials], $tag);
76164

77-
return null;
165+
return $this->assertTaggedResponse($tag);
166+
}
167+
168+
/**
169+
* {@inheritDoc}
170+
*/
171+
public function startTls(): void
172+
{
173+
$this->send('STARTTLS', tag: $tag);
174+
175+
$this->assertTaggedResponse($tag, fn () => (
176+
new ConnectionFailedException('Failed to enable STARTTLS')
177+
));
178+
179+
$this->stream->setSocketSetCrypto(true, match (true) {
180+
defined('STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT') => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT,
181+
defined('STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT') => STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT,
182+
default => STREAM_CRYPTO_METHOD_TLS_CLIENT,
183+
});
78184
}
79185

80186
/**
@@ -409,4 +515,108 @@ public function done(): void
409515
fn (TaggedResponse $response) => CommandFailedException::make(new ImapCommand('', 'DONE'), $response),
410516
);
411517
}
518+
519+
/**
520+
* Set the stream timeout.
521+
*/
522+
public function setStreamTimeout(int $streamTimeout): Connection
523+
{
524+
if (! $this->stream->setTimeout($streamTimeout)) {
525+
throw new ConnectionFailedException('Failed to set stream timeout');
526+
}
527+
528+
return $this;
529+
}
530+
531+
/**
532+
* Read the next reply from the stream.
533+
*/
534+
public function nextReply(): Response
535+
{
536+
if (! $this->parser) {
537+
throw new RuntimeException('Connection must be opened before reading replies.');
538+
}
539+
540+
if (! $reply = $this->parser->next()) {
541+
$meta = $this->stream->meta();
542+
543+
throw match (true) {
544+
$meta['timed_out'] ?? false => new ConnectionTimedOutException('Stream timed out, no response'),
545+
$meta['eof'] ?? false => new ConnectionClosedException('Server closed the connection (EOF)'),
546+
default => new RuntimeException('Unknown read error, no response: '.json_encode($meta)),
547+
};
548+
}
549+
550+
$this->logger?->received($reply);
551+
552+
return $reply;
553+
}
554+
555+
/**
556+
* Send an IMAP command.
557+
*/
558+
public function send(string $name, array $tokens = [], ?string &$tag = null): void
559+
{
560+
if (! $tag) {
561+
$this->sequence++;
562+
$tag = 'TAG'.$this->sequence;
563+
}
564+
565+
$command = new ImapCommand($tag, $name, $tokens);
566+
567+
// After every command, we'll overwrite any previous result
568+
// with the new command and its responses, so that we can
569+
// easily access the commands responses for assertion.
570+
$this->setResult(new Result($command));
571+
572+
foreach ($command->compile() as $line) {
573+
$this->write($line);
574+
}
575+
}
576+
577+
/**
578+
* Write data to the connected stream.
579+
*/
580+
protected function write(string $data): void
581+
{
582+
$command = $data."\r\n";
583+
584+
$this->logger?->sent($command);
585+
586+
if ($this->stream->fwrite($command) === false) {
587+
throw new RuntimeException('Failed to write data to stream.');
588+
}
589+
}
590+
591+
/**
592+
* Fetch one or more items for one or more messages.
593+
*/
594+
public function fetch(array|string $items, array|int $from, mixed $to = null, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ResponseCollection
595+
{
596+
$prefix = ($identifier === ImapFetchIdentifier::Uid) ? 'UID' : '';
597+
598+
$this->send(trim($prefix.' FETCH'), [
599+
Str::set($from, $to),
600+
Str::list((array) $items),
601+
], $tag);
602+
603+
$this->assertTaggedResponse($tag);
604+
605+
// Some IMAP servers can send unsolicited untagged responses along with fetch
606+
// requests. We'll need to filter these out so that we can return only the
607+
// responses that are relevant to the fetch command. For example:
608+
// TAG123 FETCH (UID 456 BODY[TEXT])
609+
// * 123 FETCH (UID 456 BODY[TEXT] {14}\nHello, World!)
610+
// * 123 FETCH (FLAGS (\Seen)) <-- Unsolicited response
611+
return $this->result->responses()->untagged()->filter(function (UntaggedResponse $response) use ($items, $identifier) {
612+
// The third token will always be a list of data items.
613+
return match ($identifier) {
614+
// If we're fetching UIDs, we can check if a UID token is contained in the list.
615+
ImapFetchIdentifier::Uid => $response->tokenAt(3)->contains('UID'),
616+
617+
// If we're fetching message numbers, we can check if the requested items are all contained in the list.
618+
ImapFetchIdentifier::MessageNumber => $response->tokenAt(3)->contains($items),
619+
};
620+
});
621+
}
412622
}

src/Connection/ImapTokenizer.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace DirectoryTree\ImapEngine\Connection;
44

5+
use DirectoryTree\ImapEngine\Connection\Streams\StreamInterface;
56
use DirectoryTree\ImapEngine\Connection\Tokens\Atom;
67
use DirectoryTree\ImapEngine\Connection\Tokens\Crlf;
78
use DirectoryTree\ImapEngine\Connection\Tokens\EmailAddress;

0 commit comments

Comments
 (0)