Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions .github/workflows/webde-connection.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
name: WEB.DE connection
name: WEB.DE read-only integration
on:
workflow_dispatch:
push:
branches: [design/api-examples, main]
branches: [main, test/expand-imap-provider-coverage]
paths:
- '.github/workflows/webde-connection.yml'
- 'test/Provider/webde-connection.php'
- 'test/Provider/**'
- 'src/**'
- 'composer.json'
- 'composer.lock'
permissions:
contents: read
jobs:
Expand All @@ -21,8 +24,8 @@ jobs:
coverage: none
tools: composer:v2
- run: composer install --prefer-dist --no-interaction --no-progress
- name: TLS and authentication only
run: php -d zend.exception_ignore_args=1 test/Provider/webde-connection.php
- name: TLS, authentication and read-only IMAP checks
run: timeout 180 php -d display_errors=0 -d log_errors=0 -d zend.exception_ignore_args=1 test/Provider/webde-connection.php
env:
EMAIL: ${{ secrets.EMAIL }}
EMAIL_PASSWD: ${{ secrets.EMAIL_PASSWD }}
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,21 @@ runs integration tests against the disposable local Dovecot created by
address example, MIME round trips, retries/conflicts, cursors, automatic/manual flags,
trash and TLS hostname rejection. No personal mailbox is used by these tests.

The separate **WEB.DE connection** workflow uses GitHub secrets `EMAIL` and
`EMAIL_PASSWD`. It tests only verified TLS and authentication; it never selects a
mailbox or reads/writes messages. It runs on changes to its workflow/test on the PR
branch and main, and supports manual dispatch once available on the default branch.
The separate **WEB.DE read-only integration** workflow uses GitHub secrets `EMAIL`
and `EMAIL_PASSWD`. It verifies TLS/authentication, opens INBOX with EXAMINE, searches
UIDs and reads at most the three newest messages through `get()` and cursor pagination.
It checks manual mode, per-action Seen suppression and unchanged persistent flags.
Attachment descriptors are checked without downloading attachment content. A test
transport guard rejects all write operations. No mail content, addresses, identifiers,
server responses or exception traces are logged or uploaded as artifacts. An empty
INBOX passes connection/cursor checks and explicitly skips message-dependent checks.
Concurrent deletion or flag changes by another client can fail the live assertions.

The same read-only probe is exercised against seeded Dovecot in normal CI, which also
covers attachment byte limits and failed APPEND without automatic source flags.
The provider workflow runs on relevant source/dependency/test changes on main and the
`test/expand-imap-provider-coverage` branch, and supports manual dispatch. It is separate
from ordinary PR tests and never exposes secrets to fork pull requests.
WEB.DE must have IMAP access enabled; two-factor accounts may need an app password.
See [WEB.DE's server settings](https://hilfe.web.de/pop-imap/imap/imap-serverdaten.html).

Expand Down
49 changes: 48 additions & 1 deletion test/Integration/ImapTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,11 @@ public function testManualAutomaticAndPerActionOverrides(): void
public function testCursorDoesNotReplayHighestUidAndStaleReferencesFail(): void
{
$transport = new ImapTransport('localhost','test','test-secret',1993);
$status = $transport->select('INBOX');
$existing = $transport->search([]);
$cursor = (new Reference(hash('sha256','localhost:1993:test'),'INBOX',(int)$status['uidvalidity'],$existing === [] ? 0 : max($existing)))->encode();
for ($i=0;$i<3;$i++) { $transport->append('INBOX',Mime::build(new Email(from:'seed@example.org',subject:'Fixture '.$i),[])); }
$client = $this->client(); $batch = $client->listNew(limit:2);
$client = $this->client(); $batch = $client->listNew(after:$cursor,limit:2);
self::assertCount(2,$batch->emails);
self::assertNotContains('\\Seen',$batch->emails[0]->flags());
$next = $client->listNew(after:$batch->nextCursor,limit:2); self::assertCount(1,$next->emails);
Expand Down Expand Up @@ -110,4 +113,48 @@ public function move(int $uid,string $folder): array { return $this->inner->move
catch (\RuntimeException $error) { self::assertSame('Simulated lost APPEND response.',$error->getMessage()); }
self::assertNotNull($client->saveDraft($email)->id()); self::assertSame(1,$transport->appends);
}
public function testLiveProviderProbeAgainstDisposableInbox(): void
{
require_once dirname(__DIR__) . '/Provider/ReadOnlyProbe.php';
$transport = new ImapTransport('localhost','test','test-secret',1993);
for ($i=0;$i<3;$i++) {
$email = (new Email(from:'seed@example.org',subject:'Provider fixture '.$i))->withMarkdown('**Body**');
$file = Attachment::fromBytes('binary.bin','application/octet-stream',"\x00\xfffixture");
$transport->append('INBOX',Mime::build($email->attach($file),[$file]));
}
$results = \Phore\MailClient\Test\Provider\ReadOnlyProbe::run($transport,hash('sha256','localhost:1993:test'));
self::assertCount(5,$results);
self::assertContains('PASS: Persistent flags unchanged after reads.',$results);
}
public function testAttachmentLimitFailureAndReadLeaveFlagsUnchanged(): void
{
$client = $this->client();
$file = Attachment::fromBytes('binary.bin','application/octet-stream',"\x00\xff1234");
$saved = $client->saveDraft((new Email())->attach($file));
$fetched = $client->get($saved->id());
$remote = $fetched->attachments()[0];
self::assertNull($remote->content);
try { $client->openAttachment($fetched,$remote,maxBytes:5); self::fail('Oversized attachment accepted'); }
catch (\RuntimeException $error) { self::assertSame('Attachment exceeds byte limit.',$error->getMessage()); }
$stream = $client->openAttachment($fetched,$remote,maxBytes:6);
try { self::assertSame("\x00\xff1234",stream_get_contents($stream)); } finally { fclose($stream); }
self::assertNotContains('\\Seen',$client->get($saved->id())->flags());
}
public function testFailedAppendDoesNotSetAutomaticSourceFlags(): void
{
$manual = $this->client();
$source = $manual->saveDraft(new Email(from:'other@example.org',to:'me@example.org'));
$inner = new ImapTransport('localhost','test','test-secret',1993);
$transport = $this->createMock(Transport::class);
foreach (['select','search','metadata','part'] as $method) {
$transport->method($method)->willReturnCallback($inner->$method(...));
}
$transport->expects(self::once())->method('append')->willThrowException(new \RuntimeException('APPEND rejected'));
$transport->expects(self::never())->method('flag');
$client = new MailClient($transport,hash('sha256','localhost:1993:test'),from:'me@example.org');
try { $client->saveDraft($client->reply($source,'Reply')); self::fail('Expected APPEND failure'); }
catch (\RuntimeException $error) { self::assertSame('APPEND rejected',$error->getMessage()); }
$flags = $manual->get($source->id())->flags();
self::assertNotContains('\\Answered',$flags); self::assertNotContains('\\Seen',$flags);
}
}
90 changes: 90 additions & 0 deletions test/Provider/ReadOnlyProbe.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Phore\MailClient\Test\Provider;

use Phore\MailClient\MailClient;
use Phore\MailClient\Internal\{Transport,Reference};
use RuntimeException;

/** Shared by disposable-server CI and the live provider runner. Never returns mail data. */
final class ReadOnlyProbe
{
/** @return list<string> Public, content-free check results. */
public static function run(Transport $inner, string $account): array
{
// Fail closed even if a future client regression attempts an automatic write.
$transport = new class($inner) implements Transport {
public function __construct(private Transport $inner) {}
public function select(string $folder, bool $write = false): array {
if ($write) { throw new RuntimeException('Provider probe attempted write selection.'); }
return $this->inner->select($folder);
}
public function search(array $criteria): array { return $this->inner->search($criteria); }
public function metadata(int $uid): array { return $this->inner->metadata($uid); }
public function part(int $uid, string $section, int $maxBytes): string { return $this->inner->part($uid,$section,$maxBytes); }
public function append(string $folder, string $mime): void { throw new RuntimeException('Provider probe attempted APPEND.'); }
public function flag(int $uid, string $flag, bool $add): void { throw new RuntimeException('Provider probe attempted STORE.'); }
public function move(int $uid, string $folder): array { throw new RuntimeException('Provider probe attempted MOVE.'); }
};
$status = $transport->select('INBOX');
self::check((int)($status['uidvalidity'] ?? 0) > 0,'INBOX UIDVALIDITY');
$results = ['PASS: INBOX opened read-only with UIDVALIDITY.'];
$ids = $transport->search(['after'=>0]);
self::check(count($ids) === count(array_unique($ids)), 'Unique search UIDs');
foreach ($ids as $uid) { self::check(is_int($uid) && $uid > 0, 'Positive search UIDs'); }
sort($ids,SORT_NUMERIC);
$results[] = 'PASS: UID search.';
$client = new MailClient($transport,$account,mode:MailClient::MODE_MANUAL);
if ($ids === []) {
$batch = $client->listNew(limit:1);
self::check($batch->emails === [],'Empty INBOX batch');
$cursor = Reference::decode($batch->nextCursor,$account,true);
self::check($cursor->uid === 0 && $cursor->validity === (int)$status['uidvalidity'],'Empty INBOX cursor');
$results[] = 'PASS: Empty INBOX cursor.';
$results[] = 'SKIP: MIME reads, pagination and flag checks require at least one message.';
return $results;
}
// Read at most the three newest messages; never download attachment content.
$sample = array_slice($ids,-3);
$before = []; $references = [];
foreach ($sample as $uid) {
$before[$uid] = self::flags($transport->metadata($uid));
$id = (new Reference($account,'INBOX',(int)$status['uidvalidity'],$uid))->encode();
$references[] = $id;
$email = $client->get($id);
self::check($email->id() === $id,'Message reference round trip');
self::check(self::flags(['FLAGS'=>$email->flags()]) === $before[$uid],'Manual read flags');
foreach ($email->attachments() as $attachment) {
self::check($attachment->content === null && $attachment->sourceId === $id,'Lazy attachment descriptor');
}
}
$results[] = 'PASS: Bounded manual MIME reads and lazy attachment descriptors.';
// Exercise the public per-action switch too, without allowing any STORE.
$client->setAutomaticMode(true)->setAutomaticMode(false,'seen');
$cursor = (new Reference($account,'INBOX',(int)$status['uidvalidity'],$sample[0]-1))->encode();
foreach ($references as $id) {
$batch = $client->listNew(after:$cursor,limit:1);
self::check(count($batch->emails) === 1 && $batch->emails[0]->id() === $id,'Single-message cursor page');
self::check($batch->nextCursor === $id,'Cursor advances to returned UID');
$cursor = $batch->nextCursor;
}
// New arrivals are allowed, but the last UID must never replay (IMAP n:* edge).
foreach ($transport->search(['after'=>end($sample)]) as $uid) {
self::check($uid > end($sample),'No UID replay');
}
$results[] = 'PASS: Cursor pagination, no replay and automatic Seen disabled.';
foreach ($sample as $uid) {
self::check(self::flags($transport->metadata($uid)) === $before[$uid],'Persistent flags unchanged');
}
$results[] = 'PASS: Persistent flags unchanged after reads.';
return $results;
}
private static function flags(array $metadata): array
{
// Recent is session-specific, not a persistent user flag.
$flags = array_values(array_filter(array_map('strtolower',$metadata['FLAGS'] ?? []),static fn(string $flag): bool => $flag !== '\\recent'));
sort($flags,SORT_STRING); return $flags;
}
private static function check(bool $condition, string $label): void
{ if (!$condition) { throw new RuntimeException('Provider check failed: ' . $label); } }
}
28 changes: 18 additions & 10 deletions test/Provider/webde-connection.php
Original file line number Diff line number Diff line change
@@ -1,28 +1,36 @@
<?php
declare(strict_types=1);

use Phore\MailClient\MailClient;
use Phore\MailClient\Internal\ImapTransport;
use Phore\MailClient\Test\Provider\ReadOnlyProbe;

require dirname(__DIR__,2) . '/vendor/autoload.php';
require __DIR__ . '/ReadOnlyProbe.php';

// Separate provider smoke test: no SELECT, FETCH, APPEND, STORE or MOVE.
// Live read-only checks: no APPEND, STORE, MOVE or attachment downloads.
// Official endpoint: https://hilfe.web.de/pop-imap/imap/imap-serverdaten.html
$email = getenv('EMAIL'); $password = getenv('EMAIL_PASSWD');
if ($email === false || $email === '' || $password === false || $password === '') {
fwrite(STDERR,"Required GitHub secrets EMAIL and/or EMAIL_PASSWD are unavailable.\n"); exit(1);
}
$stage = 'TCP/TLS preflight';
// Avoid warnings containing server data; the catch reports only our fixed stage name.
set_error_handler(static function (int $severity, string $message): never {
throw new RuntimeException('Provider runtime warning.');
});
try {
$context = stream_context_create(['ssl'=>['verify_peer'=>true,'verify_peer_name'=>true,'peer_name'=>'imap.web.de']]);
$socket = @stream_socket_client('tls://imap.web.de:993',$errno,$error,20,STREAM_CLIENT_CONNECT,$context);
if ($socket === false) {
fwrite(STDERR,"FAIL: WEB.DE TCP/TLS preflight failed; authentication was not attempted.\n"); exit(1);
}
if ($socket === false) { throw new RuntimeException('TLS preflight failed.'); }
fclose($socket);
echo "PASS: WEB.DE TCP/TLS preflight with certificate verification.\n";
$client = MailClient::connect(host:'imap.web.de',username:$email,password:$password,port:993,mode:MailClient::MODE_MANUAL);
echo "PASS: WEB.DE IMAP TLS connection and authentication succeeded. No messages accessed.\n";
$stage = 'IMAP authentication';
$transport = new ImapTransport('imap.web.de',$email,$password,993);
echo "PASS: WEB.DE IMAP TLS connection and authentication succeeded.\n";
$stage = 'read-only INBOX/MIME/cursor/flag checks';
$account = hash('sha256','imap.web.de:993:' . $email);
foreach (ReadOnlyProbe::run($transport,$account) as $result) { echo $result . "\n"; }
} catch (Throwable $exception) {
// Do not print exceptions, usernames, server responses, credentials or traces.
if (str_starts_with($exception->getMessage(),'IMAP connection failed: ')) { fwrite(STDERR,$exception->getMessage() . "\n"); }
fwrite(STDERR,"FAIL: WEB.DE TLS connection or authentication failed. Check IMAP access, credentials/app password and runner connectivity.\n"); exit(1);
// Never print exception messages, usernames, responses, credentials or traces.
fwrite(STDERR,"FAIL: WEB.DE " . $stage . ". No message data logged.\n"); exit(1);
}
42 changes: 42 additions & 0 deletions test/Unit/MailClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,46 @@ public function testUnknownAutomaticActionIsRejected(): void
$client = new MailClient($this->createStub(Transport::class),'account');
$this->expectException(\InvalidArgumentException::class); $client->setAutomaticMode(true,'delete');
}
public function testInvalidLimitsFailBeforeIo(): void
{
$transport = $this->createMock(Transport::class);
$transport->expects(self::never())->method('select');
$client = new MailClient($transport,'account');
foreach ([0,501,-1] as $limit) {
try { $client->listNew(limit:$limit); self::fail('Invalid limit accepted'); }
catch (\InvalidArgumentException) { self::assertTrue(true); }
}
}
public function testFolderCursorAndMalformedCursorFailBeforeIo(): void
{
$transport = $this->createMock(Transport::class);
$transport->expects(self::never())->method('select');
$client = new MailClient($transport,'account');
foreach (['not-a-cursor',(new Reference('account','Drafts',1,0))->encode()] as $cursor) {
try { $client->listNew(after:$cursor); self::fail('Invalid cursor accepted'); }
catch (\InvalidArgumentException) { self::assertTrue(true); }
}
}
public function testProtocolControlledFlagsCannotBeSetExplicitly(): void
{
$transport = $this->createMock(Transport::class);
$transport->expects(self::never())->method('select');
$client = new MailClient($transport,'account');
$email = (new Email())->onServer((new Reference('account','INBOX',1,1))->encode(),[]);
foreach (['\\Deleted','\\Recent',"bad flag","x\r\nSTORE"] as $flag) {
try { $client->addFlag($email,$flag); self::fail('Invalid flag accepted'); }
catch (\InvalidArgumentException) { self::assertTrue(true); }
}
}
public function testProviderProbeReportsSkippedMessageChecksOnEmptyInbox(): void
{
require_once dirname(__DIR__) . '/Provider/ReadOnlyProbe.php';
$transport = $this->createMock(Transport::class);
$transport->expects(self::exactly(2))->method('select')->with('INBOX')->willReturn(['uidvalidity'=>7]);
$transport->expects(self::exactly(2))->method('search')->with(['after'=>0])->willReturn([]);
foreach (['metadata','part','append','flag','move'] as $method) { $transport->expects(self::never())->method($method); }
$results = \Phore\MailClient\Test\Provider\ReadOnlyProbe::run($transport,'account');
self::assertContains('PASS: Empty INBOX cursor.',$results);
self::assertSame('SKIP: MIME reads, pagination and flag checks require at least one message.',end($results));
}
}
Loading